6.27.2010

хроники django или homebudget part 5

За посленее время много чего натворил в проекте, попытаюсь все изложить.

  • довел до ума систему тегов
  • добавил South к проекту для удобной работы
  • переделал логику добавления/редактирования покупок
  • сделал красивое отображение месяца

Так судя по всему, первые два пункта получились достаточно обширными, так что сегодня только про них. HTML и javascript останутся на завтра.

South - удобный процесс миграции БД

  1. для начала я обновил себе django до последней дев-сборки (в репозиториях убунты все-то лежит 1.1).
    sudo apt-get remove python-django
    cd ~/devtools
    svn co http://code.djangoproject.com/svn/django/trunk/ django
    cd django
    python setup.py install
  2. стоит проверить что django проекты не потеряли работоспособность (:
  3. Устанавливаем south (для этого нужен mercurial)
    cd ~/devtools
    hg clone http://bitbucket.org/andrewgodwin/south/
    cd south
    sudo python setup.py install
  4. Добавляем south в installed_app (в settings.py)
    INSTALLED_APPS = (
         ...
         'south',
    )
  5. для параноиков - backup/restore существующей бд.
    • Backup:
      sudo -u databaseuser pg_dump homebudget > dump_db
    • Restore:
      sudo -u postres psql homebudget2 < dump_db
  6. создаем таблицы для south в проекте (это последний раз когда мы сделали syncdb(: )
    ./manage.py syncdb
  7. теперь если бд пустая (т.е. таблицы для проектов не созданы), добавляем проекты в installed_apps и выполняем
    ./manage.py schemamigration purchases --initial
    ./manage.py migrate purchases
    
  8. если данные в ней уже были, то: создаем начальную точку миграции
    ./manage.py schemamigration purchases --initial
    
    исправляем скрипт создания (комментим все строки с созданием таблиц)
    purchases/migration/0001_initial.py
    
    накатываем "псевдо" изменения
    ./manage.py migrate purchases
    
    теперь закоменченные строки можна убрать - для истории
  9. основной сценарий работы c South
    • делаем изменения в модели
    • создаем автоматическую точку миграции
      ./manage.py schemamigration purchases --auto
      
    • проверяем сгенеренный код в purchases/migration/XXXX_randomname.py
    • если что-то не так - правим код в методах backward и forward
    • в объекте orm находится замечательная django orm :)
      allpurchases = orm.Purchase.objects.all()
      
    • накатывание изменений на БД
      ./manage.py migrate purchases
    • посмотреть все миграции (звездочками помечены те которые успешно прошли)
      ./manage.py migrate purchases --list
    • если хочется смигрировать только данные
      ./manage.py datamigration purchases my_super_migration_of_data
    • или можно даже вот так
      ./manage.py schemamigration purchases --empty my_super_migration_of_data

Таги

  1. как уже упоминалось ранее таги были добавлены самопальные. Работают примерно так - в модели Purchase есть поле ManyToMany к модели Tag. Дефолтная реализация ManyToMany откидывается. В форму насильно добавляется новое CharField, переопределяются методы загрузки и сохранения формы, в которых как раз идут операции с полем m2m и CharField. Все делается в ручную. Это не никак не нравилось, так как чтобы переиспользовать эти таги пришлось бы прибегать к copy-paste, что не есть хорошо. В общем решил продолжить поиски компонент:
    • django-tagging - жутко наворочанный пакет, вроде бы есть все что нужно, все очень кастомно. но того как оно сделано я вряд ли скоро пойму. много-много черной магии для выполнения простых операций. много, очень много sql-я (без orm-а). Сразу же был откинут.
    • django-taggit проект поменьше чем предыдущий. но черная магия опять таки присутствует. в некоторых моментах я тупо не разобрался.
    • tagsfield от Ивана Сагалаева. Вот оно то что нужно. Идея проста - наследуемся от ManyToManyField и заменяем дефолтный widget на собственный. Кода ну очень мало, все предельно понятно. Выбрал я его. Конечно же понадобились изменения.
  2. первым делом копируем к себе tagsfield, добавляем его в installed_apps
  3. изначально проект работает так: в html записывается список всех тагов и список всех тагов объекта, через javascript все это управляется - юзер может добавлять и убирать теги обекта, может добавлять новые. Когда форма сабмитится список активных тагов сохраняется в поле m2m.
  4. для моего подхода (de.li.cious style таги) было сделано следующее
  5. поле MultipleHiddenInput было заменено на TextInput. Поясняю - при сабмите приходил список значений hidden-input-ов, а мне нужно только одно text-input поле.
    class TagsFormField(forms.Field):
        text_widget = forms.TextInput
  6. собственно загрузка списка тагов (метод render):
    'value_tags': " ".join(value_tags)
  7. из модели убрал поле created и переименовал модель в BaseTag (зачем - ниже)
    class BaseTag(models.Model):
        value = models.CharField(max_length=50)
        norm_value = models.CharField(max_length=50, editable=False)
  8. сохранение:
    def save_form_data(self, instance, data):
        splitted = data.strip().split(' ')
        tags = [self._get_tag(value) for value in splitted]
        setattr(instance, self.attname, tags)
  9. шаблон виджета:
    <div id="{{ id }}" class="tagsfield">
    <input type="text" name="{{ name }}" value="{{ value_tags }}" />
    </div>
  10. удалил больше не нужные файлы и зачистил js и css файлы. Еще были изменения, но все незначительные, так что лучше их смотреть в репозитории.
  11. теперь об одном ограничении оригинального кода - все таги хранятся в одной таблице. То есть если навешать таги на два класса - таги у них будут общие. Плохо. Для решения использовал еще один уровень абстракции, в приложении Purchases создал новый класс PurchaseTag который является наследником BaseTag. Теперь в бд будет две таблицы - одна для BaseTag из приложения TagField и одна для PurchaseTag из приложения Purchase. Таги для них не пересекаются :) Ну и собственно TagField одинаково хорошо работает для PurchaseTag класса. Тем более в последствии можно кастомизировать PurchaseTag и добавить к нему дополнительные поля. В файле purchases/models.py (старый класс Tag уже затерт)
    from homebudget.tagsfield import fields
    from homebudget.tagsfield.models import BaseTag
    class Purchase(models.Model):
        ... 
        tags = fields.TagsField('PurchaseTag')
    
    class PurchaseTag(BaseTag):
        pass
    
    class PurchaseItemForm(ModelForm):
        class Meta:
            model = Purchase
            exclude = ('purchase_date',)
    
  12. Ну и в коде везде где обращались к Tag теперь обращаемся к PurchaseTag, ну и из мелочи - поле name изменилось на value.
  13. Как проходила миграция данных (замена старых таго на новые). У меня база уже забита. Собственно для этого и прикручивался South (см. выше)
  14. Сначала была таблица для приложения TagField (чтобы не было никаких проблем)
  15. Потом было добавлена модель PurchaseTag в проект Purchases, в модель Purchase было добавлено поле ntags = fields.TagsField('PurchaseTag'), старое поле tags = models.ManyToManyField('Tag') не удаляя.
  16. Первая попытка миграции. Не работает (!!) и South отправляет на страницы http://south.aeracode.org/wiki/MyFieldsDontWork http://south.aeracode.org/docs/customfields.html#extending-introspection. Типа дописывайте сами себе правила на собственные поля. Ну ведь использовано поле отнаследованное от m2m, значит и вести оно должно себя также.. Значит спиздим определение ManyToManyField из кода south-а
    rules = [
    (
        (TagsField,),
        [],
        {
            "to": ["rel.to", {}],
            "symmetrical": ["rel.symmetrical", {"default": True}],
            "related_name": ["rel.related_name", {"default": None}],
            "db_table": ["db_table", {"default": None}],
            "through": ["rel.through", {"ignore_if_auto_through": True}],
        },
    ),
    ]
    from south.modelsinspector import add_introspection_rules
    add_introspection_rules(rules, ["^homebudget\.tagsfield\."])
    Поставим это прямо возле определения TagsField-а.
  17. Теперь создание точки миграции должно пройти успешно. Теперь у нас есть два поля тагов в модели. Делаем миграцию данных. Для каждого старого тега добавляем новый, и повторяем все соотношения. В общем выглядит это так:
    class Migration(DataMigration):
        def forwards(self, orm):
            plist = orm.Purchase.objects.all()
            for p in plist:
                old_tags = p.tags.all()
                for i in old_tags:
                    norm_value = i.name.lower()
                    tag, created = orm.PurchaseTag.objects.get_or_create(
                        norm_value = norm_value,
                        defaults = {'value': i.name}
                    )
                    p.ntags.add(tag)
                    p.tags.remove(i)
                p.save()
            old_tags = orm.Tag.objects.all()
            for i in old_tags:
                i.delete()
        def backwards(self, orm):
            "Write your backwards methods here."
            plist = orm.Purchase.objects.all()
            for p in plist:
                old_tags = p.ntags.all()
                for i in old_tags:
                    tag, created = orm.Tag.objects.get_or_create(
                        name = i.value,
                    )
                    p.tags.add(tag)
                    p.ntags.remove(i)
                p.save()
            old_tags = orm.PurchaseTag.objects.all()
            for i in old_tags:
                i.delete()
  18. Теперь удаляем модель Tag (старую) и поле tags. Апдейтим базу через South.
  19. Все хорошо, только поле называется ntags... Переименовываем поле. Смотрим в скрипт сгенеренный South-ом - не порядок, он пытается удалить нашу таблицу и создать новую. Исправляем его ошибку и накатываем изменения
    class Migration(SchemaMigration):
        def forwards(self, orm):
            db.rename_table('purchases_purchase_ntags', 'purchases_purchase_tags')
    
        def backwards(self, orm):
            db.rename_table('purchases_purchase_tags', 'purchases_purchase_ntags')
  20. Теперь все окей :)

