16 December 2008

Rafał Jońca na Google App Engine

Obiecałem jakiś czas temu przedstawienie szczegółów działania tej witryny na GAE. Uprzedzam, że kod nie zawsze jest optymalny i ładny, niemniej jak na pierwszą pracę tego typu, spełnia moje potrzeby i jest teoretycznie skalowalny bez przekraczania limitów na pojedyncze żądanie HTTP.



Konfiguracja


System wykorzystuje:
- Google App Engine Helper dla Django
- Django 1.0 jako zip z usuniętymi niepotrzebnymi aplikacjami i całym locale
- pytz, timezones, pygments, code_hilite, markdown; do obsługi timezone, konwersji do HTML i kolorowania składni


Wszystkie pozostałe aspekty są wykonane własnymi siłami, czasem przez przerobienie modeli i kodu z oryginalnego Django (np. flatpages i sites).


To nie jest tutorial, więc nie będę podawał wszystkich kroków, ale raczej fragmenty kodu, problemy itp. Nie opiszę więc, jak utworzyć Django ZIP, jak skonfigurować helpera itp. To wszystko jest opisane gdzieś indziej.



Panel administracyjny


Nie korzystam z panelu administracyjnego Django pod GAE, choć wiem, że są takie próby. Stworzyłem własny, bardzo prosty acz wystarczający panel administracyjny. Zacznę więc od niego, bo w rzeczywistości również powstawał jako pierwszy.


Panel jest chroniony trybem administracyjnym przez GAE bez jakiejkolwiek obsługi użytkowników, grup, uprawnień itp.



Aplikacja Django


Admin jest standardową aplikacją Django, która ze względu na prostotę/lenistwo nie do końca jest łatwa do wielokrotnego użytku, choć początkowo taki był zamysł. Niemniej dodanie obsługi nowych aplikacji jest proste.



myadmin/init.py



Plik ten zawiera informację o aplikacjach do administrowania i ma postać:


ADMIN_APPS = (
('sites', u'Site'),

('flatpages', u'Flatpage'),
('weblog', u'Blog'),

)

Czemu nie wylądowało to w settings.py? Dobre pytanie. Powinno, ale się zasiedziało. Innym od razu radzę wsadzić to tam, gdzie być powinno.



myadmin/fields.py


Plik zawiera dodatkowe pole, dzięki któremu mogę sensownie wyświetlać w formularzu listę wielokrotnego wyboru z kluczami obcymi (referencjami). Domyślnie GAE udostępnia podobne pole, ale tylko dla relacji jeden do wielu. Tutaj stosuję relację wiele do wielu, dla którego obsługę musiałem dodać sam, rozszerzając pole wielokrotnego wyboru z Django.


W GAE unika się tabel pośredniczących dla relacji wiele do wielu jak ma to miejsce w relacyjnych bazach danych. Obsługa tego byłaby często zbyt kosztowna pod kątem zapytań (pamiętajmy, że nie ma złączeń). GAE obsługuje za to umieszczanie jako pola listy elementów i wyszukiwania po nim. Zamiast więc stosować tabelę łączącą, w jednej z tabel umieszczamy listę kluczy drugiej części połączenia. Jeśli mamy tabele stron i witryn, a jedna strona może należeć do wielu witryn, umieszczamy w elemencie strony listę witryn, do których przynależy.


from google.appengine.ext.db import Key    

from django.forms.fields import MultipleChoiceField

class ModelChoiceIterator(object):
def __init__(self, field):

self.field = field
self.query = field.query

def __iter__(self):
for obj in self.query:

yield (obj.key(), getattr(obj, self.field.use_field))

class KeyListPropertyField(MultipleChoiceField):
def __init__(self, query, use_field, *args, **kwargs):

self.use_field = use_field
super(KeyListPropertyField, self).__init__(*args, **kwargs)

self.query = query

def _get_query(self):

return self._query

