AJAX w Django

Ponieważ napisałem stronę od nowa, postanowiłem zrobić to lepiej niż ostatnim razem. Jednym z ulepszeń jest dodanie JavaScript, na przykład do obsługi komentarzy. Zanim jednak wszystko zaczęło działać, musiałem trochę poszukać i potestować.

Spróbuję w miarę przystępnie opisać jak osiągnąć ładną obsługę komentarzy. JavaScript jest językiem którego dopiero się uczę, więc kod może nie być całkowicie poprawny (nie mniej, w moim przypadku działa).

Helpers.

Z czasów kiedy interesowałem się głównie pylons pamiętam, że dostępne tam były helpery. Podobne udogodnienie dostępne jest w RoR i TurboGears. Dzięki nim, nie mając zielonego pojęcia o JavaScript, dodać można do strony parę przydatnych elementów. Aby mieć taką funkcjonalność w Django, trzeba zainstalować je jako dodatkowy moduł, ponieważ nie są bezpośrednio dołączone do projektu.

Skoro w Django również można mieć helpery, to po co samemu pisać kod? Po części kierowałem się wpisem na the B-list. Ale wiedziałem też, że przyjdzie taki moment kiedy bez znajomości JavaScript ani rusz.

MochiKit

Dostępnych jest sporo bibliotek, które kuszą ładnymi efektami i prostotą implementacji w projekcie. Przejrzałem parę i wybrałem MochiKit, ponieważ podobno dzięki niej JavaScript suck less. Czego więcej może chcieć początkujący? Nie znalazłem żadnych tutoriali i z tego co przeczytałem na grupie, żadne nie istnieją. Ale dostępna jest obszerna dokumentacja co w pełni wystarcza.

Jeszcze parę linków

Prosty przykład AJAX w Django z wykorzystaniem biblioteki YUI znaleźć można na the B-list. Ponieważ jednak wybrałem MochiKit, trochę bardziej przydatna okazała się grupa dyskusyjna.

A co jak przeglądarka nie obsługuje JavaScript?

Obsługę formularza napiszemy najpierw w starym stylu - z przeładowaniem strony, a dopiero potem dopiszemy coś w JavaScript tak, aby po jego wyłączeniu, formularze dalej działały. Ktoś nadał temu nazwę hijax i jest to chyba właściwe podejście do problemu.

Zaczynamy

Tabela do której zapisywane będą komentarze, wygląda tak:

class Comments(models.Model):
    text = models.ForeignKey(Text)
    name = models.CharField(blank=False, max_length=30)
    mail = models.EmailField(blank=False, max_length=60)
    url = models.URLField(blank=True, max_length=60)
    date = models.DateField(auto_now_add=True)
    message = models.TextField(blank=False)

class CommentsForm(forms.ModelForm):
    class Meta:
        model = Comments
        exclude = ('text',)

,część szablonu blog/show_entry.html:

<form id="commentForm" action="." method="POST">
    <p>
        imię/nick:{{ cform.name }} 
        <div class="error" id="error_name">{{ cform.name.errors }}</div> 
    </p>
    <p>
        mail:  {{ cform.mail }}
        <div class="error" id="error_mail">{{ cform.mail.errors }} </div>
    </p>
    <p>
        strona: {{ cform.url }}
        <div class="error" id="error_url">{{ cform.url.errors }}</div>
    </p>
    <p> 
        {{ cform.message }} 
        <div class="error" id="error_message">{{ cform.message.errors }}</div> 
    </p>
    <p>
        <input type="submit" id="submitter" value=" Wyślij ">
    </p>
</form>

i view który będzie go obsługiwał:

def show_entry(request, msg_url=None, template='blog/show_entry.html'):
    text = Text.objects.get(url=msg_url)
    comments = Comments.objects.filter(text=text.id)
    cform = CommentsForm(initial=request.session)
    if request.method == 'POST'
        form = CommentsForm(request.POST, instance=Comments(text=text)
        if form.is_valid():
            instance = form.save()
            request.session['name'] = instance.name
            request.session['mail'] = instance.mail
            request.session['url'] = instance.url
        else:
            cform = form
    return render_to_response(template, {
        "text"      : text,
        "cform"     : cform,
        "comments"  : comments,
    })

Otrzymaliśmy sprawną obsługę komentarzy. Jedyna wada takiego rozwiązania to uciążliwe przeładowywanie strony, po każdym wysłaniu formularza. I właśnie tu do akcji wkracza AJAX.

JSON

Do komunikacji użyję JSON. Biblioteka simplesjon jest częścią Django i można ją zaimportować z django.utils. Ponieważ jednak jestem leniwy, stworzyłem plik blog/json.py, a w nim klasę która wykona za mnie całą pracę związana z translacją:

from django.utils.functional import Promise
from django.utils.encoding import force_unicode
from django.core.serializers import json
from django.utils import simplejson

class LazyEncoder(simplejson.JSONEncoder, json.Serializer):
    def default(self, obj):
        if isinstance(obj, Promise):
            return force_unicode(obj)
        return obj

    def __call__(self, obj):
        try:
            return self.encode(obj)
        except ValueError:
            return self.serialize(obj)

Modernizacja views.py

Gdy będziemy wysyłać zapytanie za pomocą JavaScript, serwer przyjmie dane, które pobrać możemy z request.POST. Aby jednak przekonać się czy zapytanie wysyłane jest za pomocą JavaScript, sprawdzamy klucze request.GET. Powinna się tam znajdować zdefiniowana w skrypcie forms.js wartość xhr. W zależności od wyniku, przygotowujemy dane i wysyłamy odpowiedź.

from weblog.blog.json import LazyEncoder

def show_entry(request, msg_url=None, template='blog/show_entry.html'):
    text = Text.objects.get(url=msg_url)
    comments = Comments.objects.filter(text=text.id)
    cform = CommentsForm(initial=request.session)
    # jeśli zapytanie pochodzi od JavaScript - True
    xhr = request.GET.has_key('xhr')
    # jeśli nie jest wysyłany komentarz, renderuj strone
    if not request.method == 'POST':
        return render_to_response(template, {
            "text"      : text,
            "cform"     : cform,
            "comments"  : comments,
        })

    form = CommentsForm(request.POST, instance=Comments(text=text))
    if xhr:
        # szablon wiadomości
        to_return = {'valid': False, 'errors': None, 'msg': None }
    if form.is_valid():
        instance = form.save()
        if xhr:
            # to może powodować przekłamanie po stronie serwera
            c = Comments.objects.all().order_by('-id')[:1]
            c = c.get()
            # uzupełniamy wiadomość o odpowiednie informacje
            to_return['msg'] = {'name': c.name, 
                                'date': c.date.strftime("%Y-%m-%d"), 
                                'message': c.message }
            to_return['valid'] = True
        # zapis sesji
        request.session['name'] = instance.name
        request.session['mail'] = instance.mail
        request.session['url'] = instance.url
    else:
        cform = form
        if xhr:
            # uzypełniamy wiadomość informacją o niepoprawnym
            # wypełnieniu formularza
            to_return['errors'] = form.errors.items()
    if xhr:
        lenc = LazyEncoder()
        # konwersja python -> JSON
        to_return = lenc(to_return)
        # wsyłamy JSON
        return HttpResponse(to_return, mimetype="application/javascript")
    # jeśli obsługa JavaScript nie jest dostępna, renderujemy stronę,
    # uwzględniając informację o błędach w formularzu
    return render_to_response(template, {
        "text"      : text,
        "cform"     : cform,
        "comments"  : comments,
    })

I tyle ze strony serwera. Obsługa zapytań w Django gotowa.

forms.js

W funcji submitMaker tworzone jest zapytanie, które następnie wysyłamy do serwera. Ten sprawdza ich poprawność i zwraca odpowiedź. W zależności od tego czy komunikacja z serwerem przebiegła poprawnie, wywoływana jest funkcja handleServerFeedback lub handleServerError.

// wartosci ktore nalezy ustawic
var KEY_ARRAY = ['id_name', 'id_mail', 'id_url', 'id_message'];
var ERROR_ARRAY = ['error_name', 'error_mail', 'error_url', 'error_message'];
var DESTINATION_URL = '?xhr';
var SUBMIT_ID = 'submitter';

window.onload = function()
{
    $('commentForm').onsubmit = validForm;
}

function validForm(event)
{
    event.preventDefault();
    var submit = submitMaker(KEY_ARRAY, ERROR_ARRAY);
    submit();
    return false;
}

// czyszczenie informacji o bledach
function clearErrinfo(error_array)
{
    for (i=0; i<error_array.length; ++i)
    {
        $(error_array[i]).innerHTML = "";
    }
}

// blokowanie przycisku `wyslij`
function lockSubmit(submit_id)
{
    $(submit_id).disabled = true;
}

// czasowe blokowanie przycisku
// rownie dobrze mozna by blokowac, a potem recznie zwalniac
function holdSubmit(submit_id, time)
{   
    lockSubmit(submit_id);
    MochiKit.Async.callLater(time, function() {$(submit_id).disabled = false});
}

function formValue(key_array)
{
    return $(key_array).value;
}

// komunikacja z serwerem
var submitMaker = function(KEY_ARRAY, ERROR_ARRAY) 
{
    submitHandler = function() 
    {
        // czyscimy informacje o bledach
        clearErrinfo(ERROR_ARRAY);
        // blokujemy przycisk wysylania na 3s
        holdSubmit(SUBMIT_ID, 3.0);
        // odetnij `id_` z nazwy
        var keyArrayStrip = function(key)
        {
            return MochiKit.Format.lstrip(key, 'id_');
        }
        // obcina nazwy w tablicy KEY_ARRAY
        var strip_key = MochiKit.Iter.imap(keyArrayStrip, KEY_ARRAY);
        // pobiez wartosci z formularzy
        var key_values = MochiKit.Iter.imap(formValue, KEY_ARRAY);
        // utworz zapytanie
        var data_querystring = queryString(list(strip_key),list(key_values));
        var data_setup = getXMLHttpRequest();
        data_setup.open("POST", DESTINATION_URL, true);
        data_setup.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
        data_setup.setRequestHeader('Cache-Control', 'no-cache');
        // wyslij zapytanie
        var d = sendXMLHttpRequest(data_setup, data_querystring);
        // podlaczenie sygnalow
        d.addCallbacks(handleServerFeedback, handleServerError);
    }
    return submitHandler;
}

// funckja podpieta pod sygnal gdy wszystko poszlo dobrze
function handleServerFeedback(data_setup) 
{
    clearErrinfo(ERROR_ARRAY);
    msg_obj = evalJSONRequest(data_setup);
    if (msg_obj.valid)
    {
        // formularz jest poprawny, komentarz zostal zapisany do bazy...
        // moznaby wyswietlic wiadomosc
    }
    else 
    {
        // formularz zawiera bledy
        var err = msg_obj.errors
        for (i=0; i<err.length; ++i)
        {
            error_id = "error_" + err[i][0];
            $(error_id).innerHTML = err[i][1];
        }
    }

}

// funkcja podpieta pod sygnal bledu
function handleServerError() 
{
    alert("Error while transfering data.");
}

Biblioteki

Ostatnią czynnością będzie dodanie w szablonie blog/show_entry.html bibliotek MochiKit (kolejność ma znaczenie):

<script type="text/javascript" src="/javascript/MochiKit/Base.js"></script>
<script type="text/javascript" src="/javascript/MochiKit/DOM.js"></script>
<script type="text/javascript" src="/javascript/MochiKit/Async.js"></script>
<script type="text/javascript" src="/javascript/MochiKit/Iter.js"></script>
<script type="text/javascript" src="/javascript/MochiKit/Format.js"></script>
<script type="text/javascript" src="/javascript/forms.js"></script>

pats

24.08.2009

Świetny wpis, właśnie tego teraz szukałem,

dzięki

gather

18.11.2009

bardzo "przyswajalnie" opisane :)