Материализованные дебаты

by Larin

Интро

Что главное в интернете? Полезная информация и общение. И все в сети строиться вокруг этих 2-х понятий.

Сегодня мы займемся общением =) Точнее инструментом для общения – написанием древовидных комментариев на Django. Созданием универсальных древовидных комментариев с добавлением при помощи Ajax.

Материализованные пути

Вначале расскажу об одном из представлений деревьев в БД. Материализованные пути (Materialized Path) – на мой взгляд, это представление наиболее полно подходит для реализации древовидных комментариев.

Идея состоит в том, что для каждой вершины дерева храниться полный путь от корня.

ID Title Path
1 Первый 1
2 Второй 1.2
3 Третий 1.2.3
4 Четвертый 4
5 Пятый 4.5

В итоге, сделав сортировку по полю Path мы получим следующее:

где уровень вложенности (отступа) равен количеству разделителей в пути.

Данное представление гораздо легче в понимании нежели вложенные множества (Nested Sets), но так же позволяет нам выбирать дерево целиком используя один простой sql-запрос. Я естественно, не говорю, что материализованные пути полноая замена вложенных множеств и наоборот. Нет. Для каждой задачи нужно искать подходящее решение. Универсальных инструментов не бывает.

Модель

И так приступим. Т.к. задача довольно простая – этап проектирования и построения диаграмм я пропущу. Будем считать, что он пройден. =)

Приступим сразу к построению Django-модели:


# -*- coding: utf-8 -*-
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes import generic
from people.models import People

class Comment(models.Model):
    path = models.CharField(max_length=255)
    text = models.TextField()
    date = models.DateTimeField(auto_now_add=True)
    user = models.ForeignKey(People, verbose_name=u"Пользователь")
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    content_object = generic.GenericForeignKey('content_type', 'object_id')

    class Meta:
        ordering = ['path']
        db_table = 'comment'
        verbose_name = u'Комментарий'
        verbose_name_plural = u'Комментарии'

    def __init__(self, *args, **kwargs):
        self.parent_id = None
        super(Comment,self).__init__(*args, **kwargs)

    @property
    def level(self):
        return max(0,len(self.path)/8-1)

    @property
    def html_level(self):
        return self.level * 3;

    def get_absolute_url(self):
        return '%s#comment%s' % (self.content_object.get_absolute_url() , self.id)

    def __unicode__(self):
        return self.text

    def save(self):
        super(Comment,self).save()
        if not self.path:
            if self.parent_id:
                try:
                    parent_path = Comment.objects.get(pk=self.parent_id).path
                except:
                    parent_path = ''
            else:
                parent_path = ''
            self.path =  '%s%08d' % (parent_path, self.id)
            super(Comment,self).save()

В принципе, код не сложный. И думаю, пояснить стоит только два момента:

  1. вместо путей с разделителями, используются пути состоящие из частей постоянно длины (8 символов), т.е. для нашего примера, путь до вершины 2 будет: 0000000100000002.
  2. комментарии у нас будут универсальными, т.е. мы сможем их “прикрутить” к любому объекту на сайте. Для этого используем Generic relations, входящие в состав Django.

Теперь смело можно выполнять:

# cd project_path
# python manage.py syncdb

И в нашей базе появится новая таблица (comment).

(Данная статья хоть и расчитана на начинающих программистов на Django, но основ работы с фраймворком не раскрывает, за основами советую обратиться сюда и сюда.)

Форма

И так с моделью все ясно. Перейдем к ее формам ))) Точнее, к форме добавления.


# -*- coding: utf-8 -*-
from django import forms

class CommentForm(forms.Form):
    parent_id = forms.IntegerField(widget=forms.HiddenInput(), initial = 0)
    message = forms.CharField(widget=forms.Textarea(), label = u'Комментарий')
    content_id = forms.IntegerField(widget=forms.HiddenInput())
    content_type = forms.IntegerField(widget=forms.HiddenInput())

    def save(self, auth_user):
        from people.models import People
        from comment.models import Comment
        from django.contrib.contenttypes.models import ContentType
        if self.is_valid() and auth_user.is_authenticated:
            content_id = self.cleaned_data.get('content_id')
            content_type = self.cleaned_data.get('content_type')
            parent_id = self.cleaned_data.get('parent_id')
            message = self.cleaned_data.get('message')
            object = ContentType.objects.get(pk=content_type).get_object_for_this_type(pk=content_id)
            c = Comment(
                text = message,
                user = People.objects.get(username = auth_user),
                content_object = object
            )
            c.parent_id = parent_id
            c.save()
            return c
        else:
            return False