Посты по теме:

6.14.2010

хроники django или homebudget part 4

Выдалось время и появилось настроение. Чего-то нового сваял :)
  1. Первой целью стояло добавление тагов. Очень полезная вещь для организации однотипных данных. Посмотрел что предлагает интернет на этот вопрос. Есть проект django-tagging, типа все готовое прямо из коробки под джангу. Не понравился по нескольким причинам - я не понял как он работает (какие-то мега-архи приемы используются для управления тагами, типа прямых запросов к бд), куча не нужных мне наворотов, находится он бете и имеет баг лист, да и народ от него не в восторге. Вообще вопрос построения тагов теоретически выглядит довольно простым - так что было решено отказаться и самому написать простенькое решение, для целей самообучения имхо лучше. Когда понадобится облако тагов - я подумаю в сторону перехода на сей стандартный плагин. Мое требование такое - чтобы было одно поле на форме, в котором записаны все таги итема через пробел, при сохранении это поле split-ится по пробелам, и к итему добавляются/убираются теги. Как узналось в процессе - это de.li.cious style таги.
  2. Отправной точкой является модель тага
    class Tag(models.Model):
        name = models.CharField('keyword, bookmark or term', max_length=100)
    
    и отношение many-to-many к модели purchase
    class Purchase(models.Model):
        ...
        tags = models.ManyToManyField('Tag')
    
  3. Обновляем схему базы. У нас появляется новая таблица Тагов, и таблица отношения таг-покупка (по foreign-ключам из тагов и покупок). Нужно заметить что таблица PurchaseItem не меняется, значит все правильно.
  4. Таким образом получилась следующая штука, которую лучше всего опробовать на shell-е:
    from homebudget.purchases.models import Purchase, Tag
    t = Tag(name='beer')
    t.save()
    p = Purchase.objects.filter(name=u'туборг')[0]
    p.tags.add(t)
    p.save()
    [i.name for i in p.tags.all()]
    [i.name for i in t.purchases_set.all()]
    
    Нужно заметить, что пока объект Purchase не материализован (не сохранен) в базе, у него нет поля tags. И наоборот, пока Tag не создан в базе у него нет поля purchases_set.
  5. Если сейчас оставить все как есть и посмотреть что находится на вьюхах добавления и редактирования, то может стать плохо. Так как я использовал ModelForm для генерации html-формы для Purchase - для всех полей подставилась стандартная реализация. И для поля ManyToManyField она тоже есть, и выглядит прям отвратительно (тупой селект). Минусы - совершенно не подходит под мое представление, и добавить новый тег - нужна отдельная форма. Так что нах стандарты!
  6. В PurchaseItemForm добавляем новое собственное поле, и удаляем из представление оригинальное поле tag модели PurchaseItem
    from django.forms import CharField
    class PurchaseItemForm(ModelForm):
        ...
        tags_inner = CharField()
        ...
        class Meta:
            model = Purchase
            exclude = ('tags',)
    
  7. Правильное поле есть, но оно никак не обрабатывается. Сначала мы его заполним данными. Как мне кажется - лучше всего это сделать через override функции __init__ формы. Сначала вызываем родной __init__ (в котором как мы сделали - уже нет оригинального поля tags), потом заполняем собственное поле tags_inner тагами, разделенными пробелами. Проверка на null-ы нужна как раз для создания и редактирования (пока объект Purchase не создан, у него нет поля tags).
    class PurchaseItemForm(ModelForm):
        ...
        def __init__(self, *args, **kwargs):
            super(PurchaseItemForm, self).__init__(*args, **kwargs)
            if kwargs.has_key('instance'):
                inst = kwargs['instance']
                if inst is not None and inst.pk is not None:
                    self.fields["tags_inner"].initial = " ".join([i.name for i in inst.tags.all()])
    
  8. Теперь сохранение. Я выбрал решение через override функции save (которая вызывается при сохранении данных формы из вьюхи). Сначала - обязательно вызываем базовый метод (чтобы объект Purchase наверняка сохранился в базе), потом - вытаскиваем из cleaned_data значения поля tags_inner. Далее понятные операции - split по пробелам, получаем все таги буквенно, потом создаем новые, удаляем несуществующие, и сохраняем финально.
    def save(self, *args, **kwargs):
            inst = super(PurchaseItemForm, self).save(*args, **kwargs)
            tags = self.cleaned_data["tags_inner"].strip().split(' ')
            for tag in tags:
                if len(Tag.objects.filter(name=tag))==0:
                    new_tag = Tag(name=tag)
                    new_tag.save()
                if len(inst.tags.filter(name=tag))==0:
                    inst.tags.add(Tag.objects.get(name=tag))
            for tag in inst.tags.all():
                if tag.name not in tags:
                    inst.tags.remove(tag)
            inst.save()
    
  9. Ну вроде бы с реализацией все - можно пробовать. Какие минусы сейчас есть: отсутствует валидация, отсутствует зачистка тагов к которым не привязан не один итем, поле tags теперь обязательное для заполнения, решение деревянное и не гибкое. Запишу в todo :) А пока нужно наполнить тагам базу.
  10. После того как база заполнена можно сделать вьюху для просмотра статистики по тагам. Во views.py добавляем purchases_by_tag
    def purchases_by_tag(request, tag_name):
        try:
            tag = Tag.objects.get(name=tag_name)
        except:
            raise Http404
        total = Decimal('0.0')
        for item in tag.purchase_set.all():
            total += item.price_total
        return render_to_response('purchases/tag_view.html', {
            'total': total,
            'tag': tag,
        })
    
    Добавляем новый темплейт tag_view.html
    <p>Tag: {{tag.name}}</p>
    <ul>
        {% for item in tag.purchase_set.all %}
            <li>
                {{item.name}}
                <b>{{item.price_total}}</b>
                {{item.purchase_date}}
                <a href='/purchases/edit/{{item.pk}}/'>Edit</a>
            </li>
        {% endfor %}
    </ul>
    <p>Count: {{tag.purchase_set.count}}</p>
    <p>Total: {{total}}</p>
    
    Добавляем новое правило урлов в urls.py:
    urlpatterns = patterns(
        ...
        (r'^purchases/tags/(?P\w+)/$', 'purchases_by_tag'),
    )
    
    Теперь по ссылке http://127.0.0.1:8000/purchases/tags/beer/ можно узнать когда, и на какую в сумму мы выпили пива :)
  11. Можно навести красивости, типа в daily view добавить в каждую строчку таги связанные с покупкой, типа
    Tags:
       {% for tag in p.tags.all %}
       <span>{{tag.name}}</span>
       {% endfor %}
    
  12. Для реализации тагов мне в один момент понадобился дебаг, использовал для этого модуль logging, очень удобный надо сказать, вот так его заставить работать (output идет в консоль локального сервера, что архи удобно):
    import logging
    logging.basicConfig(
        level = logging.DEBUG,
        format = '%(asctime)s %(levelname)s %(message)s',
    )
    
    а вот так легко использовать:
    logging.debug(p.tags)
    
  13. Еще по мелочам, из области рефакторинга, наткнулся на мега функцию locals - она возвращает все локальные переменные из области видимости, в виде словаря 'имя переменной'-'значение'. Это позволило значительно сократить код на render_to_response, до:
    return render_to_response('purchases/month_view.html', {
        'view_date': view_date,
        'dict': day_price,
        'total': month_total,
    })
    
    после:
    return render_to_response('purchases/month_view.html', locals())
    
    И это в каждой вьюхе, очень удобно. Конечно же пришлось поправить все темплейты и вьюхи на предмет именования переменных, но так все таки намного лучше.
  14. На последок добавил Master Page - по крайней мере так оно обзывается в ASP.NET. Нужно это для двух вещей - чтобы все страницы имели общую часть - в мастере, и чтобы меньше писать кода. После коротких поисков нашел что это в джанге реализуется через наследование темплейтов читать тут. В общем по простому: добавил master.html
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    <head>
        <link rel="stylesheet" href="content/css/main.css" />
        <title>Homebudget site: {% block title %} manage yourself {% endblock %}</title>
    {% block head %}{% endblock %}
    </head>
    
    <body>
    
    <div id="content">
    {% block content %}
    {% endblock %}
    </div>
    
    </body>
    </html>
    
    Теперь он будет у меня основным шаблоном. Любой другой шаблон может его расширять и дополнять. Все элементы block - можно использовать в дочернем шаблоне. Пока я полностью не разобрался, но то что есть сейчас меня устраивает. Вот например новый шаблон tag_view.html
    {% extends "purchases/master.html" %}
    
    {% block title %} view purchases by tag {% endblock%}
    
    {% block content %}
    <p>Tag: {{tag.name}}</p>
    <ul>
        {% for item in tag.purchase_set.all %}
            <li>
                {{item.name}}
                <b>{{item.price_total}}</b>
                {{item.purchase_date}}
                <a href='/purchases/edit/{{item.pk}}/'>Edit</a>
            </li>
        {% endfor %}
    </ul>
    <p>Count: {{tag.purchase_set.count}}</p>
    <p>Total: {{total}}</p>
    {% endblock %}
    
  15. Кстати говоря, в мастере используется ссылка на css-документ, он должен лежать в папке обозначенной в конфигурации как MEDIA_ROOT, и для этого пути должно быть правило в урл-реврайтере... Для чего оно нужно? чтобы в можно было вынести весь статический контент в отдельный сайт, необремененный обработчиками джанги и тп. В settings.py:
    MEDIA_ROOT = '/home/username/projects/homebudget/content/'
    ...
    MEDIA_URL = '/content/'
    
    И в urls.py
    from django.conf import settings
    if settings.LOCAL_DEVELOPMENT:
        urlpatterns += patterns("django.views",
            url(r"%s(?P.*)/$" % settings.MEDIA_URL[1:], "static.serve", { "document_root": settings.MEDIA_ROOT, })
        )
    
    Переменная LOCAL_DEVELOPMENT заведена на будующее.

Посты по теме: