18 декабря 2009 г.

Переезд

Наверное уже можно :)

http://demalexx.org

21 ноября 2009 г.

Сервисы Google и Python

Наткнулся в журнале Хакер на статью про использование сервисов Гугла в Питоне. Мощная штука! :) Можно работать, например, с блоггером, почтой, таблицами, документами и т.д.

Начать нужно с Google Data Protocol - страницы протокола, который используется для работы с API гугловых сервисов. Там есть библиотечки для разных языков.

Хотя, это API какое-то странное и не очевидное... Вот простой пример, который печатает в консоль все посты дефолтного блога пользователя demalexx@gmail.com.
# -*- coding:utf-8 -*-

'''This script prints all blog posts from blogger.com'''

from gdata import service
import gdata
import atom

blogger_service = service.GDataService('demalexx@gmail.com', 'password')
blogger_service.source = ''
blogger_service.service = 'blogger'
blogger_service.account_type = 'GOOGLE'
blogger_service.server = 'www.blogger.com'
blogger_service.ProgrammaticLogin()

query = service.Query()
query.feed = '/feeds/default/blogs'
feed = blogger_service.Get(query.ToUri())

blog_id = feed.entry[0].GetSelfLink().href.split("/")[-1]

feed = blogger_service.GetFeed('/feeds/' + blog_id + '/posts/default')

print feed.title.text
for entry in feed.entry:
    print "\t" + entry.title.text.decode('utf-8')
Вывод:
D:\Projects\python\google>get_blogger_posts.py
race1
    Сервисы Google и Python
    PAP Affiliate и eAccelerator
    SSH - аутентификация ключом
...

20 ноября 2009 г.

PAP Affiliate и eAccelerator

На сайте стоит eAccelerator и потом я поставил PAP Affiliate. С самого начала этот пап работал очень медленно. Настолько медленно, что оставлял после себя процессы httpd, работающие по несколько часов и потребляющие все ресурсы процессора. Единственный выход был в перезагрузке Апача, но это спасало не надолго.

Интересна реакция саппорта. Они просили проверить настройки электропочты. Типа проблема в этом. Хотя письма как раз отправлялись. Попробовал я поменять эти настройки - в настроках PAP сделал отправку писем не функцией mail(), а используя SMTP сервер - ничего лучше не стало, конечно.

Как временная мера - установил лимит на выполнение скриптов из папки PAP в 30 секунд :)

В общем в итоге всё оказалось просто. PAP не работает с eAccelerator. Вот страница у них на сайте, где говорится что они знакомы с проблемой, и ничего не собираются с ней делать: http://support.qualityunit.com/knowledgebase/post-affiliate-pro/troubleshooting/eaccelerator-module-installed-at-server.html.

Что бы решить проблему нужно выключить eAccelerator для скриптов из папки PAP. Для этого я создал файл /var/www/vhosts/centurysupplements.com/conf/vhost.conf (конфиг Апача для виртуального хоста), и написал туда такой текст:
<Directory /var/www/vhosts/centurysupplements.com/httpdocs/affiliate>
    php_admin_value eaccelerator.enable 0
</Directory>
Т.е. выключить eAccelerator для папки /var/www/vhosts/centurysupplements.com/httpdocs/affiliate (там установлен PAP).

Теперь PAP просто летает, а eAccelerator работает для всего остального сайта.

PS. Удалил eAccelerator, заменил его APC. Он, говорят лучше, плюс от создателей PHP. И PAP с ним работает.

15 ноября 2009 г.

SSH - аутентификация ключом

Вот здесь хорошая документация, первоисточник - http://the.earth.li/~sgtatham/putty/0.58/htmldoc/Chapter8.html.

В SSH можно авторизоваться либо паролем, либо ключом. Авторизация ключом более секурная, но настраивается на 5 минут дольше авторизации паролем (которая просто уже есть) :) А ещё, используя авторизацию ключом, можно избавиться от ввода пароля, правда это будет уже не так секурно.

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

По-хорошему приватный ключ тоже следует зашифровать. Но тогда при каждм соединении по SSH нужно будет вводить пароль для рассшифровки приватного ключа. Если же приватный ключ не шифровать, и указать в Putty имя пользователя для входа - вводить пароли не нужно будет вообще. А только следить что бы приватный ключ никто не стырил :)

Для генерирования ключей нужно взять программку PuttyGen. С её помощью сгенерировать пару ключей. Публичный ключ нужно поместить в файл .ssh/authorized_keys2 в домашней папке на сервере. Каждая строка файла - отдельный публичный ключ. Приватный ключ нужно сохранить куда-то на компьютере.

В Putty во вкладке SSH->Auth нужно указать приватный ключ в поле Private key file for authentication. Что бы Putty автоматически использовала имя пользователя для входа, его нужно прописать в поле Auto-login username, во вкладке Connection->Data.

Готово. Теперь при соединении аутенификация будет происходить по ключу, не нужно вводить ни логин, ни пароль. Удобно :)

HeidiSQL через SSH

Оказывается, настроить HeidiSQL (фронтэнд к MySql) для работы с удалённой базой через SSH оказалось не просто, а очень просто :) На удивление такой вариант работает гораздо быстрее чем phpMyAdmin, и потребляет совсем немного трафика. А ещё это более производительный вариант - серверу не нужно обрабатывать множество http запросов от phpMyAdmin. В общем, теперь можно забыть phpMyAdmin как страшный сон, и очень удобно работать с локальными полноценными приложениями типа HeidiSQL и другими.

Итак, что бы это заработало, нужно настроить туннелирование в Putty и настроить соединение в HeidiSQL.

Настройка Putty

Во вкладке SSH->Tunnels нужно добавить новый туннель, где Source это локальный порт, который будет связан с портом на сервере (который слушает MySQL). Порт 3306 занят локальным MySQL, поэтому укажем порт 3307. Destination это имя хоста и порт MySQL на сервере (по-умолчанию 3306), указываем localhost:3306. Всё, теперь все запросы на локальный порт 3307 будут перенаправлены на порт 3306 на сервере.

Настройка HeidiSQL

Создаём новое соединение, указываем хост localhost, и порт 3307. Ещё нужно указать логин/пароль для доступа к базам. Готово.

22 октября 2009 г.

Magento, products sitemap

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

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

Сначала я попробовал сделать категорию Glossary неактивной. Ничего не вышло, Sitemap по-прежнему отображал все продукты. Вариант "выключить" все продукты категории Glossary - вообще не вариант :) Т.е. нигде в настройках в админике, и в xml, нельзя задать ни список категорий для исключения, ни порядка сортировки (может я плохо искал?).

Значит надо делать это вручную. Можно либо поменять ядрёные файлы (оставив нетронутыми шаблонные файлы) - блок catalog/seo_sitemap_product, изменить его так, что бы он исключал категорию Glossary и сортировал продукты. Понятно, почему это плохой вариант, и мы так делать не будем :) Вместо этого мы будем менять только шаблонные файлы. Вообще решение этой проблемы добавит немало безнес-логики в шаблоны. Не очень хорошо, но с этим можно жить :) Зато без переделки ядрёных файлов.

Шаблон странички Sitemap находится в файле template/catalog/seo/sitemap.phtml. Существующий код берёт коллекцию всех продуктов (полученную в соответствующем блоке) и выводит все её элементы.

Мы добавим новый массив, который будет содержать продукты, теже объекты, что и коллекция. Но этот массив не будет содержать "продуктов" категории Glossary. После формирования массива вызовем функцию usort и отсортируем массив по названиям продуктов.

Что бы узнать, в каких группах находится текущий продукт, нужно вызвать метод getCategoryIds - он вернёт массив id категорий. Только нужно не забывать, что один и тот же шаблон template/catalog/seo/sitemap.phtml используется для вывода как продуктов, так и категорий. Если вызвать getCategoryIds у объекта-категории, он вернёт null. Нужно специально обработать этот случай.

Итак, вот код, формирующий новый массив без "продуктов" категории Glossary, и упорядоченный по именам объектов:
<?php $_items = $this->getCollection(); ?>
<?php
    // Original collection is not ordered. To sort items by name
    // we'll use array, then we'll sort it by name. Array will
    // contain the same objects as original collection
    $items = array();

    // Create our array, excluding Glossary category (id == 63)
    foreach ($_items as $_item) {
        if (is_array($_item->getCategoryIds()) && in_array(63, $_item->getCategoryIds()))
            continue;
        $items[] = $_item;
    }

    function cmp($a, $b) {
        return strcmp($a->getName(), $b->getName());
    }

    usort($items, cmp);
?>

Остальной код, по выводу продуктов, остаётся нетронутым, кроме перебора не коллекции $_items, а нового массива $items.

Сразу же после просмотра результата стал заметен баг - хотя продукты из категории Glossary теперь не отображаются, кол-во продуктов выводимое сразу перед списком продуктов (130 Item(s)) осталось прежним.

Поправить это оказалось чуть посложнее. Код, выводящий эту информацию, - шаблон template/page/html/pager.html. И используется он в catalog.xml вот так:
<catalog_seo_sitemap>
    ...
    <reference name="content">
        <block type="page/template_container" name="seo.sitemap.container" template="catalog/seo/sitemap/container.phtml">
            <block type="page/template_links" name="seo.sitemap.links" as="links" template="page/template/links.phtml"/>
            
            <!-- Magento adds block with template "page/html/pager.phtml" -->
            <block type="page/html_pager" name="seo.sitemap.pager.top" as="pager_top" template="page/html/pager.phtml"/>
            
            ...

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

Создаим этот шаблон - template/catalog/seo/sitemap/mypager.phtml.

Сначала посчитаем продукты, исключая продукты из категории Glossary:
<?php // Calc products that don't belong to Glossary category (63) ?>
<?php // We already hide glossary "products" from sitemap page, now we need ?>
<?php // to correct products count ?>
<?php $productsCountWithoutGlossary = 0; ?>
<?php foreach ($this->getCollection() as $_i) { ?>
<?php     if (is_array($_i->getCategoryIds()) && in_array(63, $_i->getCategoryIds())) continue; ?>
<?php     $productsCountWithoutGlossary++; ?>
<?php } ?>

Т.к. Sitemap отображает вообще всё что есть на одной странице (это сделано уже давно), то и весь код пейджера, рисующий номера страниц, не нужен - оставим только тот код, который выводит кол-во элементов на одной странице:
<table class="pager" cellspacing="0">
    <tr>
        <td>
            <strong><?php echo $this->__('%s Item(s)', $productsCountWithoutGlossary) ?></strong>
        </td>
    </tr>
</table>

