strona główna

Archive for the 'Django' Category

Warsztaty Django: zapisy otwarte

Tuesday, October 28th, 2008

Od wczoraj można już zapisywać się na pierwszą edycję warsztatów "Tworzenie aplikacji w Django". Liczba uczestników ograniczona do 12 osób, decyduje kolejność zgłoszeń. Pierwsze cztery miejsca już zajęte, więc nie warto zwlekać – jeśli temat Cię interesuje, to zapisz się już teraz.

Szczegóły, jak zwykle, na stronie warsztatów.

Django: powiadomienia

Saturday, October 25th, 2008

Wczoraj przyszło mi do głowy, że powiadomienia o błędach to jednocześnie jeden z bardziej przydatnych i najmniej widocznych elementów Django. To dzięki nim zdarza mi się poprawić błędy i wysłać do użytkownika informację, że jakaś strona działa już poprawnie nawet jeśli nie chciało mu się zgłaszać usterki.

Na przykład:

 
from django.shortcuts import render_to_response
 
def gather_user_data(user):
    # in reality this would be more complex
    return {'email': user.email}
 
def profile_view(request):
    data = {'user': request.user}
    data.update(gather_user_data(request.user))
    return render_to_response('profile_view.html',
                              dictionary = data)
 

Widok profile_view zawiera błąd ujawniający się tylko w niektórych sytuacjach. Jeśli trafi na niego ktoś na serwerze produkcyjnym, zobaczy standardowe 500 Internal server error, a Django automatycznie wyśle do mnie taki raport:

 
Traceback (most recent call last):
 
  File "/home/marcink/checkout/django-trunk/django/core/handlers/base.py",
  line 86, in get_response
    response = callback(request, *callback_args, **callback_kwargs)
 
  File "/home/marcink/checkout/utils/blogowe/errtest/../errtest/errapp/views.py",
  line 8, in profile_view
    data.update(gather_user_data(request.user))
 
  File "/home/marcink/checkout/utils/blogowe/errtest/../errtest/errapp/views.py",
  line 4, in gather_user_data
    return {'email': user.email}
 
AttributeError: 'AnonymousUser' object has no attribute 'email'
 
< wsgirequest GET:< QueryDict: {}>,
POST:< querydict : {}>,
COOKIES:{'django_log_selectedLevel': '0',
 'django_log_showLocation': '1',
 'sessionid': '915922a5710cf210e2f5fae47e0f8900'},
META:{'COLORTERM': 'gnome-terminal',
 'CONTENT_LENGTH': '',
 'CONTENT_TYPE': 'text/plain',
 'DBUS_SESSION_BUS_ADDRESS': 'unix:abstract=/tmp/dbus-BibuFgosrz,guid=c50cabac45d33af5b6936d0049024c87',
 'DESKTOP_SESSION': 'default',
 'DISPLAY': ':0.0',
 'DJANGO_DIR': '/home/marcink/checkout/django-trunk',
 'DJANGO_SETTINGS_MODULE': 'errtest.settings',
 'FCGI_PORT': '9014',
 'GATEWAY_INTERFACE': 'CGI/1.1',
 'GDMSESSION': 'default',
 'GDM_LANG': 'en_US.UTF-8',
 'GDM_XSERVER_LOCATION': 'local',
 'GNOME_DESKTOP_SESSION_ID': 'Default',
 'GNOME_KEYRING_PID': '7904',
 'GNOME_KEYRING_SOCKET': '/tmp/keyring-glnuCS/socket',
 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/marcink/.gtkrc-1.2-gnome2',
 'HISTCONTROL': 'ignoreboth',
 'HOME': '/home/marcink',
 'HTTP_ACCEPT': 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
 'HTTP_ACCEPT_CHARSET': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
 'HTTP_ACCEPT_ENCODING': 'gzip,deflate',
 'HTTP_ACCEPT_LANGUAGE': 'en-us,en;q=0.5',
 'HTTP_CACHE_CONTROL': 'max-age=0',
 'HTTP_CONNECTION': 'keep-alive',
 'HTTP_COOKIE': 'sessionid=915922a5710cf210e2f5fae47e0f8900; django_log_showLocation=1; django_log_selectedLevel=0',
 'HTTP_HOST': '127.0.0.1:5003',
 'HTTP_KEEP_ALIVE': '300',
 'HTTP_USER_AGENT': 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.17) Gecko/20080922 Ubuntu/7.10 (gutsy) Firefox/2.0.0.17',
 'JAVA_HOME': '/home/marcink/devel/jre1.6.0/',
 'LANG': 'en_US.UTF-8',
 'LD_LIBRARY_PATH': '/home/marcink/checkout/ldev_comicspot/comicspot/../other',
 'LESSCLOSE': '/usr/bin/lesspipe %s %s',
 'LESSOPEN': '| /usr/bin/lesspipe %s',
 'LOGNAME': 'marcink',
 'LS_COLORS': 'no=00:fi=00:di=01;34:ln=01;36:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:su=37;41:sg=30;43:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arj=01;31:*.taz=01;31:*.lzh=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.gz=01;31:*.bz2=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.avi=01;35:*.fli=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.flac=01;35:*.mp3=01;35:*.mpc=01;35:*.ogg=01;35:*.wav=01;35:',
 'OLDPWD': '/home/marcink',
 'OTHER_DIR': '/home/marcink/checkout/ldev_comicspot/comicspot/../other',
 'OUTER_DIR': '/home/marcink/checkout/ldev_comicspot/comicspot/..',
 'PATH': '/home/marcink/bin:/home/marcink/local/bin:/home/marcink/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games',
 'PATH_INFO': u'/profile/',
 'PROJECT_BASE_DIR': '/home/marcink/checkout/ldev_comicspot/comicspot',
 'PROJECT_DIR': '/home/marcink/checkout/ldev_comicspot/comicspot/comicspot',
 'PROJECT_NAME': 'comicspot',
 'PWD': '/home/marcink/checkout/utils/blogowe/errtest',
 'PYTHONPATH': '/home/marcink/checkout/django-trunk',
 'QUERY_STRING': '',
 'REMOTE_ADDR': '127.0.0.1',
 'REMOTE_HOST': '',
 'REQUEST_METHOD': 'GET',
 'RUN_MAIN': 'true',
 'SCRIPT_NAME': u'',
 'SERVER_NAME': 'raven.loc',
 'SERVER_PORT': '5003',
 'SERVER_PROTOCOL': 'HTTP/1.1',
 'SERVER_SOFTWARE': 'WSGIServer/0.1 Python/2.5.1',
 'SESSION_MANAGER': 'local/raven:/tmp/.ICE-unix/7907',
 'SHELL': '/bin/bash',
 'SHLVL': '1',
 'SSH_AGENT_PID': '7947',
 'SSH_AUTH_SOCK': '/tmp/ssh-lBRzQZ7907/agent.7907',
 'TERM': 'xterm',
 'TZ': 'America/Chicago',
 'USER': 'marcink',
 'USERNAME': 'marcink',
 'WINDOWID': '39852701',
 'WINDOWPATH': '7',
 'XAUTHORITY': '/tmp/.gdmELMXJU',
 'XDG_DATA_DIRS': '/usr/local/share/:/usr/share/:/usr/share/gdm/',
 'XDG_SESSION_COOKIE': '90de2c2a7bf31a95a76c4900471ce700-1224887428.976645-1927173936',
 '_': '/usr/bin/python',
 'wsgi.errors': < open file '< stderr>', mode 'w' at 0xb7dc30b0>,
 'wsgi.file_wrapper': < class 'django.core.servers.basehttp.FileWrapper'>,
 'wsgi.input': < socket ._fileobject object at 0x854a72c>,
 'wsgi.multiprocess': False,
 'wsgi.multithread': True,
 'wsgi.run_once': False,
 'wsgi.url_scheme': 'http',
 'wsgi.version': (1, 0)}>
 

Po pierwsze – dowiaduję się o problemie bez żadnej akcji ze strony użytkownika. Po drugie – dostaję mnóstwo informacji o błędzie: opis wyjątku (tutaj – brak atrybutu email w AnonymousUser), dokładnie wskazane miejsce wystąpienia razem z pełnym stosem wywołań funkcji, zawartość parametrów przekazanych przez GET i POST i zawartość zmiennych środowiskowych. W praktyce zwykle wystarcza to do złożenia testu pozwalającego na powtórzenie i usunięcie błędu.

„Django - 3 lata pracy i wszystko, co mamy, to marne 1.0″

Monday, October 13th, 2008

Z okazji wydania Django 1.0 na ostatnim Bootstrapie opowiedziałem o najważniejszych zmianach, jakie zaszły w tej ramówce podczas tych trzech lat, które upłynęły od momentu otwarcia kodu. Slajdy z mojej prezentacji:

Reklama: już wkrótce odbędzie się pierwsza edycja warsztatów Django – najszybszy sposób na poznanie możliwości tego środowiska i rozpoczęcie pracy.

Django na Bootstrapie

Saturday, October 4th, 2008

Już w przyszłym tygodniu Bootstrap 8.10. Andy Budd opowie o testach użyteczności (“Guerilla Usability Testing with Silverback”), a ja ponarzekam na Django ("Django - 3 lata prac i wszystko, co mamy, to marne 1.0").

Serdecznie zapraszamy.

Skróty: render_to_response

Wednesday, October 1st, 2008

Twórcy Django są bardzo skuteczni w eliminowaniu miejsc, które wymagałyby złamania zasady DRY. Tym dziwniejszy jest wyjątek render_to_response: teoretycznie skrót, w praktyce – funkcja wymagająca codziennego wpisywania prawie identycznego kodu.

Wygląda to tak:

 
from django.shortcuts import render_to_response
from django.template import RequestContext
def some_view(request):
    return render_to_response('testapp/some_view.html',
                              context_instance = RequestContext(request),
                              dictionary = {'title': 'A very simple view',
                                            'text': 'Some text here'})
 

Parametr context_instance w praktyce zawsze wygląda tak samo. Nazwa szablonu często, chociaż nie zawsze, przypomina nazwę funkcji obsługującej dany adres. Parametr dictionary bywa na tyle rozbudowany, że najczęściej buduje się go wcześniej, żeby uniknąć bardzo długich lub sztucznie podzielonych linii. Łączny poziom powtarzalności jest bardzo wyraźny po napisaniu kilku-kilkunastu widoków, zwłaszcza jeśli część z nich zawiera więcej niż jedną ścieżkę kończącą się wywołaniem render_to_response.

A przecież to może wyglądać tak:

 
@with_template
def simple_view(request):
    return {'title': 'A very simple view',
            'text': 'Some text here'}
 

W którymś momencie ta powtarzalność zirytowała mnie na tyle, że poświęciłem trochę czasu na napisanie odpowiedniego dekoratora. Dla lenistwa.

Działa tak:

  • jeśli funkcja zwraca HttpResponse, to dekorator nic nie zmienia – przekazuje ten wynik wyżej,
  • w przeciwnym wypadku zakłada, że wynikiem jest słownik (lub coś, co słownik udaje). W takiej sytuacji przekazuje go do render_to_response, zakładając że wymagany szablon to "nazwa_aplikacji/nazwa_funkcji.html", a context_instance to standardowy RequestContext.
  • dodatkowo, funkcja może zmienić nazwę szablonu: wystarczy, że w słowniku zwróci element 'template_name'.

Przykłady:

 
# plik testapp/views.py
 
@with_template
def simple_view(request):
    return {'title': 'A very simple view',
            'text': 'Some text here'}
 
@with_template
def simple_view_with_locals(request):
    title = 'Another simple view'
    text = 'Some text here'
    return locals()
 
@with_template
def complex_view(request):
    if request.REQUEST.get('redirect_me'):
        return HttpResponseRedirect('http://google.com/')
    else:
        template_name = 'testapp/some_other_template.html'
        return locals()
 
@with_template('testapp/yet_another_template.html')
def view_with_template_override(request):
    title = 'Another simple view'
    text = 'Some text here'
    return locals()
 
@login_required
@with_template
def authenticated_view(request):
    text = 'Welcome, ' + request.user.username
    return locals()
 

Można też po prostu zwrócić locals() – słownik zawierający wszystkie zmienne lokalne. Możliwe jest też, jak w ostatnim przykładzie, użycie innych dekoratorów, na przykład login_required.

Dekorator with_template ma też dodatkową, dużą zaletę: ułatwia wprowadzenie konwencji, zgodnie z którą nazwa szablonu wynika z nazwy aplikacji i funkcji. Konwencje, ogólnie, to dobra rzecz. Ta konkretna sprawia, że oszczędza się czas który normalnie poświęca się na decyzję jak nazwać szablon, wpisanie jego nazwy oraz późniejsze przeglądanie kodu żeby dowiedzieć się, jakiego szablonu należy się spodziewać pod danym adresem.

Owszem, można – jak zauważył ktoś podczas europythonowej prezentacji – dopisać informację o nazwie szablonu do dokumentacji funkcji, ale konwencje działają jak makra lub funkcje w dokumentacji: określa się je w jednym miejscu i stosuje w wielu, bez potrzeby zaglądania do tekstu.

 
# plik testapp/urls.py
 
from django.conf.urls.defaults import *
 
urlpatterns = patterns('testapp.views',
    (r'^normal/', 'normal_view'),
    (r'^simple/', 'simple_view'),
    (r'^complex/', 'complex_view'),
    (r'^authenticated/', 'authenticated_view'),
    (r'^view_with_template_override/', 'view_with_template_override'),
)
 

I trochę testów:

 
# plik testapp/tests.py
 
class WithTemplateTestCase(TestCase):
    def test_views(self):
        response = self.client.get('/app/simple/')
        self.assertTemplateUsed(response, 'testapp/simple_view.html')
 
        response = self.client.get('/app/complex/')
        self.assertTemplateUsed(response, 'testapp/some_other_template.html')
 
        response = self.client.get('/app/complex/', data = {'redirect_me': '1'})
        self.assertEqual(response.status_code, 302)
 
        response = self.client.get('/app/view_with_template_override/')
        self.assertTemplateUsed(response, 'testapp/yet_another_template.html')
 
        from django.contrib.auth.models import User
        user, created = User.objects.get_or_create(username = 'marcink')
        user.set_password('marcink')
        user.save()
 
        self.client.login(username='marcink', password = 'marcink')
        response = self.client.get('/app/authenticated/')
        self.assertTemplateUsed(response, 'testapp/authenticated_view.html')
 

A sam dekorator wygląda tak:

 
import re
 
from django.http import HttpResponse
from django.shortcuts import render_to_response
from django.template import RequestContext
try:
    # functools appeared in Python 2.5
    from functools import wraps, update_wrapper
except ImportError:
    # luckily for us, Django already backported it
    from django.utils.functional import wraps, update_wrapper
 
def with_template(arg):
    """
    A view decorator that handles rendering.
 
    If the view returns a HttpResponse, it is passed intact; otherwise
    the returned value is passed as dictionary to render_to_response.
 
    Usage samples:
 
    @with_template
    def view_func(request, ...):
        return ...
 
    @with_template('custom/template/name.html')
    def other_view_func(request, ...):
        return ...
    """
 
    class TheWrapper(object):
        def __init__(self, default_template_name):
            self.default_template_name = default_template_name
 
        def __call__(self, func):
            def decorated_func(request, *args, **kwargs):
                ret = func(request, *args, **kwargs)
                if isinstance(ret, HttpResponse):
                    return ret
                return render_to_response(ret.get('template_name',
                                                  self.default_template_name),
                                          context_instance=RequestContext(request),
                                          dictionary=ret)
            update_wrapper(decorated_func, func)
            return decorated_func
 
    if not callable(arg):
        return TheWrapper(arg)
    else:
        app_name = re.search('([^.]+)[.]views', arg.__module__).group(1)
        default_template_name = ''.join([app_name, '/', arg.__name__, '.html'])
        return TheWrapper(default_template_name)(arg)
 

Pełen kod można znaleźć tutaj: testowy projekt with_template

Aktualizacja 2008/10/13: zgodnie z sugestią urbana dodałem możliwość przekazania nazwy szablonu jako parametru dekoratora.

Warsztaty IT - Django

Friday, September 26th, 2008

Chcecie dowiedzieć się, o co chodzi w całym tym Django? A może znacie kogoś, kto chce? Właśnie uruchomiłem stronę z informacją o nadchodzących warsztatach "Tworzenie aplikacji w Django" :)

Laboratorium komputerowe

Więcej informacji na stronie.

Oiola.com - otwarcie

Wednesday, August 6th, 2008

Dobra wiadomość: od poniedziałku Oiola jest dostępna dla wszystkich – konto można założyć lekko, łatwo i przyjemnie, bez konieczności kontaktowania się z nami. Więcej informacji na naszym blogu, napisał też o nas Paweł Iwaniuk w serwisie Antyweb.

Zapraszamy :)

Django na TechAuli

Friday, June 27th, 2008

Na dzisiejszej TechAuli opowiedziałem o Django – dlaczego jest fajne, szybkie i warto używać. Były trafne i ciekawe pytania z sali, być może też udało mi się namówić kilka osób do wypróbowania go w praktyce, więc prezentacja wyglądała na udaną.

Oiola.com - nowa wersja

Wednesday, June 4th, 2008

Od dzisiaj na oiola.com można zobaczyć efekty ostatnich dwóch miesięcy pracy: dużo zmian, pełen spis na Oiola-blogu.

Tym razem, pomimo szczerych chęci, więcej kodu napisałem niż znalazłem. Jedyny nowy moduł znaleziony w sieci to strip-o-gram – sensownie działający konwerter z HTML na czysty tekst, przez Oiolę wykorzystwany podczas wysyłania poczty (od teraz można dowolnie zmieniać tekst powiadomień i wysyłać listy do uczestników bezpośrednio z oiola.com).

Z ciekawostek: w tej chwili Oiola, w części serwerowej, to 5433 linie kodu, z czego 2329 to testy – ta część serwisu, która zajmuje się tylko i wyłącznie sprawdzaniem, czy reszta działa poprawnie – więc sama aplikacja to 3104 linie. Wykorzystałem biblioteki zawierające w sumie 22543 linii, nie licząc samego Django i oczywiście biblioteki standardowej Pythona, co oznacza że zdecydowaną większość kodu napisał i zdebugował już ktoś inny. I to właśnie jest podstawa szybkiego tworzenia aplikacji: platforma, dla której istnieje mnóstwo bibliotek tak wysokiej jakości :)

Komiksy w sieci

Friday, February 15th, 2008

Wczoraj wieczorem dokręciliśmy ostatnie śrubki w drugim z cyklu
projektów-w-tydzień. Tym razem z lekkim poślizgiem, w planach
mieliśmy zamknięcie prac w zeszłym tygodniu, w praktyce –
rzeczywiście intensywne prace zaczęliśmy w piątek, skończyliśmy
wczoraj (środa). Pierwszy
użytkownik
powoli kończy przeprowadzkę.

Nowy serwis to WebComicsPot,
narzędzie do możliwie prostego publikowania w sieci komiksów, także
wielojęzycznych. Założenia były takie: samo umieszczenie plansz w
sieci jest proste i tanie, kłopotem jest za to złożenie sensownego
interfejsu pozwalającego na ich przeglądanie i przełączanie się między
tłumaczeniami. WebComicsPot rozwiązuje właśnie ten kłopot.

Jak w favpico, obsługa jest
uproszczona do granic możliwości: autor podaje tylko listę adresów pod
którymi znajdują się kolejne plansze. Serwis zakłada, że każdy adres
zawiera datę w formacie RRRR-MM-DD. Dodatkowo możliwe jest wklejenie
w stronę komiksu swojego kodu HTML i dodanie arkusza styli CSS.
Jednocześnie, to jest nasz pierwszy serwis wspierający OpenId – koniec z
wymyślaniem kolejnych haseł :)

Coś o stronie technicznej i organizacyjnej; zaletą tak małych
projektów jest to, że można w krótkim czasie sprawdzić w praktyce
sporo nowych narzędzi: jeśli się sprawdzą to świetnie, jeśli nie – to
upośledzają tylko jeden drobny projekt, nie ma sensu go przepisywać.
Tym razem znalazłem sporo przydatnych rzeczy.

Django-authopenid
zachowuje się bardzo sensownie, wymagała tylko drobnych poprawek
(które muszę jeszcze spakować w jeden sensowny diff i wysłać).

Instant Django to
świetny sposób na udostępnienie serwisu nieprogramiście
(np. grafikowi) pod Windows – zawiera, w jednej paczce, 2.5, django, sqlite, dość sensowny edytor tekstu i
parę skryptów które odpowiednio konfigurują środowisko. Wystarczy
dodać skrypt uruchamiający serwer (z syncdb przed startem), nauczyć
grafika korzystać z TortoiseSVN
i już, właśnie załatwiliśmy stronę techniczną współpracy.

FeedParserFeedJack – świetne biblioteki do
obsługi RSS, używałem
ich już wcześniej w Planemoo. Tym razem FeedParser przydał się też do
czyszczenia kodu HTML wpisywanego przez autora komiksu: ze względów
bezpieczeństwa serwis usuwa część konstrukcji.

Cssutils
– podobnie jak w przypadku HTML, możliwość wpisania
dowolnego kodu CSS to dziura bezpieczeństwa ze względu na @import i
javascript w adresach. Sam cssutils nie potrafi tego wyczyścić, ale
wystarczyło utworzyć własną podklasę CSSSerializer, żeby usunąć i
@import, i podejrzane adresy:

 
URL_WITH_CALL_RE = re.compile(r'^url[(]["\']https?://\S+$')
 
class SanitizingSerializer(cssutils.CSSSerializer):
    """
    Overrides some stuff in CSSSerializer to make it safer.
 
    1. disallow ALL URLs not starting with 'http://' or 'https://'
    2. remove @imports
    """
 
    def do_css_CSSValue(self, cssvalue):
        if cssvalue and isinstance(cssvalue, CSSPrimitiveValue):
            if cssvalue.primitiveType == CSSPrimitiveValue.CSS_URI:
                if not URL_WITH_CALL_RE.match(cssvalue._value):
                    return u'url("")';
        return super(SanitizingSerializer, self).do_css_CSSValue(cssvalue)
 
    def do_CSSImportRule(self, rule):
        return u''
 
cssutils.setSerializer(SanitizingSerializer())
 

Django zawiera bibliotekę do komentarzy, bardzo przydatną ale z
paroma ograniczeniami; przeszkadzał mi, przede wszystkim, ścisły
rozdział komentarzy użytkowników zalogowanych i anonimowych, w
praktyce uniemożliwiający dyskusję między jednymi a drugimi. Tutaj
poszukiwania innej biblioteki niestety nie dało rezultatu, trzeba było
dostosować django.contrib.comments.

Drobiazg, ale jak przydatny: slughifi zamienia "zażółć gęślą jaźń"
na "zazolc-gesla-jazn", więc jest wersją funkcji slugify, mądrzejszą o
znajomość wielu znaków narodowych, w tym cyrylicy.

Musiałem też nieco rozbudować własną bibliotekę do obsługi tłumaczeń,
django-multilingual;
eksperymentalne zmiany okazały się bardzo przydatne więc niedługo znajdą
się też w wersji publicznej.

Napisałem też trochę kodu wspomagającego testy modułowe Django,
teraz duża część moich testów wygląda tak:

 
def test_adding_a_comic(self):
    self.login()
    self.get('/')
    self.click('m-your-account')
    self.click('publish-now')
 
    self.click('cancel')
    self.assertLocation('/account/ed/')
 
    # okay, now go and publish it
    self.click('publish-now')
    self.assertContains(self.response, 'name="short_name"')
    self.assertContains(self.response, 'name="main_language_id"')
    self.assertContains(self.response, 'name="add" value="save"')
 

Gdzie 'm-your-account' i 'publish-now' to identyfikatory (nie tekst!)
odsyłaczy na stronie; oczywiście każdy click przechodzi do strony
wskazywanej przez dany odsyłacz. Pozwala to łatwo przetestować całe
sekwencje zdarzeń, i to w sposób niezależny od aktualnie włączonej
wersji językowej interfejsu.

A za miesiąc, wszystko na to wskazuje, coś bardziej skomplikowanego :)