def _set_query(self, query):

self._query = query
self.widget.choices = self.choices

query = property(_get_query, _set_query)

def _get_choices(self):

return ModelChoiceIterator(self)

def _set_choices(self, value):

self._choices = self.widget.choices = list(value)

choices = property(_get_choices, _set_choices)

def clean(self, value):

new_value = super(KeyListPropertyField, self).clean(value)

return [Key(s) for s in new_value]


Jeśli ktoś nie wie, to kod ten stanowi zmodyfikowaną i uproszczoną wersję ModelMultipleChoiceField z Django.



myadmin/base.py


Ten plik zawiera klasę bazową do generowania opisów obsługi modelu przez admina. Traktuj to jako bardzo, bardzo uproszczoną wersję ModelAdmin z Django. Klasa zapewnia domyślne działanie oraz możliwość jego zmodyfikowania dla tworzenia listy obiektów, formularza, dodawania i usuwania.


from google.appengine.ext import db

class BaseAdmin(object):
"""Base class for all admins."""

def __init__(self, request):

self.request = request

def _from_object_to_list(self, iterator):

results = []
for obj in iterator:

row = [getattr(obj, col) for col in self.Meta.change_fields]

row[0] = (obj.key(), row[0])

results.append(row)
return results

def form(self, *args, **kwargs):

return self.Meta.form(*args, **kwargs)

def edit(self, id):
return self.Meta.model.get(id)

def remove(self, id):
return self.Meta.model.get(id)

def save(self, form, add=False):

form.save()

def delete(self, id):

from google.appengine.api import datastore
datastore.Delete(db.Key(id))


W zasadzie poza odwzorowanie danych na listę i określeniem formularza wraz z modelem nie trzeba nic więcej, by uzyskać prosty system listowania, edycji, dodawania i usuwania wybranego modelu. Przykład wykorzystania klasy bazowej znajduje się pod koniec wpisu.



myadmin/urls.py


W pliku mapowania adresów nie ma nic odkrywczego:


from django.conf.urls.defaults import *

urlpatterns = patterns('myadmin.views',
(r'^/?$', 'index'),

(r'^(?P<app>\w+)/$', 'list'),
(r'^(?P<app>\w+)/(?P<model>\w+)/$', 'change'),

(r'^(?P<app>\w+)/(?P<model>\w+)/add/$', 'add'),
(r'^(?P<app>\w+)/(?P<model>\w+)/remove/(?P<id>\w+)/$', 'delete'),

(r'^(?P<app>\w+)/(?P<model>\w+)/edit/(?P<id>\w+)/$', 'edit'),
)



Szablony i myadmin/models.py


Plik models.py jest pusty. Szablonów generalnie przytaczać nie będę, bo są naprawdę bardzo proste. Pokażę jedynie szablon edycji:


{% extends "admin/base.html" %}

{% block page_title %}{{ block.super }} - {{ application_name }}/{{ model_name }}/Edit{% endblock %}

{% block pc_main_content %}
<h2>Editing {{ application_name }}/{{ model_name }} -- {{ model_data }}</h2>

<form action="." method="post">
{{ form.as_p }}
<p><input type="submit" value="Save"></p>

</form>
{% endblock %}


myadmin/views.py


W odróżnieniu od nowego admina Django zastosowałem standardowe funkcje, a nie klasę. To najbardziej zaawansowana część systemu administracyjnego, a jednocześnie dosyć prosty kod. Najpierw przedstawię kilka funkcji pomocniczych i dekoratora, który pomaga w powtarzających się zadaniach. Część kodu dekoratora została zapożyczona z Django. Znający temat domyślą się, że mówię o końcówce.


from django.views.generic.simple import direct_to_template

from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.template import loader, RequestContext

from myadmin import ADMIN_APPS

def _get_id_by_name(list_, element):

for id, elem in enumerate(list_):
if elem[0] == element:

return id

def _get_admin_for_app(app):
return __import__('%s.admin' % app).admin

def _get_model_for_app(app, model):
app_module = _get_admin_for_app(app)

return getattr(app_module, model)

def admin_decorator(f):
def wrapper(*args, **kwargs):

result = f(*args, **kwargs)
if isinstance(result, tuple):

request, app, model, context = result
else:

return result


ADMIN_MODELS = _get_admin_for_app(app).ADMIN_MODELS

dictionary = {
'apps': ADMIN_APPS,
'current_app': app,

'application_name': ADMIN_APPS[_get_id_by_name(ADMIN_APPS, app)][1],
'current_model': model,

'model_name': ADMIN_MODELS[_get_id_by_name(ADMIN_MODELS, model)][1],
}

if context is None: context = {}

for key, value in context.items():
if callable(value):

dictionary[key] = value()
else:
dictionary[key] = value

c = RequestContext(request, dictionary)
t = loader.select_template([

'admin/%s/%s_%s.html' % (app, model, f.__name__),

'admin/%s/%s.html' % (app, f.__name__),

'admin/%s.html' % f.__name__
])
return HttpResponse(t.render(c))

return wrapper

Dekorator odpowiada za przygotowanie standardowych danych dostępnych w szablonach widoków edycyjnych, a także umożliwia przesłonięci szablonów, gdyby okazało się to niezbędne dla któregokolwiek z modeli i aplikacji.


Właściwe funkcje do najkrótszych nie należą, ale nie ma tam raczej nic, co mogłoby zaskoczyć osobę znającą Django. Nie zawierają elementów specyficznych dla GAE. To jedynie kod CRUD w wersji specyficznej dla mojego admina.


def index(request):

"""Start page of admin with list of applications."""
return direct_to_template(request, 'admin/index.html', {'apps': ADMIN_APPS})


def list(request, app):
"""List of models for one application that have admin pages."""
return direct_to_template(request, 'admin/list.html', {

'current_app': app,
'application_name': ADMIN_APPS[_get_id_by_name(ADMIN_APPS, app)][1],

'models': _get_admin_for_app(app).ADMIN_MODELS,
'apps': ADMIN_APPS

})

@admin_decorator
def change(request, app, model):

"""List of objects for one application model."""
admin_cls = _get_model_for_app(app, model)
admin_obj = admin_cls(request)

return request, app, model, {
'admin_object': admin_obj,

'model_data': admin_obj.change(),
}

@admin_decorator
def edit(request, app, model, id):

"""Shows the edit form for model and saves the model."""
admin_cls = _get_model_for_app(app, model)
admin_obj = admin_cls(request)

object = admin_obj.edit(id)
if object is None:

raise Http404()

if request.method == 'POST':

form = admin_obj.form(request.POST, instance=object)

if form.is_valid():
admin_obj.save(form)
return HttpResponseRedirect('../../')

context = {'form': form}
else:
context = {'form': admin_obj.form(instance=object)}

context.update({
'admin_object': admin_obj,
'model_data': object

})
return request, app, model, context

@admin_decorator
def add(request, app, model):
"""Shows the add form for model and saves the model."""

admin_cls = _get_model_for_app(app, model)
admin_obj = admin_cls(request)

if request.method == 'POST':
form = admin_obj.form(request.POST)

if form.is_valid():
admin_obj.save(form, add=True)

return HttpResponseRedirect('../')
context = {'form': form}

else:
context = {'form': admin_obj.form()}

context.update(admin_object=admin_obj)
return request, app, model, context

@admin_decorator
def delete(request, app, model, id):

"""Shows the remove form for model and removes the data on POST."""
admin_cls = _get_model_for_app(app, model)
admin_obj = admin_cls(request)

object = admin_obj.remove(id)
if object is None:

raise Http404()

if request.method == 'POST':

admin_obj.delete(id)
return HttpResponseRedirect('../../')