Ещё нужно сказать Magento что мы хотим использовать наш шаблон в файле catalog.xml:
<catalog_seo_sitemap>
    ...
    <reference name="content">
        <block type="page/template_container" name="seo.sitemap.container" template="catalog/seo/sitemap/container.phtml">
            <block type="page/template_links" name="seo.sitemap.links" as="links" template="page/template/links.phtml"/>
            
            <!-- Replace default template with ours "catalog/seo/sitemap/mypager.phtml" -->
            <block type="page/html_pager" name="seo.sitemap.pager.top" as="pager_top" template="catalog/seo/sitemap/mypager.phtml"/>
            
            ...

Последнее - кол-во продуктов выводится не только вверху, но и внизу. Для коррекции выводимого кол-ва так же просто поменяем шаблон в файле catalog.xml:
<catalog_seo_sitemap>
    ...
    <reference name="content">
        <block type="page/template_container" name="seo.sitemap.container" template="catalog/seo/sitemap/container.phtml">
            <block type="page/html_pager" name="seo.sitemap.pager.bottom" as="pager_bottom" template="catalog/seo/sitemap/mypager.phtml"/>
    ...

Вот, готово. Теперь Products Sitemap исключает продукты категории Glossary, и выводит правильное кол-во продуктов. Страница Category Sitemap не падает и выводит все категории :)

Изменённые файлы:
  • app/design/frontend/default/sunnyD/layout/catalog.xml
  • app/design/frontend/default/sunnyD/template/catalog/seo/sitemap.phtml
  • app/design/frontend/default/sunnyD/template/catalog/seo/sitemap/mypager.phtml

20 октября 2009 г.

Magento, права

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

Сейчас я добавил в config.xml модуля немного "кода", так, что теперь можно выбрать, может ли пользователь работать с пунктом меню или нет. Как-то так :) Информации по этому очень мало...
<?xml version="1.0"?>
<config>
    ...
    <adminhtml>
        <menu>
            <catalog module="catalog">
                <children>
                    <blogrewrite translate="title" module="blogrewrite">
                        <title>Blog Rewrite</title>
                        <action>blogrewrite/adminhtml_blogrewrite</action>
                    </blogrewrite>
                </children>
            </catalog>
        </menu>

        <acl>
            <resources>
                <admin>
                    <children>
                        <catalog>
                            <children>
                                <blogrewrite>
                                    <title>Blog Rewrites</title>
                                </blogrewrite>
                            </children>
                        </catalog>
                    </children>
                </admin>
            </resources>
        </acl>
    </adminhtml>
</config>

Я так понимаю, что т.к. мой модуль находится внутри пункта меню верхнего уровня Catalog, то нужно добавить его. А называется модуль (или пункт меню?) blogrewrite.

Как бы то ни было, теперь право просматривать страницу модуля BlogRewrites можно назначить в Admin - System - Permissions - Roles:
permissions.jpg
А на будущее надо бы по-лучше поизучать как работают права в Magento.

Magento, перемещение блоков на странице Shopping Cart

Вот схемка как надо сделать :)
checkout-cart-before.png
Т.е. переместить блоки снизу влево:
checkout-cart-after.png
Первое, что приходит в голову - в checkout.xml переместить блоки из одного места другое. К сожалению, всё не так просто - эти два блока так же имеют немного кода в файле template/checkout/cart.phtml, и блоки, объявленные в xml вызываются как раз из этого файла:
<!-- Discount Code block -->
<div class="shopping-cart-collaterals">
    <div class="col2-set">
        <?php echo $this->getChildHtml('crosssell') ?>
        <div class="col-2">
            <!-- Load block by name "coupon" -->
            <?php echo $this->getChildHtml('coupon') ?>
            <?php if (!$this->getIsVirtual()): echo $this->getChildHtml('shipping'); endif; ?>
        </div>
    </div>
</div>

<!-- Totals block -->
<div class="shopping-cart-totals">
    <?php echo $this->getChildHtml('totals'); ?>
    <?php if(!$this->hasError()): ?>
    <ul class="checkout-types">
        <?php echo $this->getChildHtml('methods') ?>
    </ul>
    <?php endif; ?>
</div>

Пришлось выдёргивать этот код и переносить его в свой новый файл - пришлось создать новый шаблон, на основе 2columns-left.phtml. Почему не использовать уже готовый файл - во-первых, ширина левой колонки в стандартном файле маловата для размещения блока Totals. Во-вторых, в этот файл нужно поместить код для отображения блоков слева (перемещённый из файла cart.phtml).

В общем, я сделал новый файл 2columns-left-checkout.phtml на основе 2columns-left.phtml, и назначил его странице Shopping Cart. Вот что там было изменено:
<?php echo $this->getChildHtml('breadcrumbs') ?>
<!-- start left -->
<div class="col-left side-col" style="width: 250px;">
    <!-- Show left column, take content from xml -->
    <?php echo $this->getChildHtml('left') ?>
    <!-- Make some html for Totals block -->
    <div class="box">
        <!-- Following code took from cart.phtml -->
        <!-- Block should be non-float and have 0 margin -->
        <div class="shopping-cart-totals" style="float: none; margin: 0;">
            <?php echo $this->getChildHtml('totals'); ?>
            <?php if(!$this->hasError()): ?>
            <ul class="checkout-types">
                <?php echo $this->getChildHtml('methods') ?>
            </ul>
            <?php endif; ?>
        </div>
    </div>
</div>
<!-- Set main block (content) width to 613px
     (default is a little bit more, therefore layout problems) -->
<div id="main" class="col-main" style="width: 613px;">
<!-- start global messages -->
    <?php echo $this->getChildHtml('global_messages') ?>
<!-- end global messages -->
<!-- start content -->
    <?php echo $this->getChildHtml('content') ?>
<!-- end content -->
</div>

Итак, мы добавили вывод блока Totals после вывода левой колонки. А так же немного модифицировали стили блоков (см. комментарии). Левая колонка выводит Discount Coupons, это задаётся полностью в checkout.xml. Вот как блок Discount Coupons переносится в левую колонку:
<checkout_cart_index>
    ...
    <remove name="right"/>
    <!-- We should not remove left column, comment that line -->
    <!--<remove name="left"/>-->
    <reference name="left">
        <action method="unsetChildren"/>
        <block type="checkout/cart_coupon" name="checkout.cart.coupon" as="coupon" template="checkout/cart/coupon.phtml"/>
    </reference>
    ...

Убираем удаление левой колонки, убираем всех лишних детей из левой колонки, добавляем блок с шаблоном блока Discount Coupon.

С перемещением блока получился небольшой затык. Хотя блоки, которые вызываются из шаблона (totals, methods), я скопировал из reference name="content" в reference name="left", всё отображалось совсем не как надо. А потом я сообразил, что в шаболоне эти блоки вызываются не "изнутри" блока left/content, а из "корня". После того как я переместил эти блоки в reference name="root" всё наконец заработало. Вот этот кусочек:
<reference name="root">
    <block type="checkout/cart_totals" name="checkout.cart.totals" as="totals" template="checkout/cart/totals.phtml"/>
    <block type="core/text_list" name="checkout.cart.methods" as="methods">
        <block type="checkout/onepage_link" name="checkout.cart.methods.onepage" template="checkout/onepage/link.phtml"/>
        <block type="checkout/multishipping_link" name="checkout.cart.methods.multishipping" template="checkout/multishipping/link.phtml"/>
    </block>
</reference>

В основном всё, блоки переместились снизу налево и отображают то, что и должны. Остались мелочи - удалить выводы блоков снизу (можно просто закомментировать вывод блоков прямо в cart.phtml), и поправить css, т.к. текущие правила расчитаны на то, что эти блоки будут внутри блоков с определёнными классами. Вот добавленный в boxes.css код:
/* Copy some styles from Discount Codes, it was in the middle of
   page, now it's on the left side */
.col-left .discount-codes h4 {
    background-image:url(../images/icon_asterick.gif);  padding-left:23px;
    min-height:16px;
    color:#e26703;
    background-repeat:no-repeat;
    background-position:0 0;
    padding:1px 0 1px 21px;
    text-transform:uppercase;
}

.col-left .discount-codes.box {
    margin-bottom:18px;
    padding:12px 15px;
    border:1px solid #D0CBC1;
    background:url(../images/base_mini_head_bg.gif) repeat-x #fff;
}

Ну и последнее - убираем вывод блока с классом shopping-cart-collaterals. Этот блок содержит не только Discount Coupons, но и кое-что ещё. Но его можно убрать целиком, потому что в текущей конфигурации весь остальной вывод выключен (в админке) - выводится только Discount Coupons.

Вот код, который убирает всё ненужное снизу страницы:
<?php /*
<div class="shopping-cart-collaterals">
    <div class="col2-set">
        <?php echo $this->getChildHtml('crosssell') ?>
        <div class="col-2">
            <?php echo $this->getChildHtml('coupon') ?>
            <?php if (!$this->getIsVirtual()): echo $this->getChildHtml('shipping'); endif; ?>
        </div>
    </div>
</div>

<div class="shopping-cart-totals">
    <?php echo $this->getChildHtml('totals'); ?>
    <?php if(!$this->hasError()): ?>
    <ul class="checkout-types">
        <?php echo $this->getChildHtml('methods') ?>
    </ul>
    <?php endif; ?>
</div>
*/ ?>

Просто комментируем это дело :)

Вот так за пару часов блоки переехали снизу в левую колонку.

Изменённые файлы:
  • app/design/frontend/default/sunnyD/layout/checkout.xml
  • app/design/frontend/default/sunnyD/template/checkout/cart.phtml
  • app/design/frontend/default/sunnyD/template/page/2columns-left-cart.phtml
  • skin/frontend/default/sunnyD/css/boxes.css

16 октября 2009 г.

Дополнительный контент на Checkout странице, левая колонка

В прошлый раз я писал как добавить свой контент в правую колонку на странице чекаута. Оказалось что это не совсем то, что нужно :) А нужно добавить левую колонку и уже туда поместить картинки. Вот так:
checkout-left.png
Это оказалось даже проще чем добавить контент в правую колонку. Только есть один нюанс - когда мы добавим новую колонку, размер средней колонки (с основным контентом) уменьшится. А в этой средней колонке есть элементы, которые расчитаны на строго определённый размер основной колонки, и размеры там задаются в пикселях. Это выпадающий список с адресами, поле ввода адреса-1 и адреса-2, и поля логина/пароля, если чекаут делается из под гостя.

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

Итак, для начала нужно поменять шаблон страницы чекаута на 3columns.phtml. Старый шаблон 2columns-right-checkout.phtml больше не нужен, его можно удалить.

Здесь же, недалеко от изменения шаблона, добавим наш шаблон с картинками в левую колонку:
<checkout_onepage_index>
    ...
    <!-- Mage_Checkout -->
    <!--<remove name="left"/>-->

    <reference name="root">
        <action method="setTemplate"><template>page/3columns.phtml</template></action>
    </reference>
    <reference name="left">
        <action method="unsetChildren" />
        <block type="core/template" name="images" template="callouts/checkout.phtml"/>
    </reference>
    ...

Здесь сначала мы убираем удаление левой колонки remove name="left". Потом устанавливаем для чекаута шаблон 3columns.phtml. Затем модифицируем блок с названием left - удаляем все добавленные в левую колонку блоки, добавленные где-то до нас - action method="unsetChildren". Если этого не сделать, в левой колонке будут отображаться разные блоки, вроде списка категорий, списка тэгов, и т.д. И, наконец, добавляем наш шаблон block type="core/template"...

Основная задача решена - страница чекаута превратилась в трёхколоночную, и левая колонка отображает наш шаблон с картинками.

Теперь нужно поменять размеры некоторых элементов.

Для начала поменяем размеры выпадающих списков с адресами. Эти элементы можно легко достать по их id - это billing-address-select и shipping-address-select. Добавим новый стиль в boxes.css:
#billing-address-select, #shipping-address-select {
    width: 420px;
}

Дальше нужно поменять размеры полей для ввода адреса на страницах Billing Information и Shipping Information. Эти поля возникают если в списке выбора адреса выбрать New Address.

Эти поля тоже имеют id, но они какие-то странные - shipping:street1, т.е. содержат двоеточие. У меня не получилось выбрать такие элементы с помощью css селектора #shipping:street1, поэтому пришлось добавить атрибут style прямо в эти самые тэги. Они находятся в файлах template/checkout/onepage/billing.phtml и template/checkout/onepage/shipping.phtml. Просто добавляем код style="width: 250px;" к input элементам, всего их 4 штуки.

Теперь - формы ввода логина/пароля, если пользователь делает чекаут как гость. Хотя эти поля можно вытянуть по id, я не стал этого делать, потому что точно такая же форма, такие же поля с такими же id, могут использоваться на другой форме, например, на форме логина пользователя. Поэтому я опять добавил тэг style прямо в тэг. Эта форма находится в файле template/checkout/onapage/login.phtml, всего два поля нужно обновить.

Например, тэг для пароля будет иметь такой вид:
<input type="password" class="input-text required-entry" id="login-password" name="login[password]" style="width: 200px;" />

И последнее - форма добавления комментария на последнем шаге. Она совсем чуть-чуть вылезает за разрешённые границы, но этого достаточно, что бы в IE всё поехало конкретно.

Файл template/checkout/onepage/agreements.phtml содержит этот элемент. Немного уменьшаем ширину, прописанную в атрибуте style, до 420px:
<ol class="checkout-agreements">
    <li>
        <label for="biebersdorfCustomerOrderComment"><?php echo Mage::helper('biebersdorfcustomerordercomment')->__('Your Comment for this Order') ?></label>
        <br/><textarea name="biebersdorfCustomerOrderComment" id="biebersdorfCustomerOrderComment" style="width: 420px; height: 100px;"></textarea>
    </li>
</ol>

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

Модифицированные файлы:
  • app/design/frontend/default/sunnyD/template/checkout/onepage/billing.phtml
  • app/design/frontend/default/sunnyD/template/checkout/onepage/shipping.phtml
  • app/design/frontend/default/sunnyD/template/checkout/onepage/login.phtml
  • app/design/frontend/default/sunnyD/template/checkout/onepage/agreemenst.phtml
  • app/design/frontend/default/sunnyD/layout/checkout.xml

15 октября 2009 г.

Дополнительный контент на странице Checkout

Нужно добавить две картинки на страницу чекаута, как на этой картинке:
checkout.png
Это оказалось не так просто, как казалось :)

Для начала сделаем небольшой файл-шаблончик с нужными картинками. Это файл app/design/frontend/default/sunnyD/template/callouts/checkout.phtml:
<div class="box">
    <script src="https://siteseal.thawte.com/cgi/server/thawte_seal_generator.exe"></script>
</div>

<div class="box">
    <img src="<?php echo $this->getSkinUrl('images/free-shipping.gif') ?>" alt="<?php echo __('Free Shipping') ?>" />
</div>

Первая картинка загружается динамически, для чего используется Javascript.

Первая попытка

Почему бы просто не добавить новый блок в checkout.xml? Для этого можно использовать встроенный блок core/template. Он очень удобен для вывода простых шаблонов без логики, ему нужно передать всего один параметр - шаблон, который надо вывести. И, т.к. шаблон простого чекаута состоит из контента и правого блока (шаблон 2columns-right.phtml), добавить наш новый блок к правой части страницы, как-то так:
<checkout_onepage_index>
    ...
    <reference name="right">
        ...
        <block type="core/template" name="images" template="callouts/checkout.phtml"/>
        ...
Это даже работает, и на первый взгляд мы имеем именно то что надо - справа появляются наши картинки из шаблона checkout.phtml.

Но на второй взгляд, когда нажать кнопку Continue что бы перейти к следующему шагу чекаута, правая часть страницы целиком перезагружается. html для правой страницы приходит в Ajax запросе. Этот html больше не содержит наших картинок.

Вторая попытка

Ладно, значит нужно поправить возвращаемый html, что бы он содержал наши картинки.

Для этого надо поправить checkout.xml, секцию checkout_onepage_progress. Нужно добавить наш блок, как мы это делали в секции checkout_onepage_index. Но что это? Наших картинок всё-равно нет!

В общем в итоге выяснилось, что к блоку нужно добавить параметр output и присвоить ему значение toHtml:
<checkout_onepage_progress>
    ...
    <block type="core/template" name="images" output="toHtml" template="callouts/checkout.phtml"/>
    ...

Да что же это? Теперь после кнопки Continue видно только одну картинку, которая загружается через тэг img. Второй картинки, загружаемой через Javascript, не видно. Более того, хотя js код пришёл в ответе от сервера, он пропал из DOM дерева. Какой-то фильтр?

В общем, попытка номер два тоже провалилась.

Успех

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

Javascript, работающий со страницей чекаута находится в файле opcheckout.js. Похоже, что код, который обновляет правую часть страницы, выглядит так:
...
reloadProgressBlock: function(){
    var updater = new Ajax.Updater($$('.col-right')[0], this.progressUrl, {method: 'get', onFailure: this.ajaxFailure.bind(this)});
},
...
Т.е. он берёт первый элемент с классом col-right и устанавиливает ему новый html, полученный с сервера.

А вот где элемент с этим классом объявлен - файл template/page/2columns-right.phtml:
...
<!-- start right -->
<div class="col-right side-col">
    <?php echo $this->getChildHtml('right') ?>&nbsp;
</div>
<!-- end right -->
...
Не очень хорошо... Что бы мы ни поместили в правую часть страницы с помощью xml и reference name="right", либо прямо в данный шаблон, это всё будет внутри дива с классом col-right и, в итоге, будет затёрто на последующих шагах чекаута.

Выходит, единственный выход - сделать свой шаблон, основанный на 2columns-right.phtml. А в этом шаблоне как-то поместить наш шаблон с картинками "вне" правой части, что бы он не затёрся новыми данными.

Итак, для начала создадим новый шаблон 2columns-right-checkout.phtml, и укажем в checkout.xml что использовать нужно именно его.

Было:
<checkout_onepage_index>
    ...
    <reference name="root">
        <action method="setTemplate"><template>page/2columns-right.phtml</template></action>
Стало:
<checkout_onepage_index>
    ...
    <reference name="root">
        <action method="setTemplate"><template>page/2columns-right-checkout.phtml</template></action>
Далее модифицируем новый шаблон 2columns-right.checkout.phtml в части вывода правой части:
<!-- start right -->
<div class="side-col" style="float: right;">
    <div class="col-right side-col" <?php echo $this->getChildHtml('right') ?></div>

    <div class="col-right side-col">
        <?php echo $this->getChildHtml('images') ?>
    </div>
</div>
<!-- end right -->
Смысл такой. Создаём новый div-обёртку, которая будет содержать правую колонку. Мы не можем назначит ему класс col-right, потому что тогда контент этого дива затрётся. Но мы можем оставить ему класс side-col (который просто установливает ширину колонки, сейчас 195px). И добавляем свойство float: right что бы наш див-обёртка распологался справа.

Затем добавляем внутрь этого дива-обёртки сначала исходную правую часть getChildHtml('right'), затем наш шаблон с картинками getChildHtml('images').

Теперь оба дива могут иметь оригинальные классы side-col и col-right. При переходе между шагами будет обновляться содержимое первого блока с классом col-right, а второй блок с нашими картинками останется нетронутым на протяжении всего чекаута. Что и требовалось :)

И ещё - что бы код getChildHtml('images') отработал и отобразил наш блок core/template с шаблоном с картинками, нужно его добавить в checkout.xml внутри reference name="root". И ещё добавить свойство as, равное images. Это images и есть то имя, по которому getChildHtml() найдёт наш блок. Получаем:
<checkout_onepage_index>
    ...
    <reference name="root">
        ...
        <block type="core/template" name="images" as="images" template="callouts/checkout.phtml"/>
        ...
Вот, всего несколько часов работы и страничка чекаута содержит наш допольнительный шаблон. Всё в рамках приличия, ядрёные файлы не модифицированы, все изменения только в папке с пользовательской темой.

Magento, список категорий

В теме Modern есть список категорий, отображаемый сверху. Если навести на категорию - выскочит список подкатегорий. Красивый и удобный список.
top-links.png
Только есть проблема с этим списком - он показывает все активные категории. Нет возможности через админку/xml убрать из этого списка некоторые категории, например, категорию Glossary. Вообще-то Glossary уже скрывается, а сейчас нужно скрыть категорию Out of Stock.

Но можно скрывать категории с помощью css, и воспользоваться для этого особенностью этого списка - для каждой категории генерируется уникальный класс, совпадающий с названием категории. Т.о. для того, что бы "убрать" категорию из этого списка, нужно просто добавить новый класс в какой-либо css файл.

Структура списка имеет вид:
<ul id="nav">
    <li class="level0 nav-category-name">...</li>
</ul>
Категория Out of Stock будет иметь класс nav-out-of-stock. Добавим новый стиль в файл skin/frontend/default/sunnyD/css/menu.css:
/* Hide Out Of Stock category */
#nav li.level0.nav-out-of-stock {
    display: none;
};
Это правило будет применено к элеменам li с классами nav-out-of-stock и level0, которые являются дочерними по отношению к элементу с id равным nav. Это и есть категория Out of Stock.

14 октября 2009 г.

Windows Seven - Windows XP

Перешёл с семёрки на XP. Наконец-то, и я этому рад! :)

Установленное ПО

После переустановки винды нужно переустановить вагон с тележкой разного софта... Утомительное же это занятие...

