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.