context = {
'admin_object': admin_obj,
'model_data': object

}
return request, app, model, context



Obsługa witryn (Sites)


Na zakończenie przykład użycia panelu administracyjnego na przykładzie najprostszego ze stosowanych modeli, czyli obsługi witryn.



sites/models.py


Poniższy kod to w zasadzie przerobiona i zmodyfikowana na potrzeby GAE wersja modelu Sites z Django. Zmienione zostały obiekty pól, nazwy metod zapisujących (put() zamiast save()). To jednak za mało...


W Django bardzo dużo aplikacji korzysta z mechanizmu witryn, między innymi sitemap, RSS i flatpages. To ostatnie i tak musiałem utworzyć własne, więc nie był to znaczący problem, ale dwóch pierwszych nie chciałem modyfikować. Z tego powodu model zawiera kilka dodatkowych elementów:




  • element objects udający menedżera i zapewniający metodę get_current()




  • dodanie metody get_current() jako metody statycznej klasy,



  • dodanie w module obiektu RequestSite -- wymaga tego jeden z importów w aplikacji dla RSS




  • wpis Site._meta.installed = True bez którego mechanizm RSS rozkłada się jak długi -- modele z GAE nie stosują klasy Meta!


Oto i cały kod:


from appengine_django.models import BaseModel

from google.appengine.ext import db

SITE_CACHE = {}

class RequestSite(object):

def __init__(self, request):
pass

class Manager(object):

def get_current(self):
return Site.get_current()

class Site(BaseModel):
id = db.IntegerProperty('site identifier', required=True)

domain = db.StringProperty('domain name', required=True)

name = db.StringProperty('display name', required=True)

objects = Manager()

def __unicode__(self):

return self.name

def put(self):
try:

del(SITE_CACHE[self.id])
except KeyError:
pass
super(Site, self).put()

def delete(self):
id = self.id

super(Site, self).delete()
try:
del(SITE_CACHE[id])

except KeyError:
pass

@classmethod
def get_current(cls):

"""
Returns the current ``Site`` based on the SITE_ID in the
project's settings. The ``Site`` object is cached the first
time it's retrieved from the database.
"""
from django.conf import settings

try:
sid = settings.SITE_ID
except AttributeError:

from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured("You're using the Django \"sites framework\" without having set the SITE_ID setting. Create a site in your database and set the SITE_ID setting to fix this error.")

try:
current_site = SITE_CACHE[sid]
except KeyError:

current_site = cls.all().filter('id =', sid).get()

SITE_CACHE[sid] = current_site
return current_site

@classmethod

def clear_cache(cls):
"""Clears the ``Site`` object cache."""
global SITE_CACHE
SITE_CACHE = {}

Site._meta.installed = True


sites/admin.py


Pozostało już tylko zdefiniować moduł admin.py zapewniający obsługę aplikacji w panelu administracyjnym. Zauważ, że choć wymaganego kodu jest więcej niż przy standardowym Django, to jednak nadal niewiele.


from google.appengine.ext.db import djangoforms

from myadmin.base import BaseAdmin

from sites.models import Site as SiteModel

ADMIN_MODELS = (

('SiteAdmin', u'Site'),
)

class SiteForm(djangoforms.ModelForm):

class Meta:
model = SiteModel

class SiteAdmin(BaseAdmin):

class Meta:
change_fields = ['id', 'name', 'domain']

model = SiteModel
form = SiteForm

def change(self):

return {
'header': [u'Id', u'Name', u'Domain'],

'data': self._from_object_to_list(SiteModel.all().order('name'))

}


I to by było na tyle


Na tym zakończę pierwszą część. W części drugiej pokrótce przedstawię strony statyczne (flatpages), które działają dwujęzycznie. Nie ma tu wielkiej magii, ale warto pokazać system wiele do wielu w wydaniu GAE.

0 komentarze:

Post a Comment