Drivers (Wireless, Video), WinRar, foobar2000, Miranda, Skype, Opera, Firefox (+plugins), Total Commander, Notepad++, Video Codecs (+player Media Player Classic), .Net Framework, Java, Flash Player, Pdf viewer, Djv viewer, Python (+modules: setup-tools, MySQL, mechanize, django, djang-debug-toolbar), Php, MySQL, HeidiSQL, Apache Web Server, Aptana, NuSphere PhpEd, oDesk Team, F.lux, uTorrent, John's Background Switcher, Dropbox, Lingvo.

Это только то, что сейчас установлено, что-то может забыл.

А ещё нужно настроить эти программы как удобно, а некоторые - подружить (Php с сервером, отладчик от PhpEd'а с Php, и т.д.). Хорошо что некоторые программы легко переносятся со всеми настройками :)

Что стало лучше

Всё стало работать быстро! Я много читал отзывов, в которых в основном говорилось что семёрка работает как минимум не медленнее хрюши. Всякие Fetch/Prefetch механизмы, кеши-шмеши... А толку?

Сейчас я на каждом действии замечаю разницу между семёркой и XP. Попасть в Панель управления - в семёрке 5 секунд, в XP - 1 секунда. Запустить приложение, в семёрке - от секунды, в XP - от меньше чем за секунду (иногда мгновенно, чего на семёрке не наблюдал никогда).

Ещё в семёрке иногда заедал курсор, если например попытаться развернуть программу, свёрнутую пару часов назад. Т.е. всё тормозило секунд 5-10-20-30, было невозможно работать, пока ОС не сделает свои дела по развёртыванию проги... Чаще чем курсор заедала музыка... Странно зависала миранда секунд на 5 во время создания окошка с чатом.

В общем, в семёрке всё работало с жуткими задержками на каждое действие, в XP всё работает моментально, по сравнению с семёркой :)

Чего будет не хватать

Superbar, наследник Taskbar, - очень удобная штука!

Интерфейс Aero. Это когда всё как будто сделано из стекла, всё полупрозрачное и размазанное :) Полупрозрачный Superbar это красиво :)

Шрифты. Шрифты в семёрке - загляденье! К сожалению, в XP это не так.

Драйвера. Семёрка нашла все устройства на моём Dell Inspiron 1525 "из коробки".

Что будет с радостью забыто

Тормоза, но об этом уже было :)

Несовместимости с некоторыми программами. Т.е. как бы самих несовместимых программ очень мало... Но foobar2000, например, мог работать только в режиме совместимости (я использую старую версию, потому что в новых версиях нет нужного мне плагина), а для того, что бы запустить такую программу, нужно будет ответить Yes на вопрос UAC. Неприятная мелочь.

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

Некоторые другие программы так же хранят настройки в папке установки, что приводит к таким же последствиям (foobar2000, Miranda, ...).

Итог

Windows Seven - хорошая операционка. Всё что было сказано выше относится к публичной бета-версии. Всё было стабильно, красиво, даже приятно, если отбросить странные тормоза :) Её смело можно ставить через пару лет, плюс желательно на новое железо.

Общие впечатления такие, что семёрка добавляет некоторые приятные мелочи. Не больше...

7 октября 2009 г.

https и warning

В интернет-магазине есть страница, которая должна передаваться по защищённому соединению, т.е. по https. Если эта страница делает хотя бы один http запрос, Firefox ругается, что "content is partially encrypted", или что-то такое. Я проверил, что бы все запросы были только по https. И у меня на Win 7 никаких предупреждений не было. Но у других людей, на линуксе, на маке, и даже на той же семёрке, Firefox ругался.

Что за странное дело, для проверки я использовал плагины Firebug (вкладку Net), и Tamper Data, для отслеживания всех запросов, и все они были по https!

Потом я запустил Explorer (восьмой), и он сразу показал окошечко, мол, "на этой секурной странице есть несекурные запросы, выполнять только секурные запросы?". Ещё IE позволил посмотреть несекурные запросы. И тогда ошибка раскрылась.

В коде страницы был вызов одного и тоже js скрипта, но один вызов был по https, другой по http (именно в таком порядке). Видимо, FF не загружал повторно один и тот же скрипт. Сначала он загружал скрипт по https, что и было видно в Firebug'е. А со второй загрузкой, по http он, вёл себя "undefined" - у меня он не выдавал предупреждений о несекурности страницы, у других людей выдавал.

Вот так, не следует больше одного раза загружать на страницу одни и те же данные :) И, как ни странно, не стоит забывать про IE, он сэкономил мне много времени.

6 октября 2009 г.

Wordpress, "Read the rest of this entry"

Если в тексте поста в Wordpress есть специальный тэг <!--more-->, то при выводе списка постов Wordpress выводит только текст до этого тэга, а сам тэг заменяет на ссылку на пост, с текстом Read the rest of this entry. Эта ссылка содержит указатель на якорь, т.е. на то место, где находится тэг more. Зачем-то понадобилось убрать из этой ссылки указание на этот якорь. Т.е. было <a href="/blog/2009/10/my-blog-post/#more-123">My Blog Post</a>, надо <a href="/blog/2009/10/my-blog-post/">My Blog Post</a>. Ну, надо - так надо.

Содержимое поста, и ссылка Read the rest (если нужно) выводится функцией the_content. Включить/выключить добавление якоря в ссылку нельзя через настройки. Поэтому остаётся единственный вариант - получить строку контента и удалить якорь.

Функция the_content сразу выводит содержимое. Но есть аналогичная функция get_the_content, которая возвращает контент, а не выводит. Проблема в том, что результат этой функции не содержит переносов строк, т.е. текст идёт сплошным потоком. Я подсмотрел как работает the_content - она применяет фильтр the_content перед выводом. Т.е. нам надо сделать тоже самое.

Итого, получаем код, который удаляет из ссылки якорь:
<?php $content = get_the_content('Read the rest of this entry &raquo;'); ?>
<?php $content = preg_replace('/\/#more-\d+/', '/', $content); ?>
<?php $content = apply_filters('the_content', $content); ?>
<?php print $content;  ?>

Деньги на банковской карте

После создания графика для баланса в телефоне, захотел сделать график баланса на банковской карте ВТБ24 :) Для этого можно использовать Телебанк.

Научить питон-робота работать с сайтом Телебанка было чуть сложнее чем с сайтом ИССА. Вот почему.

Форма логина перед отправкой данных вызывает JS код, который делает очень простую проверку введённых данных (просто что они есть), и выставляет скрытому полю js значение 1 (по-умолчанию - 0). Если серверу приходит форма где js == 0, он говорит что в браузере нет JavaScript и не хочет работать дальше. Хотя JS там выполняет далеко не главную задачи и без него вполне можно работать.

Немного поразбиравшись с mechanize, я научил робота выставлять в поле js значение 1:
browser.select_form(name = 'FormLogin')
browser['TextBoxName'] = LOGIN
browser['TextBoxPassword'] = PASSWORD

# There is `js` hidden readonly param, by default it's 0, and site
# says that JS is disabled. So we pretend that we have JS by setting
# `js`= 1. But first delete readonly flag 
browser.find_control('js').readonly = False
browser['js'] = '1'
На этом трудности кончились :)

Добавил новый скрипт во встроенный планировщик, теперь у меня два красивых графика :)


# -*- coding:utf-8 -*-

from mechanize import Browser
from time import strftime
import re
import os, sys

LOGIN_URL = 'https://www.telebank.ru/WebNew/Login.aspx'
ACCOUNT_URL = 'https://www.telebank.ru/WebNew/Accounts/Accounts.aspx'
DATA_FILE = os.path.dirname(sys.argv[0]) + '/history.txt'
LOGIN = 'Телебанк логин'
PASSWORD = 'Телебанк пароль'

browser = Browser()

# Open page with login form
#print('Opening login page...')
browser.open(LOGIN_URL)

# Fill in form fields - login/password
#print('Filling in login form...')
browser.select_form(name = 'FormLogin')
browser['TextBoxName'] = LOGIN
browser['TextBoxPassword'] = PASSWORD

# There is `js` hidden readonly param, by default it's 0, and site
# says that JS is disabled. So we pretend that we have JS by setting
# `js`= 1. But first delete readonly flag 
browser.find_control('js').readonly = False
browser['js'] = '1'

# Submit form
#print('Submitting form...')
r = browser.submit()
page = r.read().decode('cp1251')

# Check that site gave us "You logged in, but please wait"
try:
    page.index('Вход в систему выполнен')
except:
    print("! Login failed")
    exit(1)

# Open accounts URL
#print('Opening accounts page...')
r = browser.open(ACCOUNT_URL)
page = r.read().decode('cp1251')

match = re.search(ur'>(?P<money>\d+(\.|,)\d+)<', page, re.IGNORECASE)
if match:
    money = float(match.group('money').replace(',', '.'))
    f = open(DATA_FILE, 'a')
    f.write('%s, %.2f\n' % (strftime('%d.%m.%Y %H:%M'), + money))
    f.close()
    
    print('Card balance: %.2f rub' % money)
else:
    print("! Can't find balance info")
    print page

3 октября 2009 г.

Python backup, часть 3

Третья версия скрипта-бэкапа на питоне :) На этот раз архив с файлами для бэкапа не создаётся. Вместо этого файлы с одного места (исходные папки) перемещаются в другое место (папки с бэкапами) как есть. Для реальной работы, когда в день может поменяться всего пара десятков файлов, это гораздо выгоднее. DropBox будет загружать лишь эти несколько файлов, вместо громадного архива.

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

Теперь алгоритм такой. Берём исходную папку ("лево"), создаём папку в выходной папке ("право"), копируем слева-направо только изменившиеся папки/файлы, удалённые слева папки/файлы удаляем и справа. При этом справа будет создана папка, отображающая полный путь до папки слева.

Например, исходная папка d:\projects\python, папка назначения - d:\dropbox\backup, тогда реальная папка назначения будет такой - d:\dropbox\backup\d_\projects\python.

Всё остальное осталось от предыдущих версий - файлы-списки toBackup.lst, ignore.lst, extra.lst.

Скриншотик:
Free Image Hosting at www.ImageShack.us

Скрипт:
import sys
import os
import os.path
import tarfile
import time
import gzip
import copy
import shutil

Verbose = False

ignoreList = []

copiedFiles = 0
skippedFiles = 0
createdFolders = 0
deletedFolders = 0
deletedFiles = 0
errors = 0

def removeDir(path):
    '''
    Remove dir and all subdirs with all subfiles
    '''
    for root, dirs, files in os.walk(path, False):
        for f in files:
            os.unlink(os.path.join(root, f))
            
        for d in dirs:
            os.rmdir(os.path.join(root, d))
            
    os.rmdir(path) 
   