Данный код строит и при необходимости сохраняет форму добавления комментария.
Т.к. обсуждения мы планируем сделать универсальными, соответственно вызовов такой формы на сайте может быть очень много и дабы избежать дублирования кода, для вставки формы в шаблон сделаем TemplateTag:


# -*- coding: utf-8 -*-
from django import template
from comment.forms import CommentForm

register = template.Library()

@register.inclusion_tag('comments.html')
def tree_comments_by_object(object):
    from django.contrib.contenttypes.models import ContentType
    comments = object.comments.select_related().all()
    content_type = ContentType.objects.get_for_model(object)
    form = CommentForm(initial={'content_id': object.id, 'content_type': content_type.id})
    return {'comments': comments, 'comment_form': form}

И так, пояти все готово ) Осталось только визуальное представление – html+css+js данного тега.


<h2>Комментарии </h2>
<div id="comments_container">
    {% for comment in comments %}
        <div style="margin: 0 0 20px {{ comment.html_level }}%" id="comment{{ comment.id }}">
            {{ comment.user.username }}&nbsp;&nbsp;<small>{{ comment.date|date:"d.m.Y G:i" }}</small>
            <a href="{{ comment.get_absolute_url }}">#</a>
            <div>{{ comment.text }}</div>
            <a href="#" onclick="return reply_comment({{ comment.id }});">ответить</a>
        </div>
    {% endfor %}
</div>

<h2>Добавить комментарий</h2>
<form method="post" action="/comment/add/" id="comment_add">
    <ul>
        {{ comment_form.as_ul}}
        <li><input type="submit" name="send" value="Отправить" /></li>
    </ul>
</form>

Для AJAX отправки данных формы я использую jQuery и плагин jQuery.form. Написание JavaScript-кода я оставляю вам ) Единственно, что скажу – он не должен получится больше 6-10 строк )))

И последний штрих – обработчик запросов от формы – он же контроллер.

Контроллер

Тут вообще комментарии излишни:


# -*- coding: utf-8 -*-
from comment.forms import CommentForm
from tools.ajax import JsonResponse
from datetime import datetime

def add(request):
    if request.method == 'POST':
        form = CommentForm(request.POST)
        comment = form.save(request.user)
        result = {'errors': False, 'data': {}}
        if comment:
            result['data'] = {
                'id': comment.id,
                'parent_id': comment.parent_id,
                'text': comment.text,
                'url': comment.get_absolute_url(),
                'date': datetime.strftime(comment.date, '%d.%m.%Y %H:%M'),
                'level': comment.html_level,
                'user': comment.user.username,
            }
        else:
            result['errors'] = True
        return JsonResponse(result)

Разве, что пояснения откуда взялся JsonResponse – это небольшой класс, для уменьшения дублирования рутинного кода:


# -*- coding: utf-8 -*-
from django.utils import simplejson
from django.http import HttpResponse
class JsonResponse(HttpResponse):
    def __init__(self, data):
        HttpResponse.__init__(self,
            content = simplejson.dumps(data),
            mimetype = 'application/json')

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

Update (6 апреля 2009)

На выходных задумался об удалении дублирования шаблона отдельного комментария. Сейчас у нас он присутствует в шаблоне темплейт-тега и собирается в JS. Что есть не хорошо =) Уберем это дублирование.

Для этого вынесем часть шаблона отвечающую за отображения одного комментария в файл, например, comment_item.html:

<div style="margin: 0 0 20px {{ comment.html_level }}%" id="comment{{ comment.id }}">
    <a href="{{ comment.user.get_absolute_url }}">{{ comment.user.username }}</a>
    &nbsp;&nbsp;<small>{{ comment.date|date:"d.m.Y G:i" }}</small> <a href="{{ comment.get_absolute_url }}">#</a>
    <div>{{ comment.text }}</div>
    <a href="#" onclick="return reply_comment({{ comment.id }});">ответить</a>
</div>

В “главном” шаблоне темплейт-тега заменим вхождение этого кода на {% include ”comment_item.html” %}, получим:

<h2>Комментарии </h2>
<div id="comments_container">
    {% for comment in comments %}
        {% include "comment_item.html" %}
    {% endfor %}
</div>

И немного изменим контроллер:

# -*- coding: utf-8 -*-
from comment.forms import CommentForm
from tools.ajax import JsonResponse
from datetime import datetime
from django.template.loader import render_to_string

def add(request):
    if request.method == 'POST':
        form = CommentForm(request.POST)
        comment = form.save(request.user)
        result = {'errors': False, 'data': {}}
        if comment:
            result['data']['parent_id'] = comment.parent_id;
           result['data']['html'] = render_to_string('comment_item.html', {'comment': comment})
        else:
            result['errors'] = True
        return JsonResponse(result)

Вот и все. Дублирования больше нет. Меньше кода – меньше ошибок )))