diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..983e4eb3f39f1f2300eff631cf0e425686306bfe --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "helios"] + path = helios + url = git@github.com:benadida/helios-django-app.git +[submodule "auth"] + path = auth + url = git@github.com:benadida/auth-django-app.git diff --git a/appengine_django/__init__.py b/appengine_django/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4d1d0d874a582dc443f0aadeb99d5c7653151395 --- /dev/null +++ b/appengine_django/__init__.py @@ -0,0 +1,549 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Support for integrating a Django project with the appengine infrastructure. + +This requires Django 1.0beta1 or greater. + +This module enables you to use the Django manage.py utility and *some* of it's +subcommands. View the help of manage.py for exact details. + +Additionally this module takes care of initialising the datastore (and a test +datastore) so that the Django test infrastructure can be used for your +appengine project. + +To use this module add the following two lines to your main.py and manage.py +scripts at the end of your imports: + from appengine_django import InstallAppengineHelperForDjango + InstallAppengineHelperForDjango() + +If you would like to use a version of Django other than that provided by the +system all you need to do is include it in a directory just above this helper, +eg: + appengine_django/__init__.py - This file + django/... - your private copy of Django. +""" + +import logging +import os +import re +import sys +import unittest +import zipfile + + +DIR_PATH = os.path.abspath(os.path.dirname(__file__)) +PARENT_DIR = os.path.dirname(DIR_PATH) +if PARENT_DIR.endswith(".zip"): + # Check for appengine_django itself being in a zipfile. + PARENT_DIR = os.path.dirname(PARENT_DIR) + +# Add this project to the start of sys path to enable direct imports. +sys.path = [PARENT_DIR,] + sys.path + +# Try to import the appengine code from the system path. +try: + from google.appengine.api import apiproxy_stub_map +except ImportError, e: + # Not on the system path. Build a list of alternative paths where it may be. + # First look within the project for a local copy, then look for where the Mac + # OS SDK installs it. + paths = [os.path.join(PARENT_DIR, '.google_appengine'), + os.path.join(PARENT_DIR, 'google_appengine'), + '/usr/local/google_appengine'] + # Then if on windows, look for where the Windows SDK installed it. + for path in os.environ.get('PATH', '').split(';'): + path = path.rstrip('\\') + if path.endswith('google_appengine'): + paths.append(path) + try: + from win32com.shell import shell + from win32com.shell import shellcon + id_list = shell.SHGetSpecialFolderLocation( + 0, shellcon.CSIDL_PROGRAM_FILES) + program_files = shell.SHGetPathFromIDList(id_list) + paths.append(os.path.join(program_files, 'Google', + 'google_appengine')) + except ImportError, e: + # Not windows. + pass + # Loop through all possible paths and look for the SDK dir. + SDK_PATH = None + for sdk_path in paths: + if os.path.exists(sdk_path): + SDK_PATH = os.path.realpath(sdk_path) + break + if SDK_PATH is None: + # The SDK could not be found in any known location. + sys.stderr.write("The Google App Engine SDK could not be found!\n") + sys.stderr.write("See README for installation instructions.\n") + sys.exit(1) + if SDK_PATH == os.path.join(PARENT_DIR, 'google_appengine'): + logging.warn('Loading the SDK from the \'google_appengine\' subdirectory ' + 'is now deprecated!') + logging.warn('Please move the SDK to a subdirectory named ' + '\'.google_appengine\' instead.') + logging.warn('See README for further details.') + # Add the SDK and the libraries within it to the system path. + EXTRA_PATHS = [ + SDK_PATH, + os.path.join(SDK_PATH, 'lib', 'antlr3'), + os.path.join(SDK_PATH, 'lib', 'django'), + os.path.join(SDK_PATH, 'lib', 'webob'), + os.path.join(SDK_PATH, 'lib', 'yaml', 'lib'), + ] + # Add SDK paths at the start of sys.path, but after the local directory which + # was added to the start of sys.path on line 50 above. The local directory + # must come first to allow the local imports to override the SDK and + # site-packages directories. + sys.path = sys.path[0:1] + EXTRA_PATHS + sys.path[1:] + from google.appengine.api import apiproxy_stub_map + +# Try to import Django 1.0 through App Engine +try: + from google.appengine.dist import use_library + use_library('django', '1.0') +except ImportError: + pass + +# Look for a zipped copy of Django. +have_django_zip = False +django_zip_path = os.path.join(PARENT_DIR, 'django.zip') +if os.path.exists(django_zip_path): + have_django_zip = True + sys.path.insert(1, django_zip_path) + +# Remove the standard version of Django if a local copy has been provided. +if have_django_zip or os.path.exists(os.path.join(PARENT_DIR, 'django')): + for k in [k for k in sys.modules if k.startswith('django')]: + del sys.modules[k] + +# Must set this env var *before* importing any more of Django. +os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' + +from django import VERSION +from django.conf import settings + +from google.appengine.api import yaml_errors + +# Flags made available this module +appid = None +have_appserver = False + +# Hide everything other than the flags above and the install function. +__all__ = ("appid", "have_appserver", "have_django_zip", + "django_zip_path", "InstallAppengineHelperForDjango") + + +INCOMPATIBLE_COMMANDS = ["adminindex", "createcachetable", "dbshell", + "inspectdb", "runfcgi", "syncdb", "validate"] + + +def LoadAppengineEnvironment(): + """Loads the appengine environment. + + Returns: + This function has no return value, but it sets the following parameters on + this package: + - appid: The name of the application. + - have_appserver: Boolean parameter which is True if the code is being run + from within the appserver environment. + """ + global appid, have_appserver + + # Detect if we are running under an appserver. + have_appserver = False + stub = apiproxy_stub_map.apiproxy.GetStub("datastore_v3") + if stub: + have_appserver = True + + # Load the application identifier. + if have_appserver: + appid = os.environ.get("APPLICATION_ID", "unknown") + else: + # Running as manage.py script, read from config file. + try: + from google.appengine.tools import dev_appserver + appconfig, unused_matcher = dev_appserver.LoadAppConfig(PARENT_DIR, {}) + appid = appconfig.application + except (ImportError, yaml_errors.EventListenerYAMLError), e: + logging.warn("Could not read the Application ID from app.yaml. " + "This may break things in unusual ways!") + # Something went wrong. + appid = "unknown" + + logging.debug("Loading application '%s' %s an appserver" % + (appid, have_appserver and "with" or "without")) + + +def InstallAppengineDatabaseBackend(): + """Installs the appengine database backend into Django. + + The appengine database lives in the db/ subdirectory of this package, but is + known as "appengine" to Django. This function installs the module where + Django expects to find its database backends. + """ + from appengine_django import db + sys.modules['django.db.backends.appengine'] = db + logging.debug("Installed appengine database backend") + + +def InstallGoogleMemcache(): + """Installs the Google memcache into Django. + + By default django tries to import standard memcache module. + Because appengine memcache is API compatible with Python memcache module, + we can trick Django to think it is installed and to use it. + + Now you can use CACHE_BACKEND = 'memcached://' in settings.py. IP address + and port number are not required. + """ + from google.appengine.api import memcache + sys.modules['memcache'] = memcache + logging.debug("Installed App Engine memcache backend") + + +def InstallDjangoModuleReplacements(): + """Replaces internal Django modules with App Engine compatible versions.""" + + # Replace the session module with a partial replacement overlay using + # __path__ so that portions not replaced will fall through to the original + # implementation. + try: + from django.contrib import sessions + orig_path = sessions.__path__[0] + sessions.__path__.insert(0, os.path.join(DIR_PATH, 'sessions')) + from django.contrib.sessions import backends + backends.__path__.append(os.path.join(orig_path, 'backends')) + except ImportError: + logging.debug("No Django session support available") + + # Replace incompatible dispatchers. + import django.core.signals + import django.db + import django.dispatch.dispatcher + + # Rollback occurs automatically on Google App Engine. Disable the Django + # rollback handler. + try: + # pre 1.0 + from django.dispatch import errors + CheckedException = errors.DispatcherKeyError + def _disconnectSignal(): + django.dispatch.dispatcher.disconnect( + django.db._rollback_on_exception, + django.core.signals.got_request_exception) + except ImportError: + CheckedException = KeyError + def _disconnectSignal(): + django.core.signals.got_request_exception.disconnect( + django.db._rollback_on_exception) + + try: + _disconnectSignal() + except CheckedException, e: + logging.debug("Django rollback handler appears to be already disabled.") + +def PatchDjangoSerializationModules(): + """Monkey patches the Django serialization modules. + + The standard Django serialization modules to not correctly handle the + datastore models provided by this package. This method installs replacements + for selected modules and methods to give Django the capability to correctly + serialize and deserialize datastore models. + """ + # These can't be imported until InstallAppengineDatabaseBackend has run. + from django.core.serializers import python + from appengine_django.serializer.python import Deserializer + if not hasattr(settings, "SERIALIZATION_MODULES"): + settings.SERIALIZATION_MODULES = {} + base_module = "appengine_django" + settings.SERIALIZATION_MODULES["xml"] = "%s.serializer.xml" % base_module + python.Deserializer = Deserializer + PatchDeserializedObjectClass() + DisableModelValidation() + logging.debug("Installed appengine json and python serialization modules") + + +def PatchDeserializedObjectClass(): + """Patches the DeserializedObject class. + + The default implementation calls save directly on the django Model base + class to avoid pre-save handlers. The model class provided by this package + is not derived from the Django Model class and therefore must be called + directly. + + Additionally we need to clear the internal _parent attribute as it may + contain a FakeParent class that is used to deserialize instances without + needing to load the parent instance itself. See the PythonDeserializer for + more details. + """ + # This can't be imported until InstallAppengineDatabaseBackend has run. + from django.core.serializers import base + class NewDeserializedObject(base.DeserializedObject): + def save(self, save_m2m=True): + self.object.save() + self.object._parent = None + base.DeserializedObject = NewDeserializedObject + logging.debug("Replacement DeserializedObject class installed") + +def DisableModelValidation(): + """Disables Django's model validation routines. + + The model validation is primarily concerned with validating foreign key + references. There is no equivalent checking code for datastore References at + this time. + + Validation needs to be disabled or serialization/deserialization will fail. + """ + from django.core.management import validation + validation.get_validation_errors = lambda x, y=0: 0 + logging.debug("Django SQL model validation disabled") + +def CleanupDjangoSettings(): + """Removes incompatible entries from the django settings module.""" + + # Ensure this module is installed as an application. + apps = getattr(settings, "INSTALLED_APPS", ()) + found = False + for app in apps: + if app.endswith("appengine_django"): + found = True + break + if not found: + logging.warn("appengine_django module is not listed as an application!") + apps += ("appengine_django",) + setattr(settings, "INSTALLED_APPS", apps) + logging.info("Added 'appengine_django' as an application") + + # Ensure the database backend is appropriately configured. + dbe = getattr(settings, "DATABASE_ENGINE", "") + if dbe != "appengine": + settings.DATABASE_ENGINE = "appengine" + logging.warn("DATABASE_ENGINE is not configured as 'appengine'. " + "Value overriden!") + for var in ["NAME", "USER", "PASSWORD", "HOST", "PORT"]: + val = getattr(settings, "DATABASE_%s" % var, "") + if val: + setattr(settings, "DATABASE_%s" % var, "") + logging.warn("DATABASE_%s should be blank. Value overriden!") + + # Remove incompatible middleware modules. + mw_mods = list(getattr(settings, "MIDDLEWARE_CLASSES", ())) + disallowed_middleware_mods = ( + 'django.middleware.doc.XViewMiddleware',) + for modname in mw_mods[:]: + if modname in disallowed_middleware_mods: + # Currently only the CommonMiddleware has been ported. As other base + # modules are converted, remove from the disallowed_middleware_mods + # tuple. + mw_mods.remove(modname) + logging.warn("Middleware module '%s' is not compatible. Removed!" % + modname) + setattr(settings, "MIDDLEWARE_CLASSES", tuple(mw_mods)) + + # Remove incompatible application modules + app_mods = list(getattr(settings, "INSTALLED_APPS", ())) + disallowed_apps = ( + 'django.contrib.contenttypes', + 'django.contrib.sites',) + for app in app_mods[:]: + if app in disallowed_apps: + app_mods.remove(app) + logging.warn("Application module '%s' is not compatible. Removed!" % app) + setattr(settings, "INSTALLED_APPS", tuple(app_mods)) + + # Remove incompatible session backends. + session_backend = getattr(settings, "SESSION_ENGINE", "") + if session_backend.endswith("file"): + logging.warn("File session backend is not compatible. Overriden " + "to use db backend!") + setattr(settings, "SESSION_ENGINE", "django.contrib.sessions.backends.db") + + +def ModifyAvailableCommands(): + """Removes incompatible commands and installs replacements where possible.""" + if have_appserver: + # Commands are not used when running from an appserver. + return + from django.core import management + project_directory = os.path.join(__path__[0], "../") + if have_django_zip: + FindCommandsInZipfile.orig = management.find_commands + management.find_commands = FindCommandsInZipfile + management.get_commands() + # Replace startapp command which is set by previous call to get_commands(). + from appengine_django.management.commands.startapp import ProjectCommand + management._commands['startapp'] = ProjectCommand(project_directory) + RemoveCommands(management._commands) + logging.debug("Removed incompatible Django manage.py commands") + + +def FindCommandsInZipfile(management_dir): + """ + Given a path to a management directory, returns a list of all the command + names that are available. + + This implementation also works when Django is loaded from a zip. + + Returns an empty list if no commands are defined. + """ + zip_marker = ".zip%s" % os.sep + if zip_marker not in management_dir: + return FindCommandsInZipfile.orig(management_dir) + + # Django is sourced from a zipfile, ask zip module for a list of files. + filename, path = management_dir.split(zip_marker) + zipinfo = zipfile.ZipFile("%s.zip" % filename) + + # Add commands directory to management path. + path = os.path.join(path, "commands") + + # The zipfile module returns paths in the format of the operating system + # that created the zipfile! This may not match the path to the zipfile + # itself. Convert operating system specific characters to a standard + # character (#) to compare paths to work around this. + path_normalise = re.compile(r"[/\\]") + path = path_normalise.sub("#", path) + def _IsCmd(t): + """Returns true if t matches the criteria for a command module.""" + filename = os.path.basename(t) + t = path_normalise.sub("#", t) + if not t.startswith(path): + return False + if filename.startswith("_") or not t.endswith(".py"): + return False + return True + + return [os.path.basename(f)[:-3] for f in zipinfo.namelist() if _IsCmd(f)] + + +def RemoveCommands(command_dict): + """Removes incompatible commands from the specified command dictionary.""" + for cmd in command_dict.keys(): + if cmd.startswith("sql"): + del command_dict[cmd] + elif cmd in INCOMPATIBLE_COMMANDS: + del command_dict[cmd] + + +def InstallReplacementImpModule(): + """Install a replacement for the imp module removed by the appserver. + + This is only to find mangement modules provided by applications. + """ + if not have_appserver: + return + modname = 'appengine_django.replacement_imp' + imp_mod = __import__(modname, {}, [], ['']) + sys.modules['imp'] = imp_mod + logging.debug("Installed replacement imp module") + + +def InstallAppengineHelperForDjango(): + """Installs and Patches all of the classes/methods required for integration. + + If the variable DEBUG_APPENGINE_DJANGO is set in the environment verbose + logging of the actions taken will be enabled. + """ + # Adding this again here to solve a problem that happens when context + # switching from webapp.template to django.template. + # TODO(elsigh): Maybe there is a deeper, fixable problem somewhere? + os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' + + if VERSION < (1, 0, None): + logging.error("Django 1.0 or greater is required!") + sys.exit(1) + + if os.getenv("DEBUG_APPENGINE_DJANGO"): + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.INFO) + logging.debug("Loading the Google App Engine Helper for Django...") + + # Force Django to reload its settings. + settings._target = None + + LoadAppengineEnvironment() + InstallReplacementImpModule() + InstallAppengineDatabaseBackend() + InstallModelForm() + InstallGoogleMemcache() + InstallDjangoModuleReplacements() + PatchDjangoSerializationModules() + CleanupDjangoSettings() + ModifyAvailableCommands() + InstallGoogleSMTPConnection() + InstallAuthentication() + + logging.debug("Successfully loaded the Google App Engine Helper for Django.") + + +def InstallGoogleSMTPConnection(): + from appengine_django import mail as gmail + from django.core import mail + logging.debug("Installing Google Email Adapter for Django") + mail.SMTPConnection = gmail.GoogleSMTPConnection + mail.mail_admins = gmail.mail_admins + mail.mail_managers = gmail.mail_managers + + +def InstallAuthentication(): + if "django.contrib.auth" not in settings.INSTALLED_APPS: + return + try: + from appengine_django.auth import models as helper_models + from django.contrib.auth import models + models.User = helper_models.User + models.Group = helper_models.Group + models.Permission = helper_models.Permission + models.Message = helper_models.Message + from django.contrib.auth import middleware as django_middleware + from appengine_django.auth.middleware import AuthenticationMiddleware + django_middleware.AuthenticationMiddleware = AuthenticationMiddleware + from django.contrib.auth import decorators as django_decorators + from appengine_django.auth.decorators import login_required + django_decorators.login_required = login_required + from django.contrib import auth as django_auth + from django.contrib.auth import tests as django_tests + django_auth.suite = unittest.TestSuite + django_tests.suite = unittest.TestSuite + logging.debug("Installing authentication framework") + except ImportError: + logging.debug("No Django authentication support available") + + +def InstallModelForm(): + """Replace Django ModelForm with the AppEngine ModelForm.""" + # This MUST happen as early as possible, but after any auth model patching. + from google.appengine.ext.db import djangoforms as aeforms + try: + # pre 1.0 + from django import newforms as forms + except ImportError: + from django import forms + + forms.ModelForm = aeforms.ModelForm + + # Extend ModelForm with support for EmailProperty + # TODO: This should be submitted to the main App Engine SDK. + from google.appengine.ext.db import EmailProperty + def get_form_field(self, **kwargs): + """Return a Django form field appropriate for an email property.""" + defaults = {'form_class': forms.EmailField} + defaults.update(kwargs) + return super(EmailProperty, self).get_form_field(**defaults) + EmailProperty.get_form_field = get_form_field diff --git a/appengine_django/auth/__init__.py b/appengine_django/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d2db207407af006d1609defe407a7efdb1c99e2f --- /dev/null +++ b/appengine_django/auth/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Authentication module that mimics the behavior of Django's authentication +implementation. + +Limitations: + - all user permissions methods are not available (requires contenttypes) +""" + +from django.template import add_to_builtins + +add_to_builtins('appengine_django.auth.templatetags') diff --git a/appengine_django/auth/decorators.py b/appengine_django/auth/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..d897c24713068fb70041c94d537f07fb1ce8a086 --- /dev/null +++ b/appengine_django/auth/decorators.py @@ -0,0 +1,31 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Decorators for the authentication framework.""" + +from django.http import HttpResponseRedirect + +from google.appengine.api import users + + +def login_required(function): + """Implementation of Django's login_required decorator. + + The login redirect URL is always set to request.path + """ + def login_required_wrapper(request, *args, **kw): + if request.user.is_authenticated(): + return function(request, *args, **kw) + return HttpResponseRedirect(users.create_login_url(request.path)) + return login_required_wrapper diff --git a/appengine_django/auth/middleware.py b/appengine_django/auth/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..a727e4703dff68520e85874509c6c24b6336fd31 --- /dev/null +++ b/appengine_django/auth/middleware.py @@ -0,0 +1,36 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.contrib.auth.models import AnonymousUser + +from google.appengine.api import users + +from appengine_django.auth.models import User + + +class LazyUser(object): + def __get__(self, request, obj_type=None): + if not hasattr(request, '_cached_user'): + user = users.get_current_user() + if user: + request._cached_user = User.get_djangouser_for_user(user) + else: + request._cached_user = AnonymousUser() + return request._cached_user + + +class AuthenticationMiddleware(object): + def process_request(self, request): + request.__class__.user = LazyUser() + return None diff --git a/appengine_django/auth/models.py b/appengine_django/auth/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d93e2404438f60269fe3385ca1a453e807b0d88d --- /dev/null +++ b/appengine_django/auth/models.py @@ -0,0 +1,172 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +App Engine compatible models for the Django authentication framework. +""" + +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.db import models +from django.utils.encoding import smart_str +import urllib + +from django.db.models.manager import EmptyManager + +from google.appengine.api import users +from google.appengine.ext import db + +from appengine_django.models import BaseModel + + +class User(BaseModel): + """A model with the same attributes and methods as a Django user model. + + The model has two additions. The first addition is a 'user' attribute which + references a App Engine user. The second is the 'get_djangouser_for_user' + classmethod that should be used to retrieve a DjangoUser instance from a App + Engine user object. + """ + user = db.UserProperty(required=True) + username = db.StringProperty(required=True) + first_name = db.StringProperty() + last_name = db.StringProperty() + email = db.EmailProperty() + password = db.StringProperty() + is_staff = db.BooleanProperty(default=False, required=True) + is_active = db.BooleanProperty(default=True, required=True) + is_superuser = db.BooleanProperty(default=False, required=True) + last_login = db.DateTimeProperty(auto_now_add=True, required=True) + date_joined = db.DateTimeProperty(auto_now_add=True, required=True) + groups = EmptyManager() + user_permissions = EmptyManager() + + def __unicode__(self): + return self.username + + def __str__(self): + return unicode(self).encode('utf-8') + + @classmethod + def get_djangouser_for_user(cls, user): + query = cls.all().filter("user =", user) + if query.count() == 0: + django_user = cls(user=user, email=user.email(), username=user.nickname()) + django_user.save() + else: + django_user = query.get() + return django_user + + def set_password(self, raw_password): + raise NotImplementedError + + def check_password(self, raw_password): + raise NotImplementedError + + def set_unusable_password(self): + raise NotImplementedError + + def has_usable_password(self): + raise NotImplementedError + + def get_group_permissions(self): + return self.user_permissions + + def get_all_permissions(self): + return self.user_permissions + + def has_perm(self, perm): + return False + + def has_perms(self, perm_list): + return False + + def has_module_perms(self, module): + return False + + def get_and_delete_messages(self): + """Gets and deletes messages for this user""" + msgs = [] + for msg in self.message_set: + msgs.append(msg) + msg.delete() + return msgs + + def is_anonymous(self): + """Always return False""" + return False + + def is_authenticated(self): + """Always return True""" + return True + + def get_absolute_url(self): + return "/users/%s/" % urllib.quote(smart_str(self.username)) + + def get_full_name(self): + full_name = u'%s %s' % (self.first_name, self.last_name) + return full_name.strip() + + def email_user(self, subject, message, from_email): + """Sends an email to this user. + + According to the App Engine email API the from_email must be the + email address of a registered administrator for the application. + """ + mail.send_mail(subject, + message, + from_email, + [self.email]) + + def get_profile(self): + """ + Returns site-specific profile for this user. Raises + SiteProfileNotAvailable if this site does not allow profiles. + + When using the App Engine authentication framework, users are created + automatically. + """ + from django.contrib.auth.models import SiteProfileNotAvailable + if not hasattr(self, '_profile_cache'): + from django.conf import settings + if not hasattr(settings, "AUTH_PROFILE_MODULE"): + raise SiteProfileNotAvailable + try: + app_label, model_name = settings.AUTH_PROFILE_MODULE.split('.') + model = models.get_model(app_label, model_name) + self._profile_cache = model.all().filter("user =", self).get() + if not self._profile_cache: + raise model.DoesNotExist + except (ImportError, ImproperlyConfigured): + raise SiteProfileNotAvailable + return self._profile_cache + + +class Group(BaseModel): + """Group model not fully implemented yet.""" + # TODO: Implement this model, requires contenttypes + name = db.StringProperty() + permissions = EmptyManager() + + +class Message(BaseModel): + """User message model""" + user = db.ReferenceProperty(User) + message = db.TextProperty() + + +class Permission(BaseModel): + """Permission model not fully implemented yet.""" + # TODO: Implement this model, requires contenttypes + name = db.StringProperty() diff --git a/appengine_django/auth/templatetags.py b/appengine_django/auth/templatetags.py new file mode 100644 index 0000000000000000000000000000000000000000..82378905bf2842f3a54ba771728d5e142114a3a3 --- /dev/null +++ b/appengine_django/auth/templatetags.py @@ -0,0 +1,62 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Template tags for the auth module. These are inserted into Django as "built-in" +tags so you do not need to use the load statement in your template to get +access to them. +""" + +from django.template import Library +from django.template import Node + +from google.appengine.api import users + + +class AuthLoginUrlsNode(Node): + """Template node that creates an App Engine login or logout URL. + + If create_login_url is True the App Engine's login URL is rendered into + the template, otherwise the logout URL. + """ + def __init__(self, create_login_url, redirect): + self.redirect = redirect + self.create_login_url = create_login_url + + def render(self, context): + if self.create_login_url: + return users.create_login_url(self.redirect) + else: + return users.create_logout_url(self.redirect) + + +def auth_login_urls(parser, token): + """Template tag registered as 'auth_login_url' and 'auth_logout_url' + when the module is imported. + + Both tags take an optional argument that specifies the redirect URL and + defaults to '/'. + """ + bits = list(token.split_contents()) + if len(bits) == 2: + redirect = bits[1] + else: + redirect = "/" + login = bits[0] == "auth_login_url" + return AuthLoginUrlsNode(login, redirect) + + +register = Library() +register.tag("auth_login_url", auth_login_urls) +register.tag("auth_logout_url", auth_login_urls) diff --git a/appengine_django/auth/tests.py b/appengine_django/auth/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..20aecfa4bb88c871b246a4ca3bcf832e5cafdc26 --- /dev/null +++ b/appengine_django/auth/tests.py @@ -0,0 +1,58 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +BASIC_TESTS = """ +>>> from google.appengine.api import users +>>> from models import User, AnonymousUser +>>> appengine_user = users.User("test@example.com") +>>> django_user = User.get_djangouser_for_user(appengine_user) +>>> django_user.email == appengine_user.email() +True +>>> django_user.username == appengine_user.nickname() +True +>>> django_user.user == appengine_user +True + +>>> django_user.username = 'test2' +>>> key = django_user.save() +>>> django_user.username == 'test2' +True + +>>> django_user2 = User.get_djangouser_for_user(appengine_user) +>>> django_user2 == django_user +True + +>>> django_user.is_authenticated() +True +>>> django_user.is_staff +False +>>> django_user.is_active +True + +>>> a = AnonymousUser() +>>> a.is_authenticated() +False +>>> a.is_staff +False +>>> a.is_active +False +>>> a.groups.all() +[] +>>> a.user_permissions.all() +[] + + +""" + +__test__ = {'BASIC_TESTS': BASIC_TESTS} diff --git a/appengine_django/conf/app_template/__init__.py b/appengine_django/conf/app_template/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/conf/app_template/models.py b/appengine_django/conf/app_template/models.py new file mode 100644 index 0000000000000000000000000000000000000000..4d7c5d0ea42b6067c512dc55bc3172fda5304d01 --- /dev/null +++ b/appengine_django/conf/app_template/models.py @@ -0,0 +1,4 @@ +from appengine_django.models import BaseModel +from google.appengine.ext import db + +# Create your models here. diff --git a/appengine_django/conf/app_template/views.py b/appengine_django/conf/app_template/views.py new file mode 100644 index 0000000000000000000000000000000000000000..60f00ef0ef347811e7b0c0921b7fda097acd9fcc --- /dev/null +++ b/appengine_django/conf/app_template/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/appengine_django/db/__init__.py b/appengine_django/db/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..619bc789db6217d72668b1e8f2346c86d03b29db --- /dev/null +++ b/appengine_django/db/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Explicitly set the name of this package to "appengine". +# +# The rationale for this is so that Django can refer to the database as +# "appengine" even though at a filesystem level it appears as the "db" package +# within the appengine_django package. +__name__ = "appengine" diff --git a/appengine_django/db/base.py b/appengine_django/db/base.py new file mode 100755 index 0000000000000000000000000000000000000000..8a90182cf2f304d773a8af8dd2c197d0a1aa9b8d --- /dev/null +++ b/appengine_django/db/base.py @@ -0,0 +1,150 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module looks after initialising the appengine api stubs.""" + +import logging +import os + +from appengine_django import appid +from appengine_django import have_appserver +from appengine_django.db.creation import DatabaseCreation + + +from django.db.backends import BaseDatabaseWrapper +from django.db.backends import BaseDatabaseFeatures +from django.db.backends import BaseDatabaseOperations + + +def get_datastore_paths(): + """Returns a tuple with the path to the datastore and history file. + + The datastore is stored in the same location as dev_appserver uses by + default, but the name is altered to be unique to this project so multiple + Django projects can be developed on the same machine in parallel. + + Returns: + (datastore_path, history_path) + """ + from google.appengine.tools import dev_appserver_main + datastore_path = dev_appserver_main.DEFAULT_ARGS['datastore_path'] + history_path = dev_appserver_main.DEFAULT_ARGS['history_path'] + datastore_path = datastore_path.replace("dev_appserver", "django_%s" % appid) + history_path = history_path.replace("dev_appserver", "django_%s" % appid) + return datastore_path, history_path + + +def get_test_datastore_paths(inmemory=True): + """Returns a tuple with the path to the test datastore and history file. + + If inmemory is true, (None, None) is returned to request an in-memory + datastore. If inmemory is false the path returned will be similar to the path + returned by get_datastore_paths but with a different name. + + Returns: + (datastore_path, history_path) + """ + if inmemory: + return None, None + datastore_path, history_path = get_datastore_paths() + datastore_path = datastore_path.replace("datastore", "testdatastore") + history_path = history_path.replace("datastore", "testdatastore") + return datastore_path, history_path + + +def destroy_datastore(datastore_path, history_path): + """Destroys the appengine datastore at the specified paths.""" + for path in [datastore_path, history_path]: + if not path: continue + try: + os.remove(path) + except OSError, e: + if e.errno != 2: + logging.error("Failed to clear datastore: %s" % e) + + +class DatabaseError(Exception): + """Stub class for database errors. Required by Django""" + pass + + +class IntegrityError(Exception): + """Stub class for database integrity errors. Required by Django""" + pass + + +class DatabaseFeatures(BaseDatabaseFeatures): + """Stub class to provide the feaures member expected by Django""" + pass + + +class DatabaseOperations(BaseDatabaseOperations): + """Stub class to provide the options member expected by Django""" + pass + + +class DatabaseWrapper(BaseDatabaseWrapper): + """App Engine database definition for Django. + + This "database" backend does not support any of the standard backend + operations. The only task that it performs is to setup the api stubs required + by the appengine libraries if they have not already been initialised by an + appserver. + """ + + def __init__(self, *args, **kwargs): + super(DatabaseWrapper, self).__init__(*args, **kwargs) + self.features = DatabaseFeatures() + self.ops = DatabaseOperations() + self.creation = DatabaseCreation(self) + self.use_test_datastore = kwargs.get("use_test_datastore", False) + self.test_datastore_inmemory = kwargs.get("test_datastore_inmemory", True) + if have_appserver: + return + self._setup_stubs() + + def _get_paths(self): + if self.use_test_datastore: + return get_test_datastore_paths(self.test_datastore_inmemory) + else: + return get_datastore_paths() + + def _setup_stubs(self): + # If this code is being run without an appserver (eg. via a django + # commandline flag) then setup a default stub environment. + from google.appengine.tools import dev_appserver_main + args = dev_appserver_main.DEFAULT_ARGS.copy() + args['datastore_path'], args['history_path'] = self._get_paths() + from google.appengine.tools import dev_appserver + dev_appserver.SetupStubs(appid, **args) + if self.use_test_datastore: + logging.debug("Configured API stubs for the test datastore") + else: + logging.debug("Configured API stubs for the development datastore") + + def flush(self): + """Helper function to remove the current datastore and re-open the stubs""" + destroy_datastore(*self._get_paths()) + self._setup_stubs() + + def close(self): + pass + + def _commit(self): + pass + + def cursor(self, *args): + pass diff --git a/appengine_django/db/creation.py b/appengine_django/db/creation.py new file mode 100755 index 0000000000000000000000000000000000000000..0e0e27775a4efcd9ddc49f969d60af8cac40f68a --- /dev/null +++ b/appengine_django/db/creation.py @@ -0,0 +1,39 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import logging + +from django.conf import settings +from django.db.backends.creation import BaseDatabaseCreation + + +class DatabaseCreation(BaseDatabaseCreation): + + def create_test_db(self, *args, **kw): + """Destroys the test datastore. A new store will be recreated on demand""" + settings.DATABASE_SUPPORTS_TRANSACTIONS = False + self.destroy_test_db() + self.connection.use_test_datastore = True + self.connection.flush() + + + def destroy_test_db(self, *args, **kw): + """Destroys the test datastore files.""" + from appengine_django.db.base import destroy_datastore + from appengine_django.db.base import get_test_datastore_paths + destroy_datastore(*get_test_datastore_paths()) + logging.debug("Destroyed test datastore") diff --git a/appengine_django/mail.py b/appengine_django/mail.py new file mode 100644 index 0000000000000000000000000000000000000000..bf3e2ddf99540fd77e8b1da36e8284d953af6e4e --- /dev/null +++ b/appengine_django/mail.py @@ -0,0 +1,95 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module replaces the Django mail implementation with a version that sends +email via the mail API provided by Google App Engine. + +Multipart / HTML email is not yet supported. +""" + +import logging + +from django.core import mail +from django.core.mail import SMTPConnection +from django.conf import settings + +from google.appengine.api import mail as gmail + + +class GoogleSMTPConnection(SMTPConnection): + def __init__(self, host=None, port=None, username=None, password=None, + use_tls=None, fail_silently=False): + self.use_tls = (use_tls is not None) and use_tls or settings.EMAIL_USE_TLS + self.fail_silently = fail_silently + self.connection = None + + def open(self): + self.connection = True + + def close(self): + pass + + def _send(self, email_message): + """A helper method that does the actual sending.""" + if not email_message.to: + return False + try: + if (isinstance(email_message,gmail.EmailMessage)): + e = message + elif (isinstance(email_message,mail.EmailMessage)): + e = gmail.EmailMessage(sender=email_message.from_email, + to=email_message.to, + subject=email_message.subject, + body=email_message.body) + if email_message.extra_headers.get('Reply-To', None): + e.reply_to = email_message.extra_headers['Reply-To'] + if email_message.bcc: + e.bcc = list(email_message.bcc) + #TODO - add support for html messages and attachments... + e.send() + except: + if not self.fail_silently: + raise + return False + return True + + +def mail_admins(subject, message, fail_silently=False): + """Sends a message to the admins, as defined by the ADMINS setting.""" + _mail_group(settings.ADMINS, subject, message, fail_silently) + + +def mail_managers(subject, message, fail_silently=False): + """Sends a message to the managers, as defined by the MANAGERS setting.""" + _mail_group(settings.MANAGERS, subject, message, fail_silently) + + +def _mail_group(group, subject, message, fail_silently=False): + """Sends a message to an administrative group.""" + if group: + mail.send_mail(settings.EMAIL_SUBJECT_PREFIX + subject, message, + settings.SERVER_EMAIL, [a[1] for a in group], + fail_silently) + return + # If the group had no recipients defined, default to the App Engine admins. + try: + gmail.send_mail_to_admins(settings.SERVER_EMAIL, + settings.EMAIL_SUBJECT_PREFIX + subject, + message) + except: + if not fail_silently: + raise diff --git a/appengine_django/management/__init__.py b/appengine_django/management/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/management/commands/__init__.py b/appengine_django/management/commands/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/management/commands/console.py b/appengine_django/management/commands/console.py new file mode 100755 index 0000000000000000000000000000000000000000..2c4069706e8cdd45670230bcb922676b1d950c63 --- /dev/null +++ b/appengine_django/management/commands/console.py @@ -0,0 +1,49 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import code +import getpass +import os +import sys + +from django.conf import settings +from django.core.management.base import BaseCommand + +from google.appengine.ext.remote_api import remote_api_stub + + +def auth_func(): + return raw_input('Username:'), getpass.getpass('Password:') + +class Command(BaseCommand): + """ Start up an interactive console backed by your app using remote_api """ + + help = 'Start up an interactive console backed by your app using remote_api.' + + def run_from_argv(self, argv): + app_id = argv[2] + if len(argv) > 3: + host = argv[3] + else: + host = '%s.appspot.com' % app_id + + remote_api_stub.ConfigureRemoteDatastore(app_id, + '/remote_api', + auth_func, + host) + + code.interact('App Engine interactive console for %s' % (app_id,), + None, + locals()) diff --git a/appengine_django/management/commands/flush.py b/appengine_django/management/commands/flush.py new file mode 100755 index 0000000000000000000000000000000000000000..c5f3f8c23036c778f6aae206a6a4f06c2532680a --- /dev/null +++ b/appengine_django/management/commands/flush.py @@ -0,0 +1,36 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import sys + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django flush command. + """ + help = 'Clears the current datastore and loads the initial fixture data.' + + def run_from_argv(self, argv): + from django.db import connection + connection.flush() + from django.core.management import call_command + call_command('loaddata', 'initial_data') + + def handle(self, *args, **kwargs): + self.run_from_argv(None) diff --git a/appengine_django/management/commands/reset.py b/appengine_django/management/commands/reset.py new file mode 100755 index 0000000000000000000000000000000000000000..126f38634086a0de7e3a838d1ba832df0618c105 --- /dev/null +++ b/appengine_django/management/commands/reset.py @@ -0,0 +1,32 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import logging +import os +import sys + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django reset command. + """ + help = 'Clears the current datastore.' + + def run_from_argv(self, argv): + from django.db import connection + connection.flush() diff --git a/appengine_django/management/commands/rollback.py b/appengine_django/management/commands/rollback.py new file mode 100755 index 0000000000000000000000000000000000000000..6ce9e4eaca1d64a5c0e087bcf019381ad37689c8 --- /dev/null +++ b/appengine_django/management/commands/rollback.py @@ -0,0 +1,52 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'rollback' we will have to munge the args to replace whatever + # we called it with 'rollback' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + + +class Command(BaseCommand): + """Calls the appcfg.py's rollback command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py rollback for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/management/commands/runserver.py b/appengine_django/management/commands/runserver.py new file mode 100755 index 0000000000000000000000000000000000000000..07d080d1f9e2a3668701e0788c5f029b1fa45869 --- /dev/null +++ b/appengine_django/management/commands/runserver.py @@ -0,0 +1,82 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import sys + +from appengine_django.db.base import get_datastore_paths + +from django.core.management.base import BaseCommand + + +def start_dev_appserver(): + """Starts the appengine dev_appserver program for the Django project. + + The appserver is run with default parameters. If you need to pass any special + parameters to the dev_appserver you will have to invoke it manually. + """ + from google.appengine.tools import dev_appserver_main + progname = sys.argv[0] + args = [] + # hack __main__ so --help in dev_appserver_main works OK. + sys.modules['__main__'] = dev_appserver_main + # Set bind ip/port if specified. + if len(sys.argv) > 2: + addrport = sys.argv[2] + try: + addr, port = addrport.split(":") + except ValueError: + addr, port = None, addrport + if not port.isdigit(): + print "Error: '%s' is not a valid port number." % port + sys.exit(1) + else: + addr, port = None, "8000" + if addr: + args.extend(["--address", addr]) + if port: + args.extend(["--port", port]) + # Add email settings + from django.conf import settings + args.extend(['--smtp_host', settings.EMAIL_HOST, + '--smtp_port', str(settings.EMAIL_PORT), + '--smtp_user', settings.EMAIL_HOST_USER, + '--smtp_password', settings.EMAIL_HOST_PASSWORD]) + + # Allow skipped files so we don't die + args.extend(['--allow_skipped_files']) + + # Pass the application specific datastore location to the server. + p = get_datastore_paths() + args.extend(["--datastore_path", p[0], "--history_path", p[1]]) + + # Append the current working directory to the arguments. + dev_appserver_main.main([progname] + args + [os.getcwdu()]) + + +class Command(BaseCommand): + """Overrides the default Django runserver command. + + Instead of starting the default Django development server this command + fires up a copy of the full fledged appengine dev_appserver that emulates + the live environment your application will be deployed to. + """ + help = 'Runs a copy of the appengine development server.' + args = '[optional port number, or ipaddr:port]' + + def run_from_argv(self, argv): + start_dev_appserver() diff --git a/appengine_django/management/commands/startapp.py b/appengine_django/management/commands/startapp.py new file mode 100644 index 0000000000000000000000000000000000000000..2648cbddfc5f8669165b3d937a294579b70f6b04 --- /dev/null +++ b/appengine_django/management/commands/startapp.py @@ -0,0 +1,43 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os + +import django +from django.core.management.commands import startapp + +import appengine_django + + +class Command(startapp.Command): + def handle_label(self, *args, **kwds): + """Temporary adjust django.__path__ to load app templates from the + helpers directory. + """ + old_path = django.__path__ + django.__path__ = appengine_django.__path__ + startapp.Command.handle_label(self, *args, **kwds) + django.__path__ = old_path + + +class ProjectCommand(Command): + def __init__(self, project_directory): + super(ProjectCommand, self).__init__() + self.project_directory = project_directory + + def handle_label(self, app_name, **options): + super(ProjectCommand, self).handle_label(app_name, self.project_directory, + **options) + diff --git a/appengine_django/management/commands/testserver.py b/appengine_django/management/commands/testserver.py new file mode 100755 index 0000000000000000000000000000000000000000..5a50a7084122a31c2679ec8701967b382be6dabd --- /dev/null +++ b/appengine_django/management/commands/testserver.py @@ -0,0 +1,74 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import os +import sys + +from appengine_django.db.base import destroy_datastore +from appengine_django.db.base import get_test_datastore_paths + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Overrides the default Django testserver command. + + Instead of starting the default Django development server this command fires + up a copy of the full fledged appengine dev_appserver. + + The appserver is always initialised with a blank datastore with the specified + fixtures loaded into it. + """ + help = 'Runs the development server with data from the given fixtures.' + + def run_from_argv(self, argv): + fixtures = argv[2:] + + # Ensure an on-disk test datastore is used. + from django.db import connection + connection.use_test_datastore = True + connection.test_datastore_inmemory = False + + # Flush any existing test datastore. + connection.flush() + + # Load the fixtures. + from django.core.management import call_command + call_command('loaddata', 'initial_data') + if fixtures: + call_command('loaddata', *fixtures) + + # Build new arguments for dev_appserver. + datastore_path, history_path = get_test_datastore_paths(False) + new_args = argv[0:1] + new_args.extend(['--datastore_path', datastore_path]) + new_args.extend(['--history_path', history_path]) + new_args.extend([os.getcwdu()]) + + # Add email settings + from django.conf import settings + new_args.extend(['--smtp_host', settings.EMAIL_HOST, + '--smtp_port', str(settings.EMAIL_PORT), + '--smtp_user', settings.EMAIL_HOST_USER, + '--smtp_password', settings.EMAIL_HOST_PASSWORD]) + + # Allow skipped files so we don't die + new_args.extend(['--allow_skipped_files']) + + # Start the test dev_appserver. + from google.appengine.tools import dev_appserver_main + dev_appserver_main.main(new_args) diff --git a/appengine_django/management/commands/update.py b/appengine_django/management/commands/update.py new file mode 100755 index 0000000000000000000000000000000000000000..e489d5d09607b10ca16a2a393aa065a89e55d5ea --- /dev/null +++ b/appengine_django/management/commands/update.py @@ -0,0 +1,51 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'update' we will have to munge the args to replace whatever + # we called it with 'update' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + +class Command(BaseCommand): + """Calls the appcfg.py's update command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py update for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/management/commands/vacuum_indexes.py b/appengine_django/management/commands/vacuum_indexes.py new file mode 100755 index 0000000000000000000000000000000000000000..ab276b414b7d7fb318d9e16114c9c109049655b2 --- /dev/null +++ b/appengine_django/management/commands/vacuum_indexes.py @@ -0,0 +1,52 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import logging + +from django.core.management.base import BaseCommand + + +def run_appcfg(): + # import this so that we run through the checks at the beginning + # and report the appropriate errors + import appcfg + + # We don't really want to use that one though, it just executes this one + from google.appengine.tools import appcfg + + # Reset the logging level to WARN as appcfg will spew tons of logs on INFO + logging.getLogger().setLevel(logging.WARN) + + # Note: if we decide to change the name of this command to something other + # than 'vacuum_indexes' we will have to munge the args to replace whatever + # we called it with 'vacuum_indexes' + new_args = sys.argv[:] + new_args.append('.') + appcfg.main(new_args) + + +class Command(BaseCommand): + """Calls the appcfg.py's vacuum_indexes command for the current project. + + Any additional arguments are passed directly to appcfg.py. + """ + help = 'Calls appcfg.py vacuum_indexes for the current project.' + args = '[any appcfg.py options]' + + def run_from_argv(self, argv): + run_appcfg() diff --git a/appengine_django/models.py b/appengine_django/models.py new file mode 100755 index 0000000000000000000000000000000000000000..0b9f6dcb546e6de40176e503861b400b58f41e83 --- /dev/null +++ b/appengine_django/models.py @@ -0,0 +1,182 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import types + +from google.appengine.ext import db + +from django import VERSION +from django.core.exceptions import ObjectDoesNotExist +from django.db.models.fields import Field +from django.db.models.options import Options +from django.db.models.loading import register_models, get_model + + +class ModelManager(object): + """Replacement for the default Django model manager.""" + + def __init__(self, owner): + self.owner = owner + + def __getattr__(self, name): + """Pass all attribute requests through to the real model""" + return getattr(self.owner, name) + + +class ModelOptions(object): + """Replacement for the default Django options class. + + This class sits at ._meta of each model. The primary information supplied by + this class that needs to be stubbed out is the list of fields on the model. + """ + + def __init__(self, cls): + self.object_name = cls.__name__ + self.module_name = self.object_name.lower() + model_module = sys.modules[cls.__module__] + self.app_label = model_module.__name__.split('.')[-2] + self.abstract = False + + class pk: + """Stub the primary key to always be 'key_name'""" + name = "key_name" + + def __str__(self): + return "%s.%s" % (self.app_label, self.module_name) + + @property + def many_to_many(self): + """The datastore does not support many to many relationships.""" + return [] + + +class Relation(object): + def __init__(self, to): + self.field_name = "key_name" + + +def PropertyWrapper(prop): + """Wrapper for db.Property to make it look like a Django model Property""" + if isinstance(prop, db.Reference): + prop.rel = Relation(prop.reference_class) + else: + prop.rel = None + prop.serialize = True + return prop + + +class PropertiedClassWithDjango(db.PropertiedClass): + """Metaclass for the combined Django + App Engine model class. + + This metaclass inherits from db.PropertiedClass in the appengine library. + This metaclass has two additional purposes: + 1) Register each model class created with Django (the parent class will take + care of registering it with the appengine libraries). + 2) Add the (minimum number) of attributes and methods to make Django believe + the class is a normal Django model. + + The resulting classes are still not generally useful as Django classes and + are intended to be used by Django only in limited situations such as loading + and dumping fixtures. + """ + + def __new__(cls, name, bases, attrs): + """Creates a combined appengine and Django model. + + The resulting model will be known to both the appengine libraries and + Django. + """ + if name == 'BaseModel': + # This metaclass only acts on subclasses of BaseModel. + return super(PropertiedClassWithDjango, cls).__new__(cls, name, + bases, attrs) + + new_class = super(PropertiedClassWithDjango, cls).__new__(cls, name, + bases, attrs) + + new_class._meta = ModelOptions(new_class) + new_class.objects = ModelManager(new_class) + new_class._default_manager = new_class.objects + new_class.DoesNotExist = types.ClassType('DoesNotExist', + (ObjectDoesNotExist,), {}) + + m = get_model(new_class._meta.app_label, name, False) + if m: + return m + + register_models(new_class._meta.app_label, new_class) + return get_model(new_class._meta.app_label, name, False) + + def __init__(cls, name, bases, attrs): + """Initialises the list of Django properties. + + This method takes care of wrapping the properties created by the superclass + so that they look like Django properties and installing them into the + ._meta object of the class so that Django can find them at the appropriate + time. + """ + super(PropertiedClassWithDjango, cls).__init__(name, bases, attrs) + if name == 'BaseModel': + # This metaclass only acts on subclasses of BaseModel. + return + + fields = [PropertyWrapper(p) for p in cls._properties.values()] + cls._meta.local_fields = fields + + +class BaseModel(db.Model): + """Combined appengine and Django model. + + All models used in the application should derive from this class. + """ + __metaclass__ = PropertiedClassWithDjango + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self._get_pk_val() == other._get_pk_val() + + def __ne__(self, other): + return not self.__eq__(other) + + def _get_pk_val(self): + """Return the string representation of the model's key""" + return unicode(self.key()) + + def __repr__(self): + """Create a string that can be used to construct an equivalent object. + + e.g. eval(repr(obj)) == obj + """ + # First, creates a dictionary of property names and values. Note that + # property values, not property objects, has to be passed in to constructor. + def _MakeReprTuple(prop_name): + prop = getattr(self.__class__, prop_name) + return (prop_name, prop.get_value_for_datastore(self)) + + d = dict([_MakeReprTuple(prop_name) for prop_name in self.properties()]) + return "%s(**%s)" % (self.__class__.__name__, repr(d)) + + +class RegistrationTestModel(BaseModel): + """Used to check registration with Django is working correctly. + + Django 0.96 only recognises models defined within an applications models + module when get_models() is called so this definition must be here rather + than within the associated test (tests/model_test.py). + """ + pass diff --git a/appengine_django/replacement_imp.py b/appengine_django/replacement_imp.py new file mode 100644 index 0000000000000000000000000000000000000000..330aaf01dc666d4071b7566b0e0cefee3afba721 --- /dev/null +++ b/appengine_django/replacement_imp.py @@ -0,0 +1,26 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This file acts as a very minimal replacement for the 'imp' module. + +It contains only what Django expects to use and does not actually implement the +same functionality as the real 'imp' module. +""" + + +def find_module(name, path=None): + """Django needs imp.find_module, but it works fine if nothing is found.""" + raise ImportError diff --git a/appengine_django/serializer/__init__.py b/appengine_django/serializer/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/serializer/python.py b/appengine_django/serializer/python.py new file mode 100755 index 0000000000000000000000000000000000000000..bce16e70e00be1fd64ea6aee224b9e9b279167a7 --- /dev/null +++ b/appengine_django/serializer/python.py @@ -0,0 +1,130 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A Python "serializer", based on the default Django python serializer. + +The only customisation is in the deserialization process which needs to take +special care to resolve the name and parent attributes of the key for each +entity and also recreate the keys for any references appropriately. +""" + + +from django.conf import settings +from django.core.serializers import base +from django.core.serializers import python +from django.db import models + +from google.appengine.api import datastore_types +from google.appengine.ext import db + +from django.utils.encoding import smart_unicode + +Serializer = python.Serializer + + +class FakeParent(object): + """Fake parent 'model' like object. + + This class exists to allow a parent object to be provided to a new model + without having to load the parent instance itself. + """ + + def __init__(self, parent_key): + self._entity = parent_key + + +def Deserializer(object_list, **options): + """Deserialize simple Python objects back into Model instances. + + It's expected that you pass the Python objects themselves (instead of a + stream or a string) to the constructor + """ + models.get_apps() + for d in object_list: + # Look up the model and starting build a dict of data for it. + Model = python._get_model(d["model"]) + data = {} + key = resolve_key(Model._meta.module_name, d["pk"]) + if key.name(): + data["key_name"] = key.name() + parent = None + if key.parent(): + parent = FakeParent(key.parent()) + m2m_data = {} + + # Handle each field + for (field_name, field_value) in d["fields"].iteritems(): + if isinstance(field_value, str): + field_value = smart_unicode( + field_value, options.get("encoding", + settings.DEFAULT_CHARSET), + strings_only=True) + field = Model.properties()[field_name] + + if isinstance(field, db.Reference): + # Resolve foreign key references. + data[field.name] = resolve_key(Model._meta.module_name, field_value) + if not data[field.name].name(): + raise base.DeserializationError(u"Cannot load Reference with " + "unnamed key: '%s'" % field_value) + else: + data[field.name] = field.validate(field_value) + # Create the new model instance with all it's data, but no parent. + object = Model(**data) + # Now add the parent into the hidden attribute, bypassing the type checks + # in the Model's __init__ routine. + object._parent = parent + # When the deserialized object is saved our replacement DeserializedObject + # class will set object._parent to force the real parent model to be loaded + # the first time it is referenced. + yield base.DeserializedObject(object, m2m_data) + + +def resolve_key(model, key_data): + """Creates a Key instance from a some data. + + Args: + model: The name of the model this key is being resolved for. Only used in + the fourth case below (a plain key_name string). + key_data: The data to create a key instance from. May be in four formats: + * The str() output of a key instance. Eg. A base64 encoded string. + * The repr() output of a key instance. Eg. A string for eval(). + * A list of arguments to pass to db.Key.from_path. + * A single string value, being the key_name of the instance. When this + format is used the resulting key has no parent, and is for the model + named in the model parameter. + + Returns: + An instance of db.Key. If the data cannot be used to create a Key instance + an error will be raised. + """ + if isinstance(key_data, list): + # The key_data is a from_path sequence. + return db.Key.from_path(*key_data) + elif isinstance(key_data, basestring): + if key_data.find("from_path") != -1: + # key_data is encoded in repr(key) format + return eval(key_data) + else: + try: + # key_data encoded a str(key) format + return db.Key(key_data) + except datastore_types.datastore_errors.BadKeyError, e: + # Final try, assume it's a plain key name for the model. + return db.Key.from_path(model, key_data) + else: + raise base.DeserializationError(u"Invalid key data: '%s'" % key_data) diff --git a/appengine_django/serializer/xml.py b/appengine_django/serializer/xml.py new file mode 100755 index 0000000000000000000000000000000000000000..f67588a527fdd636f4adeda9a83f2abe40f89948 --- /dev/null +++ b/appengine_django/serializer/xml.py @@ -0,0 +1,147 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Replaces the default Django XML serializer with one that uses the built in +ToXml method for each entity. +""" + +import re + +from django.conf import settings +from django.core.serializers import base +from django.core.serializers import xml_serializer +from django.db import models + +from google.appengine.api import datastore_types +from google.appengine.ext import db + +from python import FakeParent + +getInnerText = xml_serializer.getInnerText + + +class Serializer(xml_serializer.Serializer): + """A Django Serializer class to convert datastore models to XML. + + This class relies on the ToXml method of the entity behind each model to do + the hard work. + """ + + def __init__(self, *args, **kwargs): + super(Serializer, self).__init__(*args, **kwargs) + self._objects = [] + + def handle_field(self, obj, field): + """Fields are not handled individually.""" + pass + + def handle_fk_field(self, obj, field): + """Fields are not handled individually.""" + pass + + def start_object(self, obj): + """Nothing needs to be done to start an object.""" + pass + + def end_object(self, obj): + """Serialize the object to XML and add to the list of objects to output. + + The output of ToXml is manipulated to replace the datastore model name in + the "kind" tag with the Django model name (which includes the Django + application name) to make importing easier. + """ + xml = obj._entity.ToXml() + xml = xml.replace(u"""kind="%s" """ % obj._entity.kind(), + u"""kind="%s" """ % unicode(obj._meta)) + self._objects.append(xml) + + def getvalue(self): + """Wrap the serialized objects with XML headers and return.""" + str = u"""<?xml version="1.0" encoding="utf-8"?>\n""" + str += u"""<django-objects version="1.0">\n""" + str += u"".join(self._objects) + str += u"""</django-objects>""" + return str + + +class Deserializer(xml_serializer.Deserializer): + """A Django Deserializer class to convert XML to Django objects. + + This is a fairly manualy and simplistic XML parser, it supports just enough + functionality to read the keys and fields for an entity from the XML file and + construct a model object. + """ + + def next(self): + """Replacement next method to look for 'entity'. + + The default next implementation exepects 'object' nodes which is not + what the entity's ToXml output provides. + """ + for event, node in self.event_stream: + if event == "START_ELEMENT" and node.nodeName == "entity": + self.event_stream.expandNode(node) + return self._handle_object(node) + raise StopIteration + + def _handle_object(self, node): + """Convert an <entity> node to a DeserializedObject""" + Model = self._get_model_from_node(node, "kind") + data = {} + key = db.Key(node.getAttribute("key")) + if key.name(): + data["key_name"] = key.name() + parent = None + if key.parent(): + parent = FakeParent(key.parent()) + m2m_data = {} + + # Deseralize each field. + for field_node in node.getElementsByTagName("property"): + # If the field is missing the name attribute, bail (are you + # sensing a pattern here?) + field_name = field_node.getAttribute("name") + if not field_name: + raise base.DeserializationError("<field> node is missing the 'name' " + "attribute") + field = Model.properties()[field_name] + field_value = getInnerText(field_node).strip() + + if isinstance(field, db.Reference): + m = re.match("tag:.*\[(.*)\]", field_value) + if not m: + raise base.DeserializationError(u"Invalid reference value: '%s'" % + field_value) + key = m.group(1) + key_obj = db.Key(key) + if not key_obj.name(): + raise base.DeserializationError(u"Cannot load Reference with " + "unnamed key: '%s'" % field_value) + data[field.name] = key_obj + else: + data[field.name] = field.validate(field_value) + + # Create the new model instance with all it's data, but no parent. + object = Model(**data) + # Now add the parent into the hidden attribute, bypassing the type checks + # in the Model's __init__ routine. + object._parent = parent + # When the deserialized object is saved our replacement DeserializedObject + # class will set object._parent to force the real parent model to be loaded + # the first time it is referenced. + return base.DeserializedObject(object, m2m_data) diff --git a/appengine_django/sessions/__init__.py b/appengine_django/sessions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/sessions/backends/__init__.py b/appengine_django/sessions/backends/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/appengine_django/sessions/backends/db.py b/appengine_django/sessions/backends/db.py new file mode 100644 index 0000000000000000000000000000000000000000..e2e3aa40f43bfd13827842e510e304117225afb6 --- /dev/null +++ b/appengine_django/sessions/backends/db.py @@ -0,0 +1,82 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +from django.contrib.sessions.backends import base +from django.core.exceptions import SuspiciousOperation + +from appengine_django.sessions.models import Session + + +class SessionStore(base.SessionBase): + """A key-based session store for Google App Engine.""" + + def load(self): + session = self._get_session(self.session_key) + if session: + try: + return self.decode(session.session_data) + except SuspiciousOperation: + # Create a new session_key for extra security. + pass + self.session_key = self._get_new_session_key() + self._session_cache = {} + self.save() + # Ensure the user is notified via a new cookie. + self.modified = True + return {} + + def save(self, must_create=False): + if must_create and self.exists(self.session_key): + raise base.CreateError + session = Session( + key_name='k:' + self.session_key, + session_data = self.encode(self._session), + expire_date = self.get_expiry_date()) + session.put() + + def exists(self, session_key): + return Session.get_by_key_name('k:' + session_key) is not None + + def delete(self, session_key=None): + if session_key is None: + session_key = self._session_key + session = self._get_session(session_key=session_key) + if session: + session.delete() + + def _get_session(self, session_key): + session = Session.get_by_key_name('k:' + session_key) + if session: + if session.expire_date > datetime.now(): + return session + session.delete() + return None + + def create(self): + while True: + self.session_key = self._get_new_session_key() + try: + # Save immediately to ensure we have a unique entry in the + # database. + self.save(must_create=True) + except base.CreateError: + # Key wasn't unique. Try again. + continue + self.modified = True + self._session_cache = {} + return diff --git a/appengine_django/sessions/models.py b/appengine_django/sessions/models.py new file mode 100644 index 0000000000000000000000000000000000000000..16816448c99d3631c066c9b18be232e79802dea2 --- /dev/null +++ b/appengine_django/sessions/models.py @@ -0,0 +1,22 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.appengine.ext import db + +class Session(db.Model): + """Django compatible App Engine Datastore session model.""" + session_data = db.BlobProperty() + expire_date = db.DateTimeProperty() diff --git a/appengine_django/tests/__init__.py b/appengine_django/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b511f5858a19aec6068a463be1a553c9c49eb813 --- /dev/null +++ b/appengine_django/tests/__init__.py @@ -0,0 +1,56 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Loads all the _test.py files into the top level of the package. + +This file is a hack around the fact that Django expects the tests "module" to +be a single tests.py file and cannot handle a tests package inside an +application. + +All _test.py files inside this package are imported and any classes derived +from unittest.TestCase are then referenced from this file itself so that they +appear at the top level of the tests "module" that Django will import. +""" + + +import os +import re +import types +import unittest + +TEST_RE = r"^.*_test.py$" + +# Search through every file inside this package. +test_names = [] +test_dir = os.path.dirname( __file__) +for filename in os.listdir(test_dir): + if not re.match(TEST_RE, filename): + continue + # Import the test file and find all TestClass clases inside it. + test_module = __import__('appengine_django.tests.%s' % + filename[:-3], {}, {}, + filename[:-3]) + for name in dir(test_module): + item = getattr(test_module, name) + if not (isinstance(item, (type, types.ClassType)) and + issubclass(item, unittest.TestCase)): + continue + # Found a test, bring into the module namespace. + exec "%s = item" % name + test_names.append(name) + +# Hide everything other than the test cases from other modules. +__all__ = test_names diff --git a/appengine_django/tests/commands_test.py b/appengine_django/tests/commands_test.py new file mode 100755 index 0000000000000000000000000000000000000000..a02ddbfc0ae28b51e9c33e145e7c3f6907adee84 --- /dev/null +++ b/appengine_django/tests/commands_test.py @@ -0,0 +1,183 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests that the manage.py commands execute correctly. + +These tests only verify that the commands execute and exit with a success code. +They are intended to catch import exceptions and similar problems, it is left +up to tests in other modules to verify that the functionality of each command +works correctly. +""" + + +import os +import re +import signal +import subprocess +import tempfile +import time +import unittest + +from django.db.models import get_models + +from google.appengine.ext import db +from appengine_django.models import BaseModel +from appengine_django.models import ModelManager +from appengine_django.models import ModelOptions +from appengine_django.models import RegistrationTestModel + + +class CommandsTest(unittest.TestCase): + """Unit tests for the manage.py commands.""" + + # How many seconds to wait for a command to exit. + COMMAND_TIMEOUT = 10 + + def runCommand(self, command, args=None, int_after=None, input=None): + """Helper to run the specified command in a child process. + + Args: + command: The name of the command to run. + args: List of command arguments to run the command with. + int_after: If set to a positive integer, SIGINT will be sent to the + running child process after this many seconds to cause an exit. This + should be less than the COMMAND_TIMEOUT value (10 seconds). + input: A string to write to stdin when the command starts. stdin is + closed after the string is written. + + Returns: + rc: The integer return code of the process. + output: A string containing the childs output. + """ + if not args: + args = [] + start = time.time() + int_sent = False + fd = subprocess.PIPE + + child = subprocess.Popen(["./manage.py", command] + args, stdin=fd, + stdout=fd, stderr=fd, cwd=os.getcwdu()) + if input: + child.stdin.write(input) + child.stdin.close() + + while 1: + rc = child.poll() + if rc is not None: + # Child has exited. + break + elapsed = time.time() - start + if int_after and int_after > 0 and elapsed > int_after and not int_sent: + # Sent SIGINT as requested, give child time to exit cleanly. + os.kill(child.pid, signal.SIGINT) + start = time.time() + int_sent = True + continue + if elapsed < self.COMMAND_TIMEOUT: + continue + # Command is over time, kill and exit loop. + os.kill(child.pid, signal.SIGKILL) + time.sleep(2) # Give time for the signal to be received. + break + + # Return status and output. + return rc, child.stdout.read(), child.stderr.read() + + def assertCommandSucceeds(self, command, *args, **kwargs): + """Asserts that the specified command successfully completes. + + Args: + command: The name of the command to run. + All other arguments are passed directly through to the runCommand + routine. + + Raises: + This function does not return anything but will raise assertion errors if + the command does not exit successfully. + """ + rc, stdout, stderr = self.runCommand(command, *args, **kwargs) + fd, tempname = tempfile.mkstemp() + os.write(fd, stdout) + os.close(fd) + self.assertEquals(0, rc, + "%s did not return successfully (rc: %d): Output in %s" % + (command, rc, tempname)) + os.unlink(tempname) + + def getCommands(self): + """Returns a list of valid commands for manage.py. + + Args: + None + + Returns: + A list of valid commands for manage.py as read from manage.py's help + output. + """ + rc, stdout, stderr = self.runCommand("help") + parts = re.split("Available subcommands:", stderr) + if len(parts) < 2: + return [] + + return [t.strip() for t in parts[-1].split("\n") if t.strip()] + + def testDiffSettings(self): + """Tests the diffsettings command.""" + self.assertCommandSucceeds("diffsettings") + + def testDumpData(self): + """Tests the dumpdata command.""" + self.assertCommandSucceeds("dumpdata") + + def testFlush(self): + """Tests the flush command.""" + self.assertCommandSucceeds("flush") + + def testLoadData(self): + """Tests the loaddata command.""" + self.assertCommandSucceeds("loaddata") + + def testLoadData(self): + """Tests the loaddata command.""" + self.assertCommandSucceeds("loaddata") + + def testReset(self): + """Tests the reste command.""" + self.assertCommandSucceeds("reset", ["appengine_django"]) + + def testRunserver(self): + """Tests the runserver command.""" + self.assertCommandSucceeds("runserver", int_after=2.0) + + def testShell(self): + """Tests the shell command.""" + self.assertCommandSucceeds("shell", input="exit") + + def testUpdate(self): + """Tests that the update command exists. + + Cannot test that it works without mocking out parts of dev_appserver so for + now we just assume that if it is present it will work. + """ + cmd_list = self.getCommands() + self.assert_("update" in cmd_list) + + def testZipCommandListFiltersCorrectly(self): + """When running under a zipfile test that only valid commands are found.""" + cmd_list = self.getCommands() + self.assert_("__init__" not in cmd_list) + self.assert_("base" not in cmd_list) diff --git a/appengine_django/tests/core_test.py b/appengine_django/tests/core_test.py new file mode 100755 index 0000000000000000000000000000000000000000..e2f51c839315ef666ecfcc956ad49513cca6c700 --- /dev/null +++ b/appengine_django/tests/core_test.py @@ -0,0 +1,47 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the core module functionality is present and functioning.""" + + +import unittest + +from django.test import TestCase as DjangoTestCase + +from appengine_django import appid +from appengine_django import have_appserver + + +class AppengineDjangoTest(unittest.TestCase): + """Tests that the helper module has been correctly installed.""" + + def testAppidProvided(self): + """Tests that application ID and configuration has been loaded.""" + self.assert_(appid is not None) + + def testAppserverDetection(self): + """Tests that the appserver detection flag is present and correct.""" + # It seems highly unlikely that these tests would ever be run from within + # an appserver. + self.assertEqual(have_appserver, False) + + +class DjangoTestCaseTest(DjangoTestCase): + """Tests that the tests can be subclassed from Django's TestCase class.""" + + def testPassing(self): + """Tests that tests with Django's TestCase class work.""" + self.assert_(True) diff --git a/appengine_django/tests/db_test.py b/appengine_django/tests/db_test.py new file mode 100755 index 0000000000000000000000000000000000000000..452e8f93411266528007339d19107fc41d8afb07 --- /dev/null +++ b/appengine_django/tests/db_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the db module correctly initialises the API stubs.""" + + +import unittest + +from django.db import connection +from django.db.backends.appengine.base import DatabaseWrapper + +from appengine_django import appid +from appengine_django.db import base + + +class DatastoreTest(unittest.TestCase): + """Tests that the datastore stubs have been correctly setup.""" + + def testDjangoDBConnection(self): + """Tests that the Django DB connection is using our replacement.""" + self.assert_(isinstance(connection, DatabaseWrapper)) + + def testDjangoDBConnectionStubs(self): + """Tests that members required by Django are stubbed.""" + self.assert_(hasattr(connection, "features")) + self.assert_(hasattr(connection, "ops")) + + def testDjangoDBErrorClasses(self): + """Tests that the error classes required by Django are stubbed.""" + self.assert_(hasattr(base, "DatabaseError")) + self.assert_(hasattr(base, "IntegrityError")) + + def testDatastorePath(self): + """Tests that the datastore path contains the app name.""" + d_path, h_path = base.get_datastore_paths() + self.assertNotEqual(-1, d_path.find("django_%s" % appid)) + self.assertNotEqual(-1, h_path.find("django_%s" % appid)) + + def testTestInMemoryDatastorePath(self): + """Tests that the test datastore is using the in-memory datastore.""" + td_path, th_path = base.get_test_datastore_paths() + self.assert_(td_path is None) + self.assert_(th_path is None) + + def testTestFilesystemDatastorePath(self): + """Tests that the test datastore is on the filesystem when requested.""" + td_path, th_path = base.get_test_datastore_paths(False) + self.assertNotEqual(-1, td_path.find("testdatastore")) + self.assertNotEqual(-1, th_path.find("testdatastore")) diff --git a/appengine_django/tests/memcache_test.py b/appengine_django/tests/memcache_test.py new file mode 100644 index 0000000000000000000000000000000000000000..4e5f02e28ce9eaf24fe48bae819d304807ba718e --- /dev/null +++ b/appengine_django/tests/memcache_test.py @@ -0,0 +1,43 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensures the App Engine memcache API works as Django's memcache backend.""" + +import unittest + +from django.core.cache import get_cache +from appengine_django import appid +from appengine_django import have_appserver + + +class AppengineMemcacheTest(unittest.TestCase): + """Tests that the memcache backend works.""" + + def setUp(self): + """Get the memcache cache module so it is available to tests.""" + self._cache = get_cache("memcached://") + + def testSimpleSetGet(self): + """Tests that a simple set/get operation through the cache works.""" + self._cache.set("test_key", "test_value") + self.assertEqual(self._cache.get("test_key"), "test_value") + + def testDelete(self): + """Tests that delete removes values from the cache.""" + self._cache.set("test_key", "test_value") + self.assertEqual(self._cache.has_key("test_key"), True) + self._cache.delete("test_key") + self.assertEqual(self._cache.has_key("test_key"), False) diff --git a/appengine_django/tests/model_test.py b/appengine_django/tests/model_test.py new file mode 100755 index 0000000000000000000000000000000000000000..8611d8b31112b4c80cc9ec70ca4248d7b61d40ad --- /dev/null +++ b/appengine_django/tests/model_test.py @@ -0,0 +1,110 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the combined appengine and Django models function correctly.""" + + +import unittest + +from django import VERSION +from django.db.models import get_models +from django import forms + +from google.appengine.ext.db import djangoforms +from google.appengine.ext import db + +from appengine_django.models import BaseModel +from appengine_django.models import ModelManager +from appengine_django.models import ModelOptions +from appengine_django.models import RegistrationTestModel + + +class TestModelWithProperties(BaseModel): + """Test model class for checking property -> Django field setup.""" + property1 = db.StringProperty() + property2 = db.IntegerProperty() + property3 = db.Reference() + + +class ModelTest(unittest.TestCase): + """Unit tests for the combined model class.""" + + def testModelRegisteredWithDjango(self): + """Tests that a combined model class has been registered with Django.""" + self.assert_(RegistrationTestModel in get_models()) + + def testDatastoreModelProperties(self): + """Tests that a combined model class still has datastore properties.""" + self.assertEqual(3, len(TestModelWithProperties.properties())) + + def testDjangoModelClass(self): + """Tests the parts of a model required by Django are correctly stubbed.""" + # Django requires model options to be found at ._meta. + self.assert_(isinstance(RegistrationTestModel._meta, ModelOptions)) + # Django requires a manager at .objects + self.assert_(isinstance(RegistrationTestModel.objects, ModelManager)) + # Django requires ._default_manager. + self.assert_(hasattr(RegistrationTestModel, "_default_manager")) + + def testDjangoModelFields(self): + """Tests that a combined model class has (faked) Django fields.""" + fields = TestModelWithProperties._meta.local_fields + self.assertEqual(3, len(fields)) + # Check each fake field has the minimal properties that Django needs. + for field in fields: + # The Django serialization code looks for rel to determine if the field + # is a relationship/reference to another model. + self.assert_(hasattr(field, "rel")) + # serialize is required to tell Django to serialize the field. + self.assertEqual(True, field.serialize) + if field.name == "property3": + # Extra checks for the Reference field. + # rel.field_name is used during serialization to find the field in the + # other model that this field is related to. This should always be + # 'key_name' for appengine models. + self.assertEqual("key_name", field.rel.field_name) + + def testDjangoModelOptionsStub(self): + """Tests that the options stub has the required properties by Django.""" + # Django requires object_name and app_label for serialization output. + self.assertEqual("RegistrationTestModel", + RegistrationTestModel._meta.object_name) + self.assertEqual("appengine_django", RegistrationTestModel._meta.app_label) + # The pk.name member is required during serialization for dealing with + # related fields. + self.assertEqual("key_name", RegistrationTestModel._meta.pk.name) + # The many_to_many method is called by Django in the serialization code to + # find m2m relationships. m2m is not supported by the datastore. + self.assertEqual([], RegistrationTestModel._meta.many_to_many) + + def testDjangoModelManagerStub(self): + """Tests that the manager stub acts as Django would expect.""" + # The serialization code calls model.objects.all() to retrieve all objects + # to serialize. + self.assertEqual([], list(RegistrationTestModel.objects.all())) + + def testDjangoModelPK(self): + """Tests that each model instance has a 'primary key' generated.""" + obj = RegistrationTestModel(key_name="test") + obj.put() + pk = obj._get_pk_val() + self.assert_(pk) + new_obj = RegistrationTestModel.get(pk) + self.assertEqual(obj.key(), new_obj.key()) + + def testModelFormPatched(self): + """Tests that the Django ModelForm is being successfully patched.""" + self.assertEqual(djangoforms.ModelForm, forms.ModelForm) diff --git a/appengine_django/tests/serialization_test.py b/appengine_django/tests/serialization_test.py new file mode 100755 index 0000000000000000000000000000000000000000..39b92ea195491d8f77e708b8d4d81be9c4e1e35b --- /dev/null +++ b/appengine_django/tests/serialization_test.py @@ -0,0 +1,310 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that the serialization modules are functioning correctly. + +In particular, these tests verify that the modifications made to the standard +Django serialization modules function correctly and that the combined datastore +and Django models can be dumped and loaded to all of the provided formats. +""" + + +import os +import re +import unittest +from StringIO import StringIO + +from django.core import serializers + +from google.appengine.ext import db +from appengine_django.models import BaseModel + + +class ModelA(BaseModel): + description = db.StringProperty() + + +class ModelB(BaseModel): + description = db.StringProperty() + friend = db.Reference(ModelA) + + +class TestAllFormats(type): + + def __new__(cls, name, bases, attrs): + """Extends base test functions to be called for every serialisation format. + + Looks for functions matching 'run.*Test', where the wildcard in the middle + matches the desired test name and ensures that a test case is setup to call + that function once for every defined serialisation format. The test case + that is created will be called 'test<format><name>'. Eg, for the function + 'runKeyedObjectTest' functions like 'testJsonKeyedObject' will be created. + """ + test_formats = serializers.get_serializer_formats() + test_formats.remove("python") # Python serializer is only used indirectly. + + for func_name in attrs.keys(): + m = re.match("^run(.*)Test$", func_name) + if not m: + continue + for format in test_formats: + test_name = "test%s%s" % (format.title(), m.group(1)) + test_func = eval("lambda self: getattr(self, \"%s\")(\"%s\")" % + (func_name, format)) + attrs[test_name] = test_func + + return super(TestAllFormats, cls).__new__(cls, name, bases, attrs) + + +class SerializationTest(unittest.TestCase): + """Unit tests for the serialization/deserialization functionality. + + Tests that every loaded serialization format can successfully dump and then + reload objects without the objects changing. + """ + __metaclass__ = TestAllFormats + + def compareObjects(self, orig, new, format="unknown"): + """Compares two objects to ensure they are identical. + + Args: + orig: The original object, must be an instance of db.Model. + new: The new object, must be an instance of db.Model. + format: The serialization format being tested, used to make error output + more helpful. + + Raises: + The function has no return value, but will raise assertion errors if the + objects do not match correctly. + """ + if orig.key().name(): + # Only compare object keys when the key is named. Key IDs are not static + # and will change between dump/load. If you want stable Keys they need to + # be named! + self.assertEqual(orig.key(), new.key(), + "keys not equal after %s serialization: %s != %s" % + (format, repr(orig.key()), repr(new.key()))) + + for key in orig.properties().keys(): + oval = getattr(orig, key) + nval = getattr(new, key) + if isinstance(orig.properties()[key], db.Reference): + # Need to compare object keys not the objects themselves. + oval = oval.key() + nval = nval.key() + self.assertEqual(oval, nval, "%s attribute differs after %s " + "serialization: %s != %s" % (key, format, oval, nval)) + + def doSerialisationTest(self, format, obj, rel_attr=None, obj_ref=None): + """Runs a serialization test on an object for the specified format. + + Args: + format: The name of the Django serialization class to use. + obj: The object to {,de}serialize, must be an instance of db.Model. + rel_attr: Name of the attribute of obj references another model. + obj_ref: The expected object reference, must be an instance of db.Model. + + Raises: + The function has no return value but raises assertion errors if the + object cannot be successfully serialized and then deserialized back to an + identical object. If rel_attr and obj_ref are specified the deserialized + object must also retain the references from the original object. + """ + serialised = serializers.serialize(format, [obj]) + # Try and get the object back from the serialized string. + result = list(serializers.deserialize(format, StringIO(serialised))) + self.assertEqual(1, len(result), + "%s serialization should create 1 object" % format) + result[0].save() # Must save back into the database to get a Key. + self.compareObjects(obj, result[0].object, format) + if rel_attr and obj_ref: + rel = getattr(result[0].object, rel_attr) + if callable(rel): + rel = rel() + self.compareObjects(rel, obj_ref, format) + + def doLookupDeserialisationReferenceTest(self, lookup_dict, format): + """Tests the Key reference is loaded OK for a format. + + Args: + lookup_dict: A dictionary indexed by format containing serialized strings + of the objects to load. + format: The format to extract from the dict and deserialize. + + Raises: + This function has no return value but raises assertion errors if the + string cannot be deserialized correctly or the resulting object does not + reference the object correctly. + """ + if format not in lookup_dict: + # Check not valid for this format. + return + obj = ModelA(description="test object", key_name="test") + obj.put() + s = lookup_dict[format] + result = list(serializers.deserialize(format, StringIO(s))) + self.assertEqual(1, len(result), "expected 1 object from %s" % format) + result[0].save() + self.compareObjects(obj, result[0].object.friend, format) + + def doModelKeyDeserialisationReferenceTest(self, lookup_dict, format): + """Tests a model with a key can be loaded OK for a format. + + Args: + lookup_dict: A dictionary indexed by format containing serialized strings + of the objects to load. + format: The format to extract from the dict and deserialize. + + Returns: + This function has no return value but raises assertion errors if the + string cannot be deserialized correctly or the resulting object is not an + instance of ModelA with a key named 'test'. + """ + if format not in lookup_dict: + # Check not valid for this format. + return + s = lookup_dict[format] + result = list(serializers.deserialize(format, StringIO(s))) + self.assertEqual(1, len(result), "expected 1 object from %s" % format) + result[0].save() + self.assert_(isinstance(result[0].object, ModelA)) + self.assertEqual("test", result[0].object.key().name()) + + # Lookup dicts for the above (doLookupDeserialisationReferenceTest) function. + SERIALIZED_WITH_KEY_AS_LIST = { + "json": """[{"pk": "agR0ZXN0chMLEgZNb2RlbEIiB21vZGVsYmkM", """ + """"model": "tests.modelb", "fields": {"description": "test", """ + """"friend": ["ModelA", "test"] }}]""", + "yaml": """- fields: {description: !!python/unicode 'test', friend: """ + """ [ModelA, test]}\n model: tests.modelb\n pk: """ + """ agR0ZXN0chMLEgZNb2RlbEEiB21vZGVsYWkM\n""" + } + SERIALIZED_WITH_KEY_REPR = { + "json": """[{"pk": "agR0ZXN0chMLEgZNb2RlbEIiB21vZGVsYmkM", """ + """"model": "tests.modelb", "fields": {"description": "test", """ + """"friend": "datastore_types.Key.from_path(""" + """'ModelA', 'test')" }}]""", + "yaml": """- fields: {description: !!python/unicode 'test', friend: """ + """\'datastore_types.Key.from_path("ModelA", "test")\'}\n """ + """model: tests.modelb\n pk: """ + """ agR0ZXN0chMLEgZNb2RlbEEiB21vZGVsYWkM\n""" + } + + # Lookup dict for the doModelKeyDeserialisationReferenceTest function. + MK_SERIALIZED_WITH_LIST = { + "json": """[{"pk": ["ModelA", "test"], "model": "tests.modela", """ + """"fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: [ModelA, test]\n""" + } + MK_SERIALIZED_WITH_KEY_REPR = { + "json": """[{"pk": "datastore_types.Key.from_path('ModelA', 'test')", """ + """"model": "tests.modela", "fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: \'datastore_types.Key.from_path("ModelA", "test")\'\n""" + } + MK_SERIALIZED_WITH_KEY_AS_TEXT = { + "json": """[{"pk": "test", "model": "tests.modela", "fields": {}}]""", + "yaml": """-\n fields: {description: null}\n model: tests.modela\n """ + """pk: test\n""" + } + + # Lookup dict for the function. + SERIALIZED_WITH_NON_EXISTANT_PARENT = { + "json": """[{"pk": "ahhnb29nbGUtYXBwLWVuZ2luZS1kamFuZ29yIgsSBk1vZG""" + """VsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw", """ + """"model": "tests.modela", "fields": """ + """{"description": null}}]""", + "yaml": """- fields: {description: null}\n """ + """model: tests.modela\n """ + """pk: ahhnb29nbGUtYXBwLWVuZ2luZS1kamFuZ29yIgsSBk1""" + """vZGVsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw\n""", + "xml": """<?xml version="1.0" encoding="utf-8"?>\n""" + """<django-objects version="1.0">\n""" + """<entity kind="tests.modela" key="ahhnb29nbGUtYXBwL""" + """WVuZ2luZS1kamFuZ29yIgsSBk1vZGVsQiIGcGFyZW50DA""" + """sSBk1vZGVsQSIEdGVzdAw">\n """ + """<key>tag:google-app-engine-django.gmail.com,""" + """2008-05-13:ModelA[ahhnb29nbGUtYXBwLWVuZ2luZS1kam""" + """FuZ29yIgsSBk1vZGVsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw""" + """]</key>\n <property name="description" """ + """type="null"></property>\n</entity>\n</django-objects>""" + } + + # The following functions are all expanded by the metaclass to be run once + # for every registered Django serialization module. + + def runKeyedObjectTest(self, format): + """Test serialization of a basic object with a named key.""" + obj = ModelA(description="test object", key_name="test") + obj.put() + self.doSerialisationTest(format, obj) + + def runObjectWithIdTest(self, format): + """Test serialization of a basic object with a numeric ID key.""" + obj = ModelA(description="test object") + obj.put() + self.doSerialisationTest(format, obj) + + def runObjectWithReferenceTest(self, format): + """Test serialization of an object that references another object.""" + obj = ModelA(description="test object", key_name="test") + obj.put() + obj2 = ModelB(description="friend object", friend=obj) + obj2.put() + self.doSerialisationTest(format, obj2, "friend", obj) + + def runObjectWithParentTest(self, format): + """Test serialization of an object that has a parent object reference.""" + obj = ModelA(description="parent object", key_name="parent") + obj.put() + obj2 = ModelA(description="child object", key_name="child", parent=obj) + obj2.put() + self.doSerialisationTest(format, obj2, "parent", obj) + + def runObjectWithNonExistantParentTest(self, format): + """Test deserialization of an object referencing a non-existant parent.""" + self.doModelKeyDeserialisationReferenceTest( + self.SERIALIZED_WITH_NON_EXISTANT_PARENT, format) + + def runCreateKeyReferenceFromListTest(self, format): + """Tests that a reference specified as a list in json/yaml can be loaded OK.""" + self.doLookupDeserialisationReferenceTest(self.SERIALIZED_WITH_KEY_AS_LIST, + format) + + def runCreateKeyReferenceFromReprTest(self, format): + """Tests that a reference specified as repr(Key) in can loaded OK.""" + self.doLookupDeserialisationReferenceTest(self.SERIALIZED_WITH_KEY_REPR, + format) + + def runCreateModelKeyFromListTest(self, format): + """Tests that a model key specified as a list can be loaded OK.""" + self.doModelKeyDeserialisationReferenceTest(self.MK_SERIALIZED_WITH_LIST, + format) + + def runCreateModelKeyFromReprTest(self, format): + """Tests that a model key specified as a repr(Key) can be loaded OK.""" + self.doModelKeyDeserialisationReferenceTest( + self.MK_SERIALIZED_WITH_KEY_REPR, format) + + def runCreateModelKeyFromTextTest(self, format): + """Tests that a reference specified as a plain key_name loads OK.""" + self.doModelKeyDeserialisationReferenceTest( + self.MK_SERIALIZED_WITH_KEY_AS_TEXT, format) + + +if __name__ == '__main__': + unittest.main() diff --git a/auth b/auth new file mode 160000 index 0000000000000000000000000000000000000000..373a8b40b793d0fe58f69ebe807f5f51f077a0db --- /dev/null +++ b/auth @@ -0,0 +1 @@ +Subproject commit 373a8b40b793d0fe58f69ebe807f5f51f077a0db diff --git a/helios b/helios new file mode 160000 index 0000000000000000000000000000000000000000..0f70fed4e89092595296d0c84cc1276a51194e3d --- /dev/null +++ b/helios @@ -0,0 +1 @@ +Subproject commit 0f70fed4e89092595296d0c84cc1276a51194e3d diff --git a/iacr/__init__.py b/iacr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f5946f83bd3fd5d7836484fd751730f0b8c378fb --- /dev/null +++ b/iacr/__init__.py @@ -0,0 +1,9 @@ +""" +This django app is meant only to connect the pieces of Helios and Auth that are specific to Votwee +""" + +import glue + +import helios + +helios.TEMPLATE_BASE = "votwee/templates/base.html" \ No newline at end of file diff --git a/iacr/glue.py b/iacr/glue.py new file mode 100644 index 0000000000000000000000000000000000000000..89cc38ba052cf7cd82a8616c47baf3767c70cd7d --- /dev/null +++ b/iacr/glue.py @@ -0,0 +1,19 @@ +""" +Glue some events together +""" + +from django.conf import settings +from django.core.urlresolvers import reverse +import helios.views, helios.signals + +import views + +def vote_cast_update_status(user, election, cast_vote, **kwargs): + pass + +helios.signals.vote_cast.connect(vote_cast_update_status) + +def election_tallied(election, **kwargs): + pass + +helios.signals.election_tallied.connect(election_tallied) \ No newline at end of file diff --git a/iacr/media/main.css b/iacr/media/main.css new file mode 100644 index 0000000000000000000000000000000000000000..5dc67e9f183b8406f92965d4d8745b7163ba7f4d --- /dev/null +++ b/iacr/media/main.css @@ -0,0 +1,69 @@ + +body { + font-family: 'Lucida Grande',sans-serif; + background: white; + padding: 0px; + margin: 0px; +} + +#content { + position: absolute; + padding: 20px 30px 20px 30px; + top: 0px; + margin-left: 100px; + margin-top: 0px; + width: 860px; + background: #eee; + border-left: 1px solid #666; + border-right: 1px solid #666; + border-bottom: 1px solid #666; +} + +#header { + padding-top: 0px; + text-align: center; + padding-bottom: 20px; +} + +#footer { + border-top: 1px solid #666; + bottom: 0px; + margin: auto; + width: 860px; + text-align: center; + color: #666; + clear: both; +} + +#footer a, #footer a:visited { + color: black; + text-decoration: none; +} + +#footer a:hover { + text-decoration: underline; +} + +#page h2 { + background: #fc9; + border-bottom: 1px solid #666; + padding: 5px 0px 2px 5px; +} + +#election_info { + font-size: 16pt; +} + +table.pretty { + margin: 1em 1em 1em 2em; + background: whitesmoke; + border-collapse: collapse; + width: 80%; +} + +table.pretty th, td { + border: 1px silver solid; + padding: 0.3em; + vertical-align: top; +} + diff --git a/iacr/templates/base.html b/iacr/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..023ad29ef6e9a7038060e94b033b0b07e17d9385 --- /dev/null +++ b/iacr/templates/base.html @@ -0,0 +1,48 @@ +<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> + +<html xmlns="http://www.w3.org/1999/xhtml" + dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}" + xml:lang="{% firstof LANGUAGE_CODE 'en' %}" + lang="{% firstof LANGUAGE_CODE 'en' %}"> + <head> + <title>{% block title %}{% endblock %} - IACR Helios</title> + {% block css %} + <!-- + <link rel="stylesheet" type="text/css" media="screen, projection" href="{{ MEDIA_URL }}combined-{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}.css" /> + --> + <link rel="stylesheet" type="text/css" media="screen" href="/static/main.css"> + + <!--[if IE]> + <link rel="stylesheet" type="text/css" media="screen, projection" href="{{ MEDIA_URL }}ie.css"> + <![endif]--> + {% endblock %} + + <script language="javascript" src="/static/helios/helios/jquery-1.2.2.min.js"></script> + + {% block js %} + {% endblock %} + + {% block extra-head %}{% endblock %} + </head> + + <body> + <div id="content"> + <div id="header"> + {% block header %} +{% if user %} +logged in as <b>{{user.name}} ({{user.user_type}})</b> +[<a href="{% url auth.views.logout %}?return_url={{CURRENT_URL}}">logout</a>] +{% else %} +not logged in. [<a href="{% url auth.views.index %}?return_url={{CURRENT_URL}}">login</a>] +{% endif %} + {% endblock %} + </div> + {% block content %}{% endblock %} + <div id="footer"> + </div> + </div> + + </body> +</html> diff --git a/iacr/templates/index.html b/iacr/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..9c747336404d692f4ef201eebb5f90bd377c0791 --- /dev/null +++ b/iacr/templates/index.html @@ -0,0 +1,32 @@ +{% extends 'iacr/templates/base.html' %} +{% block title %}IACR Helios{% endblock %} + +{% block content %} +<h1>Welcome to IACR Helios</h1> + +{% if user %} +<p style="font-size: 1.4em;"> +logged in as <b>{{user.name}}</b>. +</p> + +<p> + {{elections|length}} elections administered: +{% for e in elections %} +<a href="{% url helios.views.one_election_view e.uuid %}">{{e.name}}</a> +{% endfor %} +</p> + +<p> + {{elections_registered|length}} elections for which you're registered: +{% for e in elections_registered %} +<a href="{% url helios.views.one_election_view e.uuid %}">{{e.name}}</a> +{% endfor %} +</p> + +<p> + <a href="{% url helios.views.election_new %}">create an election</a> +</p> +{% else %} +<a href="{% url auth.views.index %}">log in</a> +{% endif %} +{% endblock %} diff --git a/iacr/urls.py b/iacr/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..b4350418f2fddddd1b6d647a5051c0ae0b44cfe7 --- /dev/null +++ b/iacr/urls.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from django.conf.urls.defaults import * + +from views import * + +urlpatterns = patterns('', + (r'^$', home), + (r'^about$', about) +) diff --git a/iacr/view_utils.py b/iacr/view_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e06337af54fe398a70ef04237d642eee399936a5 --- /dev/null +++ b/iacr/view_utils.py @@ -0,0 +1,23 @@ +""" +Utilities for iacr views + +Ben Adida (2009-07-18) +""" + +from django.template import Context, Template, loader +from django.http import HttpResponse, Http404 +from django.shortcuts import render_to_response + +from auth.security import get_user + +## +## template abstraction +## +def render_template(request, template_name, vars = {}): + t = loader.get_template(template_name + '.html') + + vars_with_user = vars.copy() + vars_with_user['user'] = get_user(request) + + return render_to_response('iacr/templates/%s.html' % template_name, vars_with_user) + diff --git a/iacr/views.py b/iacr/views.py new file mode 100644 index 0000000000000000000000000000000000000000..8479e4d3bbec7c578acc6e7edd4b3bbaefd101a5 --- /dev/null +++ b/iacr/views.py @@ -0,0 +1,30 @@ +""" +Votwee specific views +""" + +from helios.models import * +from auth.security import * +from view_utils import * + +import helios.views + +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponseNotAllowed + +def home(request): + user = get_user(request) + if user: + elections = Election.get_by_user_as_admin(user) + elections_registered = Election.get_by_user_as_voter(user) + else: + elections = [] + elections_registered = [] + + return render_template(request, "index", {'elections' : elections, 'elections_registered' : elections_registered}) + +def about(request): + return HttpResponse(request, "about") + +def election_shortcut(request, election_short_name): + election = Election.get_by_short_name(election_short_name) + return HttpResponseRedirect(reverse(helios.views.one_election_view, args=[election.uuid])) \ No newline at end of file diff --git a/settings.py b/settings.py index cd44a9e8521799ee659e6f23717bf5530e817eba..d70442d9f3ff0829e37000e0b8de9d6c86a7f324 100644 --- a/settings.py +++ b/settings.py @@ -117,7 +117,7 @@ INSTALLED_APPS = ( 'appengine_django', 'auth', 'helios', - 'votwee', + 'iacr', ) @@ -125,4 +125,4 @@ APPEND_SLASH = False DEBUG = True TEMPLATE_DEBUG = True -URL_HOST = "http://votwee.com" \ No newline at end of file +URL_HOST = "https://iacr-helios.appspot.com" \ No newline at end of file diff --git a/urls.py b/urls.py index 62387888e41d424b33c4efbff57ab05eb0a14bb4..564c2f8f310f3e2d5ff3f48952be9adf04a91bb0 100644 --- a/urls.py +++ b/urls.py @@ -5,9 +5,5 @@ from django.contrib import admin urlpatterns = patterns('', (r'^auth/', include('auth.urls')), (r'^helios/', include('helios.urls')), - (r'^', include('votwee.urls')), - - # static hack -- production should bypass this route - #(r'^static/helios/(?P<path>.*)$', 'django.views.static.serve', - # {'document_root': '/web/votwee/helios/media/helios/'}), + (r'^', include('iacr.urls')), ) \ No newline at end of file