def copyFile(fromFilename, toFilename):
    '''
    Function will copy file only if toFilename is different than fromFilename.
    Size and modification date checked.
    
    Returns True if file been realy copied,
    return False if there was no real copy (files seems the same)
    '''
    copy = True
    if os.path.isfile(toFilename):
        fromStat = os.stat(fromFilename)
        toStat = os.stat(toFilename)

        copy = (fromStat.st_size != toStat.st_size) or (str(fromStat.st_mtime) != str(toStat.st_mtime))        

    # Copy file with stat info, including e.g. modification date
    if copy:
        shutil.copy2(fromFilename, toFilename)
        
    return copy

def createFolderStruct(path):
    '''
    Create struct (tree node) for folder tree
    '''
    return {'full_path': path,
            'files': [],
            'dirs': {}}

def makeDirTree(path, data):
    '''
    Creates folder tree in form of special struct
    '''
    for i in os.listdir(path):
        newPath = os.path.join(path, i)

        # Ignore some folders from proccessing
        if (i.lower() in ignoreList) or (newPath.lower() in ignoreList):
            continue
        
        if os.path.isfile(newPath):
            data['files'].append(i)
        elif os.path.isdir(newPath):
            data['dirs'][i] = createFolderStruct(newPath) 
            
            makeDirTree(newPath, data['dirs'][i])
            
def copyFolder(srcDirs, outDirs):
    '''
    Copy folder from "left" to "right", given folders trees srcDirs and outDirs.
    Right folder will be complete copy of left folder with minimum number of file operations
    (don't copy file if it's already exists and hasn't been modified)
    '''
    
    # Use global counters
    global copiedFiles
    global skippedFiles
    global createdFolders
    global deletedFolders
    global deletedFiles
    global errors    
    
    # Loop throught all src folders, s - current folder name
    for s in srcDirs['dirs']:
        # If src folder isn't exist in output folder
        if s not in outDirs['dirs']:
            # If there is file in output folder with name as folder in src folder - delete that file
            if s in outDirs['files']:
                # Delete file s
                fullFilename = os.path.join(outDirs['full_path'], s)
                os.unlink(fullFilename)
                if Verbose:
                    print('D %s' % fullFilename)
                deletedFiles += 1                                        
                
            outDirs['dirs'][s] = createFolderStruct(os.path.join(outDirs['full_path'], s))
            
            # Create output folder with src folder name                
            fullPath = os.path.join(outDirs['full_path'], s)
            os.mkdir(fullPath)
            if Verbose:
                print('M [%s]' % fullPath)
            createdFolders += 1

        # Go deep inside current folder
        copyFolder(srcDirs['dirs'][s], outDirs['dirs'][s])
    
    # Remove folders from right, that don't exist on left
    for o in outDirs['dirs']:
        if o not in srcDirs['dirs']:
            fullPath = os.path.join(outDirs['dirs'][o]['full_path'])
            removeDir(fullPath)
            if Verbose:
                print('D [%s]' % fullPath)
            deletedFolders += 1                

    # Remove files from right, that don't exist on left
    for o in outDirs['files']:
        if o not in srcDirs['files']:
            fullFilename = os.path.join(outDirs['full_path'], o)
            os.unlink(fullFilename)
            if Verbose:
                print('D %s' % fullFilename)
            deletedFiles += 1            

    # Copy files from left to right
    for s in srcDirs['files']:
        fullFrom = os.path.join(srcDirs['full_path'], s)
        fullTo = os.path.join(outDirs['full_path'], s)
        
        copied = copyFile(fullFrom, fullTo)
        if copied: 
            c = 'C'
            copiedFiles += 1 
        else: 
            c = '-'
            skippedFiles += 1
            
        if Verbose and copied:
            print('%s %s -> %s' % (c, fullFrom, fullTo)) 

if __name__ == "__main__":
    if (len(sys.argv) < 2):
        print("Usage: backup.py <output folder> [--verbose]")
        print('E.g.: "backup.py d:\\dropbox\\my dropbox\\backup\\"')
        exit(1)

    RootFoldersFile = "tobackup.lst"
    IgnoreFoldersFile = "ignore.lst"
    ExtraFile = "extra.lst"
    OutputFolder = sys.argv[1]
    
    try:
        Verbose = (sys.argv[2] == '--verbose')
    except:
        pass

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

    # Output folder should exist, or we should be able to create it
    if not os.path.isdir(OutputFolder):
        try:
            os.makedirs(OutputFolder)
        except:
            print("! Can't create output folder %s" % OutputFolder)
            exit(1)

    # Read root folders from file, skip empty lines
    rootFolders = [i.strip() for i in open(RootFoldersFile, "r").readlines()]
    if '' in rootFolders:
        rootFolders.remove('')

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

    # Create folders trees for each input folder
    for rootFolder in rootFolders:
        inputTree = createFolderStruct(rootFolder)
        makeDirTree(rootFolder, inputTree)

        # File will be placed under OutputFolder plus full path to src folder
        # E.g. src folder d:\projects\python will be copied to folder q:\backup\d_\projects\python
        #                 ^^^^^^^^^^^^^^^^^^                                    ^^^^^^^^^^^^^^^^^^            
        outFolder = inputTree['full_path']
        outFolder = outFolder.replace(':', '_')
        outFolder = os.path.join(OutputFolder, outFolder)

        print('[%s] -> [%s]...' % (rootFolder, outFolder))
            
        if not os.path.isdir(outFolder):
            try:
                os.makedirs(outFolder)
                print('M [%s]' % outFolder)
            except:
                print("! Can't create output folder [%s]" % outFolder)
                print("! Skip input folder %s" % rootFolder)
                errors += 1
                continue
        
        # Make folder tree for real output folder
        outputTree = createFolderStruct(outFolder)        
        makeDirTree(outFolder, outputTree)
        
        # Copy input folder to output folder
        copyFolder(inputTree, outputTree)
        
    # Read list of extra files
    if (os.path.isfile(ExtraFile)):
        extraList = [i.strip().lower() for i in open(ExtraFile, "r").readlines()]
        if '' in extraList:
            extraList.remove('')
        
        print('Extra files...')
        
        for file in extraList:
            outFileDir = os.path.join(OutputFolder, os.path.dirname(file).replace(':', '_'))
            if not os.path.isdir(outFileDir):
                try:
                    os.makedirs(outFileDir)
                except:
                    print("! Can't create output folder [%s]. Skip extra file %s" % (outFileDir, file))
                    errors += 1
                    continue
        
            fullTo = os.path.join(outFileDir, os.path.basename(file))
        
            copied = copyFile(file, fullTo)
            if copied: 
                c = 'C'
                copiedFiles += 1 
            else: 
                c = '-'
                skippedFiles += 1
                
            if Verbose and copied:
                print('%s %s -> %s' % (c, file, fullTo))
            
    print('==============================')
    print('Copied file(s):    %d' % copiedFiles)
    print('Skipped file(s):   %d' % skippedFiles)
    print('Created folder(s): %d' % createdFolders)
    print('Deleted folder(s): %d' % deletedFolders)
    print('Deleted file(s):   %d' % deletedFiles)
    print('Errors:            %d' % errors)
    print('==============================')    
    print("Done"

2 октября 2009 г.

Баланс в телефоне

Решил реализовать такую интересную и простую задачку - следить за балансом на телефоне, что бы был красивый график. Я использую БВК, у них есть ИССА - Интернет Служба Сервиса Абонента, через которую можно узнать состояние своего счёта.

Задача разбивается на две подзадачи - посмотреть состояние счёта в интернете, сохраняя эти данные куда-нибудь; построить график на основе этих данных.

Получение состояния счёта

Я написал небольшой скрипт на питоне, который логинится в ИССА и узнаёт состояние счёта. Далее он записывает эти данные, вместе с текущей датой/временем, в текстовый файл. Для работы с интернетом используется mechanize, этот веб-клиент, написанный на питоне, поддерживает куки, что необходимо для логина.

mechanize не входит в стандартную поставку питона, его нужно установить:
easy_install.py mechanize

Поиск баланса ведётся с помощью регулярных выражений по строке "Баланс вашего лицевого счёта равен xx руб.".
# -*- coding:utf-8 -*-

from mechanize import Browser
from time import strftime
import re
import os, sys

LOGIN_URL = 'http://issa.bwc.ru/'
ACCOUNT_URL = 'http://issa.bwc.ru/cgi-bin/cgi.exe?function=is_account'
DATA_FILE = os.path.dirname(sys.argv[0]) + '/history.txt'
PHONE_NUMBER = <phone number>
PASSWORD = <issa password>

browser = Browser()

browser.open(LOGIN_URL)
browser.select_form(name = 'num')
browser['mobnum'] = PHONE_NUMBER
browser['Password'] = PASSWORD
browser.submit()
r = browser.open(ACCOUNT_URL)

page = r.read().decode('cp1251')

match = re.search(ur'Баланс вашего лицевого счета равен (?P<money1>\d+).(?P<money2>\d)', page, re.IGNORECASE)
if match:
    money = int(match.group('money1')) + float(match.group('money2')) / 10 
    f = open(DATA_FILE, 'a')
    f.write('%s, %.1f\n' % (strftime('%d.%m.%Y %H:%M'), + money))
    f.close()
    
    print('Phone balance: %.1f rub' % money)
else:
    print page
Я добавил задание в стандартный планировщик Windows, который запускает этот скрипт каждые два часа.

График

Для рисования графика я взял библиотеку от Google google.visualization.AnnotatedTimeLine. Ей нужно передать данные, она сама нарисует красивый график, используя Flash. Для разбора файла-лога с балансами я написал скрипт на PHP. Страница index.php, которая ответственна за вывод графика, имеет такой вид:

<?php
    define('HISTORY_FILE', 'd:\projects\python\issa.bwc\history.txt');

    $history = array();
    $hist = explode("\n", trim(file_get_contents(HISTORY_FILE)));
    foreach ($hist as $_h) {
        $tmp = split('[. ,:]', $_h);

        if (!$tmp[0])
            continue;

        $history[] = array('day' => $tmp[0],
                           'month' => $tmp[1],
                           'year' => $tmp[2],
                           'hours' => $tmp[3],
                           'minutes' => $tmp[4],
                           'value' => intval($tmp[6]) + intval($tmp[7]) / 10);
    }
?>

<script type='text/javascript' src='http://www.google.com/jsapi'></script>
<script type='text/javascript'>
  google.load('visualization', '1', {'packages':['annotatedtimeline']});
  google.setOnLoadCallback(drawChart);
  function drawChart() {
    var data = new google.visualization.DataTable();
    data.addColumn('datetime', 'Date');
    data.addColumn('number', 'Phone balance');
    data.addRows([
    <?php foreach ($history as $_h) { ?>
      [new Date(<?php print $_h['year']; ?>, <?php print $_h['month']; ?>, <?php print $_h['day']; ?>, <?php print $_h['hours']; ?>, <?php print $_h['minutes']; ?>), <?php print $_h['value']; ?>]<?php if ($_h != end($history)) { ?>,<?php } ?>

    <?php } ?>
    ]);

    var chart = new google.visualization.AnnotatedTimeLine(document.getElementById('chart_div'));
    chart.draw(data, {displayAnnotations: true});
  }
</script>

<body>
    <div id='chart_div' style='width: 100%; height: 100%;'></div>
</body>

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

Заключение

Практическая ценность такой игрушки весьма сомнительна :) Потом, когда у меня появится свой VDS :) это нужно будет переместить туда.

1 октября 2009 г.

ЧПУ для тэгов в Magento

Задача - сделать ЧПУ адреса для тэгов, т.е. из /tag/product/list/tagId/253/ сделать /tag/weight-loss. Думаю другого способа, кроме реврайтов, нет, поэтому будем создавать по реврайту на тэг. А т.к. модуль, создающий реврайты, у нас уже есть (Blogrewrite, он делает реврайты для блоговых адресов), то вместо создания нового модуля воспользуемся существующим. В будущем надо бы сделать отдельный модуль, если можно - даже перехватывать создание нового тэга и автоматически делать реврайт. Но сейчас лениво :)

Контроллер

Итак, добавляем новую кнопку на страницу модуля Blogrewrite, которая будет создавать/обновлять реврайты для тэгов. Соответственно, добавляем новое действие в контроллер Blogrewrite:
<?php

class Mage_Blogrewrite_Adminhtml_BlogrewriteController extends Mage_Adminhtml_Controller_Action {
    ...
    public function maketagrewritesAction() {
    ...
    }
}
?>
Действие очень простое - нужно перебрать все тэги и на каждый создать реврайт. Для этого будем использовать модель tag/tag:
// Load all approved tags
$model = Mage::getModel('tag/tag');
$collection = $model->getResourceCollection()
    ->addStatusFilter($model->getApprovedStatus())
    ->addStoreFilter(Mage::app()->getStore()->getId())
    ->load();
Получили коллекцию тэгов, утверждённых, и относящихся к текущему магазину. Дальше перебираем эту коллекцию и обновляем реврайты:
foreach ($collection as $_tag) {
    $tagId = $_tag->getId();

    $idPath = "tag/$tagId";
    $requestPath = 'tag/' . Slugify($_tag->getName());
    $targetPath = "tag/product/list/tagId/$tagId";

    $this->_makeRewrite($idPath, $requestPath, $targetPath);
}
Наверное я плохо искал, но я не увидел как сделать slug адреса с помощью Magento. Поэтому я написал свою функцию, которую вынес в index.php. Она нам ещё пригодится для шаблонов.
function Slugify($url) {
    // remove all non-alphanumeric characters except for spaces and hyphens
    $res = preg_replace('/[^a-z0-9- ]/', '', strtolower($url));

    // remove double spaces and substitute the spaces with hyphens
    $res = str_replace(' ', '-', str_replace('  ', ' ', $res));

    return $res;
}
Готово. Теперь что бы создать реврайты для тэгов нужно зайти в админку, на странцу модуля Blogrewrite, и нажать кнопку Make Tag Rewrites.

Шаблоны

Теперь адреса вида /tag/weight-loss обработаются нормально, только вот они пока нигде не используются. Нужно поправить шаблоны, которые выводят тэги. Это файлы cloud.phtml, list.phtml и popular.phtml в папке app\design\frontend\default\sunnyD\template\tag\. Вывод адреса тэга имеет такой вид:
<a href="<?php echo $_tag->getTaggedProductsUrl() ?>"><?php echo $this->htmlEscape($_tag->getName()) ?></a>
Нам нужно заменить вызов функции getTaggedProductsUrl на свой код:
<a href="<?php print trim(Mage::getBaseUrl(), '/') . '/tag/' . Slugify($_tag->getName()) ?>"><?php echo $this->htmlEscape($_tag->getName()) ?></a>
Готово! :) Реврайты созданы и работают, шаблоны выводят ЧПУ.

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

Проблемы при установке модуля BlogRewrites на сервер

На локальной машине всё работало, но перестало на сервере. Почему же? А потому что была лишняя заглавная буква в названиях.
  • Название модуля. Было BlogRewrite, надо Blogrewrite. Названия модулей можно делать только с одной заглавной буквой в начале, что бы потом не было неожиданных проблемм. Название модуля используется во всех названиях классов модуля, и кое-где в конфигах. Везде BlogRewrite надо заменить на Blogrewrite.
  • Название класса контроллера. Было Mage_BlogRewrite_Adminhtml_BlogRewriteController, надо Mage_Blogrewrite_Adminhtml_BlogrewriteController.
  • Имена файлов. В Windows файловая система регистро-независима, в линуксах - зависима (что за бред!). Поэтому нужно переименовать все файлы и папки где есть BlogRewrite в Blogrewrite. Например, app/code/local/Mage/BlogRewrite должно стать app/code/local/Mage/Blogrewrite.

После таких простых переименовываний (и столько потраченного времени), всё заработало.

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

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

Интеграция Wordpress в Magento - ЧПУ, часть 2

После создания модуля, генерирующего реврайты для вордпресовских адресов, я подумал и решил, что такая идея не будет работать на 100%. Вот почему:
  • Кроме адресов постов, категорий, тэгов, и т.д., т.е. тех адресов, для которых мы делаем реврайты, есть другие адреса, такие, как action формы, отправляющей комментарий, адрес редактирования поста, адрес подписки на комментарии каждого поста. И это только те адреса, которые я заметил, при том все эти адреса ЧПУ, т.е. они просто не будут работать в Magento без реврайтов.
  • Некоторые страницы будут иметь так много постов, что они не будут влезать на одну страницу. Т.е. адреса "следующая страница с постами" не будут работать.
К счастью, я нашёл решение этих проблем, что не позволит выкинуть результат предыдущей работы с автосозданием реврайтов для блога :)

Тепер подробнее.

Проблема с ЧПУ

Сначала рассмотрим проблему со множеством ЧПУ.

Источник проблемы в том, что в настройках Wordpress указана ЧПУ схема адресов. Т.е. абсолютно все адреса, даже для которых мы не писали реврайты, будут ЧПУ. А зная, что такие адреса в Magento работать не будут, получается, что блог будет выдавать "страница не найдена" в неожиданных местах :)

Отсюда следует, что если поставить не-ЧПУ схему, все адреса станут не-ЧПУ, и начнут работать. Это и надо сделать.

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

Для этого я поступил не совсем красиво - вылез в файл index.php, что находится в самом корне Magento. Думаю, есть решение по-красивее, но я его не знаю :)

В этот файл я добавил следующий код (после включения хедеров вордпресса):
...
require_once $mageFilename;

define('WP_USE_THEMES', true);
require('wordpress/wp-blog-header.php');

// bof Blogrewrite module code
$oldPermalink = get_option('permalink_structure');

function ChangePermalink() {
    global $wp_rewrite, $oldPermalink;
    $wp_rewrite->set_permalink_structure("/%year%/%monthnum%/%postname%/");
}

function RestorePermalink() {
    global $wp_rewrite, $oldPermalink;
    $wp_rewrite->set_permalink_structure($oldPermalink);
}
// eof Blogrewrite module code

#Varien_Profiler::enable();
...
Т.е. сначала я запоминаю текущую схему адресов. Вообще-то это достаточно бесполезное занятие т.к. схема должна быть только "по-умолчанию", т.е. $oldPermalink == ''. Т.е. вместо запоминания текущей схемы можно использовать пустую строку. Сделал это, наверное, на будущее...

Затем я добавил две функции. Одна меняет схему адресов на ЧПУ (что автоматически меняет схемы адресов категорий, тэгов и т.д.), другая восставнавливает предыдущую схему (не-ЧПУ, пустая строка).

По вкусу можно добавить вызов RestorePermalink(); в конец файла :) Наверное так будет лучше.

Теперь, когда нам нужна ЧПУ схема, мы вызываем ChangePermalink(). Это мы будем делать, когда будем генерировать реврайты (т.к. для получения вордпресовских ссылок на посты/категории/и т.д. мы используем специальные вордпресовские функции, которые возвращают адреса, соответствующие текущей схеме). Так же мы будем вызывать эту функцию во время формирования блока с ссылками на посты, категории, тэги (который находится слева или справа). И последнее место - вывод контента - содержимого постов, список постов категории и т.д.

После того, как мы поработаем с ЧПУ, вызываем RestorePermalink() - восстанавливаем изменённую схему адресов.

Итак, файлы и код, которые используют эти функции:
  • Mage_Blogrewrite_Adminhtml_BlogrewriteController::makerewritesAction()

    Где-нибудь вначале метода нужно вызвать ChangePermalink, где-нибудь в конце - RestorePermalink.
  • Шаблон блока со списком постов/категорий/и т.д. находится в файле app/design/frontend/default/sunnyD/template/blog/menu.phtml. Т.к. мы создали реврайты для всех ссылок, которые выводятся в этом файле, то вызываем ChangePermalink в начале файла, RestorePermalink - в конце.
  • wordpress/wp-content/themes/Wordpress-theme/magento/magento.php

    Это файл-шаблон, используемый для отображения "контента", содержимого блога - списка постов, отдельного поста. Выводится по-середине страницы :)

    Те адреса, для которых мы писали реврайты, мы выводим с включёнными ЧПУ. Остальные адреса - с выключенными ЧПУ.

    Например, мы знаем, что ссылка на пост может быть ЧПУ. Поэтому код может выглядеть так:
    <?php wp_reset_query(); ?>
    
    <?php if (have_posts()) : ?>
    
      <?php while (have_posts()) : the_post(); ?>
    
       <div class="post" id="post-<?php the_ID(); ?>">
                    <?php ChangePermalink(); ?>
        <h2><a href="<?php the_permalink() ?>" rel="bookmark" title="Permanent Link to <?php the_title_attribute(); ?>"><?php the_title(); ?></a></h2>
                    <?php RestorePermalink(); ?>
    ...

Pagination

Ремонтирование разбиения на страницы оказалось проще. Когда мы получаем список категорий, постов, тэгов, месячных архивов, мы можем узнать количество постов, удовлетворяющих выбранному критерию. Ещё мы можем узнать выводимое количество постов на странице:
$postsPerPage = get_option('posts_per_page');
А, зная это, мы легко можем подсчитать количество страниц, необходимое для отображения всех постов, удовлетворяющих выбранному критерию, и добавить соответствующих реврайтов:
$pagesCount = ceil($totalPostsCount / $postsPerPage);
Вот как был модифицирован код:
  • Для категорий:
    // Update rewrites for categories
    $cats = get_categories();
    foreach ($cats as $_c) {
        $this->_makeRewrite('blog_cat/' . $_c->term_id,
            trim(substr(get_category_link($_c->term_id), strlen($baseUrl)), '/'),
            'blog/index/index/cat/' . $_c->term_id);
    
        // Also add rewrites for other pages, e.g. page/2, page/3 etc
        for ($i = 2; $i <= ceil($_c->count / $postsPerPage); $i++) {
            $pages++;
            $this->_makeRewrite('blog_cat/' . $_c->term_id . '/page/' . $i,
                trim(substr(get_category_link($_c->term_id), strlen($baseUrl)), '/') . "/page/$i",
                'blog/index/index/cat/' . $_c->term_id . '/page/' . $i);
        }
    }
  • Для тэгов:
    // Update rewrites for tags
    $tags = get_tags();
    foreach ($tags as $_t) {
        $this->_makeRewrite('blog_tag/' . $_t->term_id,
            trim(substr(get_tag_link($_t->term_id), strlen($baseUrl)), '/'),
            'blog/index/index/tag/' . $_t->term_id);
    
        // Also add rewrites for other pages, e.g. page/2, page/3 etc
        for ($i = 2; $i <= ceil($_t->count / $postsPerPage); $i++) {
            $pages++;
            $this->_makeRewrite('blog_tag/' . $_t->term_id . '/page/' . $i,
                trim(substr(get_category_link($_t->term_id), strlen($baseUrl)), '/') . "/page/$i",
                'blog/index/index/tag/' . $_t->term_id . '/page/' . $i);
        }
    }
  • Для месячных архивов:
    $query = "SELECT DISTINCT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts FROM $wpdb->posts $join $where GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY post_date DESC $limit";
    $archive = $wpdb->get_results($query);
    if ($archive) {
        $afterafter = $after;
        foreach ((array) $archive as $_a) {
            $id = sprintf("%4d%02d", $_a->year, $_a->month);
    
            $this->_makeRewrite('blog_month/' . $id,
                trim(substr(get_month_link($_a->year, $_a->month), strlen($baseUrl)), '/'),
                'blog/index/index/m/' . $id);
    
            // Also add rewrites for other pages, e.g. page/2, page/3 etc
            for ($i = 2; $i <= ceil($_a->posts / $postsPerPage); $i++) {
                $pages++;
                $this->_makeRewrite('blog_month/' . $id . '/page/' . $i,
                    trim(substr(get_month_link($_a->year, $_a->month), strlen($baseUrl)), '/') . "/page/$i",
                    'blog/index/index/m/' . $id . '/page/' . $i);
            }
        }
    }

Итог

Итак:
  • Выставляем схему адресов в Wordpress "по-умолчанию". Это гарантирует нам, что любые ссылки, сгенерированне вордпрессом будут работать сами по себе.
  • Во время генерации реврайтов для постов/категорий/тэгов/месячных архивов устанавливаем схему адресов в ЧПУ. Генерируем реврайты, не забываем про "многостраничные страницы". Благодаря реврайтам пользователь сможет зайти на блог по ЧПУ (это относится к постам, категориям, тэгам, месячным архивам).
  • Модифицируем шаблоны, генерирующие вордпресовские адреса, включая ЧПУ схему для тех элементов, для которых мы создали реврайт на предыдущем шаге, выключая ЧПУ для тех элементов, для которых реврайтов нет
В итоге в основном пользователь будет видеть ЧПУ. Остальные адреса, для которых мы не сделали реврайтов, будут не-ЧПУ. Т.е. теперь блог будет 100% рабочий, разве что не 100% ЧПУ.

PS. Ну и недостаток, который есть у данной схемы: смена структуры адресов не самая быстрая операция. При этом она может выполняться несколько десятков раз (при отображении 20 постов на одной странице смен будет 20 * 3 = 60). Ну, Magento сама по себе весьма не скоростная, будем надеяться эти новые смены никто не заметит :)

PPS. Ещё я добавил загрузку реврайта по Request Path, если реврайт не найден по Id Path. Это сделано для того, что бы код без проблем заработал в магазине, где уже есть некоторые реврайты, добавленные вручную.
protected function _makeRewrite($idPath, $requestPath, $targetPath, $permamentRedirect = false) {
    $this->_urlRewrite->loadByIdPath($idPath);
    if (!$this->_urlRewrite->getId())
        $this->_urlRewrite->loadByRequestPath($requestPath);
    ...
}
Код попытается найти реврайт по Id Path, скорее всего не найдёт (Id Path заполнялся вручную и не совпадает с генерируемым нашим модулем). Если затем не попробовать загрузить реврайт по Request Path, Magento упадёт при попытке создать новый реврайт с тем же Request Path, который уже есть в системе (Request Path однозначно идентифицирует реврайт, это уникальное поле). Добавляя дополнительную загрузку по Request Path мы уберегаем себя от подобных падений - вместо падения Magento загрузит уже существующий, добавленный вручную реврайт, и обновит его.

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

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

Новая версия скрипта для бэкапа. Или другая версия...

Логика работы с файлами-списками не поменялась. Поменялся только параметр скрипта - теперь это имя архива без расширения, скрипт сам добавит .tar.gz.

Новое:
  • Создание архива переместилось из внешнего архиватора внутрь скрипта. Создаётся tar.gz архив.
  • Все пути теперь системо-зависимы, т.е. скрипт должен работать на линуксах тоже
Кроме приятного нового, повились неприятный минус - скрипт стал жрать кучу памяти.

Сделать сжатый архив с помощью модуля tarfile не вышло. Архив создаётся и сжимается, но внтури архива пути почему-то съезжают.

Т.е. нужно создать архив d:\dropbox\my dropbox\backup\backup.tar.gz, он там появляется. Но если открыть этот gz архив, tar архив будет глубоко внутри по пути dropbox\my dropbox\backup\backup.tar. Т.е. полный путь до данных получается такой: d:\dropbox\my dropbox\backup\backup.tar.gz\dropbox\my dropbox\backup\backup.tar\.... В принципе с этим можно жить, но некрасиво...

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

Надо бы ещё покопать в эту сторону и найти лучшее решение...
import sys
import os
import os.path
import tarfile
import time
import gzip

__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\"")
        exit(1)

    RootFoldersFile = "tobackup.lst"
    IgnoreFoldersFile = "ignore.lst"
    ExtraFile = "extra.lst"
    OutputArchive = sys.argv[1]
    OutputArchiveTar = OutputArchive + ".tar"
    OutputArchiveTarGz = OutputArchive + ".tar.gz"

    # Only mandatory file is RootFoldersFile, check that it exists
    if (not os.path.isfile(RootFoldersFile)):
        print("%s doesn't exist" % RootFoldersFile)
        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()]

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

    if (os.path.isfile(OutputArchiveTarGz)):
        os.unlink(OutputArchiveTarGz)

    filesCount = 0

    # Open output archive for writing (we'll gzip it later)
    try:
        tar = tarfile.open(OutputArchiveTar, "w")
    except IOError as err:
        print(err)
        exit(1)

    lastTime = time.time()
    for rootFolder in rootFolders:
        for root, dirs, files in os.walk(rootFolder):
            for file in files:
                tar.add(os.path.join(root, file))
                filesCount += 1

                # Show number of processed files each second
                if (time.time() - lastTime >= 1):
                    print("%d files..." % (filesCount))
                    lastTime = time.time()

            # Skip ignored folders
            for dir in dirs:                
                if (dir.lower() in ignoreList or (os.path.join(root, dir).lower() in ignoreList)):
                    dirs.remove(dir)

    # Add some files from extra files list
    if (os.path.isfile(ExtraFile)):
        for file in [i.strip() for i in open(ExtraFile, "r").readlines()]:
            tar.add(file)
            
    tar.close()

    print("Compressing...")

    # Gzip output archive
    try:
        gz = gzip.open(OutputArchiveTarGz, "wb")
    except IOError as err:
        print(err)
        exit(1)
    
    gz.write(open(OutputArchiveTar, "rb").read())
    gz.close()

    os.unlink(OutputArchiveTar)
        
    print("Output: %s, %d file(s), %d bytes" % (OutputArchiveTarGz, filesCount, os.path.getsize(OutputArchiveTarGz)))
    print("Done")

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

Интеграция Wordpress в Magento - ЧПУ

Уже давно у нас в в Magento был внедрён Wordpress, специальным модулем-плагином для Magento. Но, используя такой метод внедрения, есть проблема с адресами в Wordpress - можно использовать только простые адреса, вида ?p=<номер поста>, ?cat=<категория> и т.д. Это связано с тем, что обработку всех адресов берёт на себя Magento, т.е. все адреса должны быть в специальном формате - <module name>/<controller name>/<controller action>[/<param1>/<value1>/<param2>/<value2>...]. Если же в Wordpress включить ЧПУ адреса, например, вида <year>/<month>/<post title>, такой адрес придёт в Magento, которая попытается найти модуль <year>, в нём найти контроллер <month>, и выполнить метод <post title>. Естественно ничего такого в Magento нет, поэтому будет ошибка "страница не найдена".

Тем не менее, в Magento можно использовать ЧПУ. Для этого надо писать реврайты. Magento сама создаёт по реврайту на каждый продукт и категорию. Rewrite говорит, какой произвольный адрес перенаправить на какой корректный "Magento-адрес". Например, адрес /benq-2220hd нужно заменить адресом /catalog/product/view/id/496.

Реврайты бывают "внтуренние" и "с редиректом". Внутренние работают внутри :) Т.е. пользователь ввёл один адрес, он видит его в адресной строке, а Magento внутри себя вызывает другой адрес для обработки (более точно - другое действие другого контроллера). Реврайт с редиректом наоборот сразу бросается в глаза - браузер пользователя перенаправляется с одного адреса на другой.

Но даже с реврайтами есть проблема. Модуль, использующийся для интеграции Wordpress в Magento, не позволял использовать ЧПУ. Я его немного переделал, и теперь стало возможным написать адрес вида blog/index/index/param1/value1/param2/value2, и эти параметры будут переданы Wordpress, который на их основе выполнит запрос. Т.е. раньше приходилось писать реврайты с редиректом на не-ЧПУ адрес (например, на /blog/?p=123), сейчас же можно писать "внутренние" реврайты. А подсмотреть, какие нужно передавать параметры, можно в документации на Wordpress. Т.е., если перейти по адресу /blog/index/index/p/123, Wordpress покажет пост с id=123. Кроме p есть другие параметры, cat - для категорий, tag - для тэгов, m - для архивов, и т.д.

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

Мы напишем модуль, который сам будет создавать реврайты на все посты/категории/тэги/архивы блога/rss. Делать это будет специальная волшебная кнопка "пыщь" в админке :)

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

Создадим модуль Blogrewrite в пространстве имён Mage. Что бы сделать свою страницу в админке нужно иметь такой config.xml:
<?xml version="1.0"?>
<config>
    <global>
        <helpers>
            <Blogrewrite>
                <class>Mage_Blogrewrite_Helper</class>
            </Blogrewrite>
        </helpers>
    </global>

    <admin>
        <routers>
            <Blogrewrite>
                <use>admin</use>
                <args>
                    <module>Mage_Blogrewrite</module>
                    <frontName>Blogrewrite</frontName>
                </args>
            </Blogrewrite>
        </routers>
    </admin>

    <adminhtml>
        <menu>
            <catalog module="catalog">

            <children>
                <Blogrewrite translate="title" module="Blogrewrite">
                    <title>Blog Rewrite</title>
                    <action>Blogrewrite/adminhtml_Blogrewrite</action>
                </Blogrewrite>
            </children>

            </catalog>
        </menu>
    </adminhtml>
</config>
В секции admin мы говорим, что наш модуль будет доступен по адресу Blogrewrite (www.example.com/Blogrewrite). Т.о. контроллер нашего модуля - Mage_Blogrewrite_Adminhtml_BlogrewriteController.

В секции adminhtml мы добавляем новый пункт меню, в меню Catalog, и говорим, какое действие нужно выполнить, когда пользователь нажмёт на этом пункте меню - модуль Blogrewrite, контроллер Mage_Blogrewrite_Adminhtml_BlogrewriteController, действие по-умолчанию - index.

Действие index всего лишь отображает блок Mage_Blogrewrite_Block_Adminhtml_Blogrewrite:
public function indexAction() {
    $this->_initAction();
    $this->getLayout()->getBlock('head')
         ->setCanLoadRulesJs(true);
    $this->_addContent($this->getLayout()->createBlock('Blogrewrite/adminhtml_Blogrewrite')
         ->setCanLoadRulesJs(true));
    $this->renderLayout();
}
Этот блок находится в файле Block/Adminhtml/Blogrewrite.php:
<?php
class Mage_Blogrewrite_Block_Adminhtml_Blogrewrite extends Mage_Adminhtml_Block_Template {
    public function __construct() {
        parent::__construct();
        $this->setTemplate('Blogrewrite/index.phtml');
    }
}
Блок всего лишь выводит шаблонный файл index.phtml, который должен быть в app/design/adminhtml/default/default/template/Blogrewrite/index.phtml. В шаблоне - одна кнопка, которая вызывает действие makerewrites нашего контроллера:
<div class="content-header">
    <table cellspacing="0">
        <tr>
            <td>
                <h3 class="head-dashboard"><?php echo $this->__('Blog Rewrite') ?></h3>
            </td>
        </tr>
    </table>
</div>

<form action="<?php print $this->getUrl('*/*/makerewrites'); ?>">
    <button type="submit">Make Blog Rewrites</button>
</form>
Итак, модуль готов, он виден в админке, форма с кнопкой отображается. Надо делать собственно добавление реврайтов

Создаём rewrites

Как создать реврайт? Для этого есть модель core/url_rewrite, мы можем получить её так:
Mage::getModel('core/url_rewrite');
Можно загрузить данные из базы по некоторым параметрам, нам вполне хватит загрузки по Id Path:
Mage::getModel('core/url_rewrite')->loadByIdPath($idPath);
Если запись с таким Id Path есть в базе, она загрузится в модель, иначе нет :)

После того как мы загрузили данные из базы (или если данных нет), нужно задать параметры реврайта:
Mage::getModel('core/url_rewrite')
    ->loadByIdPath($idPath)
    ->setIdPath($idPath)
    ->setRequestPath($requestPath)
    ->setTargetPath($targetPath)
    ->setDescription('Automagically generated, Blogrewrite module')
    ->setIsSystem(0);
И, наконец, сохраняем реврайт:
->save();
Для удобства вынесем создание/обновление реврайта в отдельную функцию:
protected function _makeRewrite($idPath, $requestPath, $targetPath, $permamentRedirect = false) {
    $this->_urlRewrite->loadByIdPath($idPath);
    $this->_urlRewrite->setIdPath($idPath)
          ->setRequestPath($requestPath)
          ->setTargetPath($targetPath)
          ->setOptions($permamentRedirect ? 'RP' : '')
          ->setDescription('Automagically generated, Blogrewrite module')
          ->setIsSystem(0);
    $this->_urlRewrite->save();
}
Ещё я добавил новый параметр $permanentRedirect, который, соответственно, делает реврайт редиректом (по-умолчанию реврайты создаются "внутренними").

Если сохранить реврайт так, как выше, без указания магазина (Store Id), он добавится для всех магазинов. Это хорошо :)

makerewritesAction

Создание реврайтов будет происходить в действии makerewrites контроллера Mage_Blogrewrite_Adminhtml_BlogrewriteController:
<?php
class Mage_Blogrewrite_Adminhtml_BlogrewriteController extends Mage_Adminhtml_Controller_Action {
...
    public function makerewritesAction() {
...
    }
...
}
?>
Что бы создать реврайт, нужно знать, для чего его создавать. Т.е. нужно перебрать все посты, категории, архивы, и т.д., узнать их адреса, и создать по реврайту, перенаправляя эти адреса на правильные.

Сейчас будет в основном Wordpress часть.

Wordpress

Мы будем пользоваться функциями Wordpress для получения ЧПУ постов/категорий и т.д. Отсюда следует два замечения:
  • Пермалинки в настройках Wordpress должны быть настроены на ЧПУ. Иначе мы будем делать ревайты на адреса вида ?p=123. Нам этого не надо, эти адреса и так работают.

    С другой стороны, схема ЧПУ может быть любой. Всё что нужно сделать после смены вида пермалинков - нажать кнопку "пыщь" на странице нашего модуля :)
  • Все функции, возвращающие адреса, возвращают полные, абсолютные адреса. Реврайты же нужно создавать на относительные адреса (иначе они просто не будут работать). Поэтому нужно удалять доменную часть. Для этого получаем базовый адрес Magento - вызываем метод Mage::getBaseUrl(Mage_Core_Model_Store::URL_TYPE_WEB), и получаем, например, http://www.example.com/. Далее при получении адреса для реврайта, будем отсекать эту часть:
    trim(substr(get_permalink(), strlen($baseUrl)), '/')
    Кроме того, нужно убрать последний слэш, без него реврайты тоже не сработают. trim это делает.

Перебор постов

Для перебора постов можно воспользоваться "циклом", loop. Но по-умолчанию Wordpress загружает лишь несколько постов в цикл. А нам надо все. Это делается вызовом метода query_posts. После этого запускаем цикл, перебирающий все посты. На каждой итерации добавляем/обновляем реврайт:
// Query all published posts
query_posts(array('post_status' => 'publish', 'showposts' => -1));
while (have_posts()) {
    the_post();

    // get_permalink returns full absolute url. We need to remove domain info.
    // Also remove trailing slash, it's important.
    // E.g. was http://www.example.com/blog/2009/02/title
    //   become                                             blog/2009/02/title
    $this->_makeRewrite('blog/' . get_the_ID(),
        trim(substr(get_permalink(), strlen($blogUrl) - 4), '/'),
        'blog/index/index/p/' . get_the_ID());

    $posts++;
}

Перебор категорий

Для получения всех категорий достаточно вызвать функцию get_categories. Для получения адреса категории есть функция get_category_link.
// Update rewrites for categories
$cats = get_categories();
foreach ($cats as $_c) {
    $this->_makeRewrite('blog_cat/' . $_c->term_id,
        trim(substr(get_category_link($_c->term_id), strlen($blogUrl) - 4), '/'),
        'blog/index/index/cat/' . $_c->term_id);
}

Перебор тэгов

Тэги так же доступны вызовом всего одной функции get_tags, а для получения адреса есть функция get_tag_url:
// Update rewrites for tags
$tags = get_tags();
foreach ($tags as $_t) {
    $this->_makeRewrite('blog_tag/' . $_t->term_id,
        trim(substr(get_tag_link($_t->term_id), strlen($blogUrl) - 4), '/'),
        'blog/index/index/tag/' . $_t->term_id);
}

Перебор архивов

Я посмотрел, как архивы выводятся у нас на сайте. Это задаётся в шаблонном файле app/design/frontend/default/sunnyD/template/blog/menu.phtml, а именно - вызов функции wp_get_archives('type=monthly'). Эта функция возвращает уже отформатированный html. К сожалению, нет нормального способа выбрать нужные мне ссылки. Пришлось скопировать код этой функции к себе и немного его переделать:
// Update rewrites for archive (from Wordpress core file wp-includes\general-template.php,
// function wp_get_archives, from line 753)
global $wpdb;
$defaults = array(
    'type' => 'monthly', 'limit' => '',
    'format' => 'html', 'before' => '',
    'after' => '', 'show_post_count' => false,
    'echo' => 1
);
$r = wp_parse_args('', $defaults);
$where = apply_filters('getarchives_where', "WHERE post_type = 'post' AND post_status = 'publish'", $r);
$join = apply_filters('getarchives_join', "", $r);
$query = "SELECT DISTINCT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts FROM $wpdb->posts $join $where GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY post_date DESC $limit";
$archive = $wpdb->get_results($query);
if ($archive) {
    $afterafter = $after;
    foreach ((array) $archive as $_a) {
        $this->_makeRewrite('blog_month/' . $_a->year . $_a->month,
            trim(substr(get_month_link($_a->year, $_a->month), strlen($blogUrl) - 4), '/'),
            'blog/index/index/m/' . $_a->year . $_a->month);
    }
}

RSS

Последние ссылки без реврайтов - подписка на новости. Их две - подписка на новые посты, и новые комментарии.
$feeds = trim(substr(get_bloginfo('rss2_url'), strlen($baseUrl)), '/');
$feedsComments = trim(substr(get_bloginfo('comments_rss2_url'), strlen($baseUrl)), '/');

$this->_makeRewrite('blog_feeds', $feeds, 'blog/index/index/feed/rss2');
$this->_makeRewrite('blog_comments_feeds', $feedsComments, 'blog/index/index/feed/comments-rss2');

Заключение

Итак, мы сделали по реврайту на посты, категории, тэги, месячные архивы и rss. Теперь пользователи будут видеть ЧПУ, относящиеся к блогу, в адресной строке браузера. Так же адреса, связанные с Wordpress, выводимые на странице магазина, будут ЧПУ (т.к. это указано в настройках Wordpress).

Но всё же есть одна неприятность. Страницы просмотра категории, или тэга, могут иметь несколько страниц. Например, blog/my-category/page/2. Такой адрес выдаст страницу "не найдено" :(