diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README b/README new file mode 100644 index 0000000..bea0a22 --- /dev/null +++ b/README @@ -0,0 +1,104 @@ + +This is the code for CHIRP's internal web applications. The apps are +hosted on Google's App Engine, under the 'chirpradio' project. + +For the chirpradio developer dashboard, go to: +http://appengine.google.com/dashboard?&app_id=chirpradio + +For the end-user landing page, go to: +http://chirpradio.appspot.com/ + +Helpful documentation: + +* App Engine Python API + http://code.google.com/appengine/docs/python/ + +* Django 1.0: + http://www.djangobook.com/en/2.0/ + Be sure you are looking at the right version of the book! We are using + version 1.0. + + +CODING CONVENTIONS +================== + +Please follow the conventions outlined in PEP 8. + + +OVERVIEW OF THE TREE +==================== + +There are part of the common infrastructure. + djzango.zip + Django 1.0.2-final, zipped up. We never want to change this. + appengine_django/ + From google-app-engine-django, AppEngine helper & glue code. + common/ + Code & data shared by all apps. + django-extras/ + A tree that is merged into the django namespace. We put our own + glue code here. This should be kept small and simple. + __init__.py + main.py + manage.py + Launchers for Django. + settings.py + Global configuration for Django. + urls.py + Main URL file. + auth/ + Our own custom authentication & account management system. + media/ext_js/[package name] + External third-party Javascript packages (like JQuery) live under this + directory. + +These are places where all applications store data. + media/[application name]/{js, css, img}/ + templates/[application name]/ + +These are applications that are running in production. + (None so far) + +These are applications that are under development. + landing_page/ + Where you end up when you go to "/". Currently a test page. + volunteers/ + Volunteer tracking. + + +ADDING A NEW APPLICATION +======================== + +Every application has a name that looks like this: "landing_page". +Your code lives in a directory with the same name. +Your templates go under the directory templates/[application name]. +Your media files go under the directory media/[application name]. + +All of your URLs are automatically mapped to be under +http://host/volunteers/my/url + +To make your URLs visible, you need to: +(1) Update the top-level urls.py to include your urls. +(2) Add your application to INSTALLED_APPS in settings.py. + + +THIRD-PARTY CODE +================ + +Some of the files in this directory and all of files under the +appengine_django/ subdirectory are based on rev 81 of the +google-app-engine-django Subversion repository. + +All files in django.zip are taken from Django 1.0.2-final. It was +constructed by running the following commands: + +zip -r django.zip django/__init__.py django/bin django/core \ + django/db django/dispatch django/forms \ + django/http django/middleware django/shortcuts \ + django/template django/templatetags \ + django/test django/utils django/views + +zip -r django.zip django/conf -x 'django/conf/locale/*' + +These commands were taken from +http://code.google.com/appengine/articles/django10_zipimport.html \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.yaml b/app.yaml new file mode 100644 index 0000000..a68d254 --- /dev/null +++ b/app.yaml @@ -0,0 +1,16 @@ +application: chirpradio +version: 1 +runtime: python +api_version: 1 + +handlers: +- url: /static + static_dir: static + +# Always use HTTPS for pages related to authentication & user management +- url: /auth/.* + secure: always + script: main.py + +- url: /.* + script: main.py diff --git a/appengine_django/__init__.py b/appengine_django/__init__.py new file mode 100644 index 0000000..fce03b9 --- /dev/null +++ b/appengine_django/__init__.py @@ -0,0 +1,537 @@ +#!/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 + +# 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. + """ + 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 0000000..d2db207 --- /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 0000000..d897c24 --- /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 0000000..a727e47 --- /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 0000000..d93e240 --- /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 0000000..8237890 --- /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 0000000..20aecfa --- /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 0000000..e69de29 diff --git a/appengine_django/conf/app_template/models.py b/appengine_django/conf/app_template/models.py new file mode 100644 index 0000000..4d7c5d0 --- /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 0000000..60f00ef --- /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 0000000..619bc78 --- /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 0000000..8a90182 --- /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 0000000..74e3048 --- /dev/null +++ b/appengine_django/db/creation.py @@ -0,0 +1,37 @@ +#!/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.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""" + 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 0000000..d647dd9 --- /dev/null +++ b/appengine_django/mail.py @@ -0,0 +1,93 @@ +#!/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.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 0000000..e69de29 diff --git a/appengine_django/management/commands/__init__.py b/appengine_django/management/commands/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/appengine_django/management/commands/console.py b/appengine_django/management/commands/console.py new file mode 100755 index 0000000..2c40697 --- /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 0000000..c5f3f8c --- /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 0000000..126f386 --- /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 0000000..6ce9e4e --- /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 0000000..7b91f65 --- /dev/null +++ b/appengine_django/management/commands/runserver.py @@ -0,0 +1,77 @@ +#!/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]) + # 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 0000000..2648cbd --- /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 0000000..bd2c6d1 --- /dev/null +++ b/appengine_django/management/commands/testserver.py @@ -0,0 +1,71 @@ +#!/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]) + + # 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 0000000..e489d5d --- /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 0000000..ab276b4 --- /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 0000000..82e43cc --- /dev/null +++ b/appengine_django/models.py @@ -0,0 +1,176 @@ +#!/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): + # Returns a string representation that can be used to construct an + # equivalent object. First, creates a dictionary of property names and + # values. Note that property values, not property objects, has to be passed + # in to constructor. + d = dict([(k, self.__getattribute__(k)) for k 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 0000000..330aaf0 --- /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 0000000..e69de29 diff --git a/appengine_django/serializer/python.py b/appengine_django/serializer/python.py new file mode 100755 index 0000000..bce16e7 --- /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 0000000..f67588a --- /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"""\n""" + str += u"""\n""" + str += u"".join(self._objects) + str += u"""""" + 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 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(" 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 0000000..e69de29 diff --git a/appengine_django/sessions/backends/__init__.py b/appengine_django/sessions/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/appengine_django/sessions/backends/db.py b/appengine_django/sessions/backends/db.py new file mode 100644 index 0000000..e2e3aa4 --- /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 0000000..1681644 --- /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 0000000..b511f58 --- /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 0000000..a02ddbf --- /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 0000000..7454177 --- /dev/null +++ b/appengine_django/tests/core_test.py @@ -0,0 +1,37 @@ +#!/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 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) diff --git a/appengine_django/tests/db_test.py b/appengine_django/tests/db_test.py new file mode 100755 index 0000000..452e8f9 --- /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 0000000..4e5f02e --- /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 0000000..8611d8b --- /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 0000000..8642d92 --- /dev/null +++ b/appengine_django/tests/serialization_test.py @@ -0,0 +1,313 @@ +#!/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'. 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": """\n""" + """\n""" + """\n """ + """tag:google-app-engine-django.gmail.com,""" + """2008-05-13:ModelA[ahhnb29nbGUtYXBwLWVuZ2luZS1kam""" + """FuZ29yIgsSBk1vZGVsQiIGcGFyZW50DAsSBk1vZGVsQSIEdGVzdAw""" + """]\n \n\n""" + } + + # 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.""" + # NOTE(trow): Test disabled, as it fails when the app name has + # been changed. The pre-serialized version has + # google-app-engine-django hard-wired in as the app. + #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/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..5100a87 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,200 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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. +### + +"""CHIRP authentication system.""" + +import base64 +import logging +import os +import time +from Crypto.Cipher import AES +from Crypto.Hash import HMAC +from django import http +from auth.models import User +from auth import roles + +# Our logout URL. +LOGOUT_URL = "/auth/goodbye/" + +# Users are ultimately redirected to the URL after logging out. +_FINAL_LOGOUT_URL = '/auth/hello/' + +# The name of the cookie used to store our security token. +_CHIRP_SECURITY_TOKEN_COOKIE = 'chirp_security_token' + +# Our security tokens expire after two hours. +_TOKEN_TIMEOUT_S = 2 * 60 * 60 + +# Key used to HMAC-sign security tokens. +# To simplify deployment, this should probably be stashed in the datastore. +_SECRET_HMAC_KEY = 'testkey' + +# Key used to AES-encrypt security tokens. +# To simplify deployment, this should probably be stashed in the datastore. +_SECRET_AES_KEY = 'testkey8901234567890123456789012' + + +class UserNotAllowedError(Exception): + """Raised when the user is recognized but forbidden from entering.""" + + +class _Credentials(object): + email = None + security_token_is_stale = False + + +def _create_security_token(user): + """Create a CHIRP security token. + + Args: + user: A User object. + + Returns: + A string containing an encrypted security token that encodes + the user's email address as well as a timestamp. + """ + timestamp = int(time.time()) + plaintext = "%x %s" % (timestamp, user.email) + nearest_mult_of_16 = 16 * ((len(plaintext) + 15) // 16) + # Pad plaintest with whitespace to make the length a multiple of 16, + # as this is a requirement of AES encryption. + plaintext = plaintext.rjust(nearest_mult_of_16, ' ') + body = AES.new(_SECRET_AES_KEY, AES.MODE_CBC).encrypt(plaintext) + sig = HMAC.HMAC(key=_SECRET_HMAC_KEY, msg=body).hexdigest() + return '%s:%s' % (sig, body) + +def _parse_security_token(token): + """Parse a CHIRP security token. + + Returns: + A Credentials object, or None if the token is not valid. + If a Credentials object is returned, its "user" field will not + be set. + """ + if not token: + return None + if ':' not in token: + logging.warn('Malformed token: no signature separator') + return None + sig, body = token.split(':', 1) + computed_sig = HMAC.HMAC(key=_SECRET_HMAC_KEY, msg=body).hexdigest() + if sig != computed_sig: + logging.warn('Malformed token: invalid signature') + return None + try: + plaintext = AES.new(_SECRET_AES_KEY, AES.MODE_CBC).decrypt(body) + except ValueError: + logging.warn('Malformed token: wrong size') + return None + # Remove excess whitespace. + plaintext = plaintext.strip() + # The plaintext should contain at least one space. + if ' ' not in plaintext: + logging.warn('Malformed token: bad contents') + return None + parts = plaintext.split(' ') + if len(parts) != 2: + logging.warn('Malformed token: bad structure') + return None + timestamp, email = parts + try: + timestamp = int(timestamp, 16) + except ValueError: + logging.warn('Malformed token: bad timestamp') + return None + # Reject tokens that are too old or which have time-traveled. We + # allow for 1s of clock skew. + age_s = time.time() - timestamp + if age_s < -1 or age_s > _TOKEN_TIMEOUT_S: + logging.warn('Malformed token: expired (age=%ds)', age_s) + return None + cred = _Credentials() + cred.email = email + cred.security_token_is_stale = (age_s > 0.5 * _TOKEN_TIMEOUT_S) + return cred + + +def attach_credentials(response, user): + """Attach a user's credentials to a response. + + Args: + response: An HttpResponse object. + user: A User object. + """ + response.set_cookie(_CHIRP_SECURITY_TOKEN_COOKIE, + _create_security_token(user)) + + +def get_current_user(request): + """Get the current logged-in user's. + + Returns: + A User object, or None if the user is not logged in. + + Raises: + UserNotAllowedError if the user is prohibited from accessing + the site. + """ + cred = None + token = request.COOKIES.get(_CHIRP_SECURITY_TOKEN_COOKIE) + if token: + cred = _parse_security_token(token) + # No valid token? This is hopeless! + if cred is None: + return None + # Try to find a user for this email address. + user = User.get_by_email(cred.email) + if user is None: + return None + # Reject inactive users. + if not user.is_active: + logging.info('Rejected inactive user %s', user.email) + raise UserNotAllowedError + user._credentials = cred + return user + + +def create_login_url(path): + """Returns the URL of a login page that redirects to 'path' on success.""" + return "/auth/hello?redirect=%s" % path + + +def logout(): + """Create an HTTP response that will log a user out. + + Returns: + An HttpResponse object that will log the user out. + """ + # If the user was signed in and has a cookie, clear it. + response = http.HttpResponseRedirect(_FINAL_LOGOUT_URL) + response.set_cookie(_CHIRP_SECURITY_TOKEN_COOKIE, '') + return response + + +def get_password_reset_token(user): + """A URL-safe token that authenticates a user for a password reset.""" + return base64.urlsafe_b64encode(_create_security_token(user)) + + +def parse_passord_reset_token(token): + """Extracts an email address from a valid password reset token.""" + try: + token = base64.urlsafe_b64decode(str(token)) + except TypeError: + return None + cred = _parse_security_token(token) + return cred and cred.email diff --git a/auth/decorators.py b/auth/decorators.py new file mode 100644 index 0000000..848f4af --- /dev/null +++ b/auth/decorators.py @@ -0,0 +1,65 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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 CHIRP's authentication system.""" + +from django import http +import auth +from auth import models + + +class require_role(object): + """A decorator that limits access only to users with a particular role. + + If 'role' is None, this check is pass for any signed-in user. + """ + def __init__(self, role): + self._role = role + + def __call__(self, func): + def wrapper(request, *args, **kwargs): + # Not signed in? Redirect to a login page. + if not request.user: + return http.HttpResponseRedirect( + auth.create_login_url(request.path)) + # If the user is signed in and has the required role, + # satisfy the request. + if (request.user and + (self._role is None + or self._role in request.user.roles + or request.user.is_superuser)): + return func(request, *args, **kwargs) + # Othewise return a 403. + return http.HttpResponseForbidden( + 'Page requires role "%s"' % self._role) + return wrapper + + +def require_signed_in_user(func): + """A decorator that limits access to signed-in users. + + Note that limiting acces to signed-in users is the default + behavior for all pages outside of the /auth/ namespace. + """ + def wrapper(request, *args, **kwargs): + if getattr(request, 'user') is None: + # If the user is not signed in, send them off to somewhere + # they can log in. + login_url = auth.create_login_url(request.path) + return http.HttpResponseRedirect(login_url) + return func(request, *args, **kwargs) + return wrapper diff --git a/auth/forms.py b/auth/forms.py new file mode 100644 index 0000000..8057bcc --- /dev/null +++ b/auth/forms.py @@ -0,0 +1,151 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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 import forms +from django.forms import HiddenInput, PasswordInput +from auth.models import User +from auth.models import roles + + +class LoginForm(forms.Form): + redirect = forms.CharField(widget=HiddenInput, required=True) + email = forms.EmailField(required=True) + password = forms.CharField(required=True, widget=PasswordInput) + + def clean_password(self): + self.user = User.get_by_email(self.cleaned_data['email']) + if self.user is None: + raise forms.ValidationError('Unknown email address') + if not self.user.is_active: + raise forms.ValidationError('Account not active') + if not self.user.password: + raise forms.ValidationError('Password not set') + if not self.user.check_password(self.cleaned_data['password']): + raise forms.ValidationError('Incorrect password') + return self.cleaned_data['password'] + + +class ChangePasswordForm(forms.Form): + current_password = forms.CharField(required=True, widget=PasswordInput) + new_password = forms.CharField(required=True, widget=PasswordInput) + confirm_new_password = forms.CharField(required=True, widget=PasswordInput) + + def set_user(self, user): + self._user = user + + def clean_current_password(self): + pw = self.cleaned_data['current_password'] + if not self._user.check_password(pw): + raise forms.ValidationError('Incorrect password') + return pw + + def clean_confirm_new_password(self): + pw = self.cleaned_data['confirm_new_password'] + if pw != self.cleaned_data['new_password']: + raise forms.ValidationError('Passwords are not the same') + return pw + + +class ForgotPasswordForm(forms.Form): + email = forms.EmailField(required=True) + user = None + + def clean_email(self): + self.user = User.get_by_email(self.cleaned_data['email']) + if self.user is None: + raise forms.ValidationError('Unknown email address') + return self.cleaned_data['email'] + + +class ResetPasswordForm(forms.Form): + token = forms.CharField(required=True, widget=HiddenInput) + new_password = forms.CharField(required=True, widget=PasswordInput) + confirm_new_password = forms.CharField(required=True, widget=PasswordInput) + + def clean_confirm_new_password(self): + pw = self.cleaned_data['confirm_new_password'] + if pw != self.cleaned_data['new_password']: + raise forms.ValidationError('Passwords are not the same') + return pw + + +class UserForm(forms.Form): + original_email = forms.EmailField(widget=HiddenInput, required=False) + email = forms.EmailField() + first_name = forms.CharField() + last_name = forms.CharField() + password = forms.CharField(required=False) + is_active = forms.BooleanField(initial=True, required=False) + # We also plug in synthetic fields for all of our various roles. + + def clean_email(self): + email = self.cleaned_data['email'] + if (email != self.cleaned_data.get('original_email') + and User.get_by_email(email) is not None): + raise forms.ValidationError('Email address already in use') + return email + + def clean_first_name(self): + # Remove leading and trailing whitespace. + return self.cleaned_data['first_name'].strip() + + def clean_last_name(self): + # Remove leading and trailing whitespace.p + return self.cleaned_data['last_name'].strip() + + @classmethod + def from_user(cls, user): + initial = { + 'original_email': user.email, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'is_active': user.is_active, + } + for r in user.roles: + initial['is_' + r] = True + return cls(initial=initial) + + def to_user(self): + data = self.cleaned_data.copy() + + user_roles = [] + for r in roles.ALL_ROLES: + key = 'is_' + r + if key in data: + if data[key]: + user_roles.append(r) + del data[key] + data['roles'] = user_roles + + if data['original_email']: + user = User.get_by_email(data['original_email']) + for key, val in data.items(): + if key == 'password': + user.set_password(val) + elif key != 'original_email': + setattr(user, key, val) + else: + del data['original_email'] + user = User(**data) + return user + + +# Plug in form fields for all of our roles. +for r in roles.ALL_ROLES: + UserForm.base_fields['is_' + r] = forms.BooleanField(initial=False, + required=False) diff --git a/auth/middleware.py b/auth/middleware.py new file mode 100644 index 0000000..418580b --- /dev/null +++ b/auth/middleware.py @@ -0,0 +1,51 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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. +### + +"""Middleware for CHIRP's authentication system.""" + +from django import http +import auth + + +class AuthenticationMiddleware(object): + + def process_request(self, request): + try: + user = auth.get_current_user(request) + except auth.UserNotAllowedError: + return http.HttpResponseForbidden('Access Denied!') + # Un-logged-in users are not redirected away from the /auth/ + # namespace. This ensures that the log-in and related pages + # are reachable. + if user is None and not request.path.startswith('/auth/'): + login_url = auth.create_login_url(request.path) + return http.HttpResponseRedirect(login_url) + # Attach the user to the request. + request.user = user + return None + + def process_response(self, request, response): + # If the "_logout" flag is set on the response, generate a response + # that will log the user out. + if getattr(response, '_logout', False): + return auth.logout() + # If our security token is old, issue a new one. + if hasattr(request, 'user'): + cred = getattr(request.user, '_credentials', None) + if cred and cred.security_token_is_stale: + auth.attach_credentials(response, request.user) + return response diff --git a/auth/models.py b/auth/models.py new file mode 100644 index 0000000..de80e16 --- /dev/null +++ b/auth/models.py @@ -0,0 +1,113 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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 model for CHIRP applications.""" + +import hashlib +import time +from google.appengine.ext import db +from auth import roles + + +class User(db.Model): + """CHIRP radio's canonical user class. + + This object is designed to be contrib.auth-like but not + necessarily compatible. We only keep basic information about the + user here; essentially only things we need for "sysadmin-y" tasks. + More detailed information should go in the volunteer tracker or + other + """ + email = db.EmailProperty(required=True) + first_name = db.StringProperty() + last_name = db.StringProperty() + + # This is the SHA1 hash of the user's password. + password = db.StringProperty() + # We omit Django's is_staff property. + is_active = db.BooleanProperty(default=True, required=True) + # Superusers are given unfettered access to the site, and are + # considered to be in every role. + 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) + + # We omit Django's groups property, and replace it with 'roles'. + # A role is just a constant string identifier. For a list of + # the possible roles, see the auth.roles module. + # + # Properties for checking if a user has a particular role are + # automatically patched into the User class. The following two + # expressions are equivalent: + # roles.ROLE_NAME in user.roles + # user.is_role_name + # + # TODO(trow): Add validation that all roles are valid. + roles = db.StringListProperty() + + + def __unicode__(self): + name_parts = [] + if self.first_name: + name_parts.append(self.first_name) + if self.last_name: + name_parts.append(self.last_name) + if not name_parts: + name_parts.append(self.email) + return u' '.join(name_parts) + + def __str__(self): + return unicode(self).encode('utf-8') + + @classmethod + def _hash_password(cls, plaintext): + return hashlib.sha1(plaintext).hexdigest() + + def set_password(self, plaintext): + """Store the SHA1 hash in the password property.""" + salt = '%04x' % int(0xffff * (time.time() % 1)) + self.password = salt + User._hash_password(salt + plaintext) + + def check_password(self, plaintext): + if not self.password or len(self.password) < 4: + return False + salt = self.password[:4] + hashed = self.password[4:] + return hashed == User._hash_password(salt + plaintext) + + @classmethod + def get_by_email(cls, email): + query = db.Query(cls) + query.filter('email =', email) + if query.count() == 0: + return None + elif query.count() == 1: + return query.get() + else: + raise LookupError('User email collision for %s' % email) + + +# Patch the User class to provide properties for checking roles. +# These are useful in templates. +for role in roles.ALL_ROLES: + property_name = 'is_' + role.lower() + assert not hasattr(User, property_name) + setattr(User, property_name, + property(lambda self: self.is_superuser or role in self.roles)) + + + diff --git a/auth/roles.py b/auth/roles.py new file mode 100644 index 0000000..d3bf489 --- /dev/null +++ b/auth/roles.py @@ -0,0 +1,31 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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. +### + +"""Constants and definitions related to roles. + +We use these roles to define a very simple set of permissions. +""" + +# A hard-wired list of all possible roles. Each role is specified by +# a more-or-less human-readable string. +VOLUNTEER_COORDINATOR = 'volunteer_coordinator' + + +# A tuple containing all possible roles. +ALL_ROLES = ( + VOLUNTEER_COORDINATOR, +) diff --git a/auth/tests.py b/auth/tests.py new file mode 100644 index 0000000..e2213e4 --- /dev/null +++ b/auth/tests.py @@ -0,0 +1,335 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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 time +import unittest + +from django import http +from django.test.client import Client +from google.appengine.api import users as google_users + +import auth +from auth import forms as auth_forms +from auth import roles +from auth.models import User + + +# These are needed by the App Engine local user service stub. +# (google_appengine/google/appengine/api/user_service_stub.py) +os.environ['SERVER_NAME'] = 'localhost' +os.environ['SERVER_PORT'] = '1234' +# We need this in order to instantiate a google_users.User object. +os.environ['USER_EMAIL'] = 'test@test.com' + + +class AuthTestCase(unittest.TestCase): + + def setUp(self): + # Remember the original versions of these functions. + self._orig_get_current_user = google_users.get_current_user + self._orig_is_current_user_admin = google_users.is_current_user_admin + self._orig_time = time.time + # Now replace them with mocks. + self.g_user = None + self.is_superuser = False + self.now = 12345 + google_users.get_current_user = lambda: self.g_user + google_users.is_current_user_admin = lambda: self.is_superuser + time.time = lambda: self.now + + def tearDown(self): + # Restore the google_users APIs we mocked out. + google_users.get_current_user = self._orig_get_current_user + google_users.is_current_user_admin = self._orig_is_current_user_admin + time.time = self._orig_time + + def test_user_model_role_properties(self): + user = User(email='test') + self.assertFalse(user.is_volunteer_coordinator) + user.roles.append(roles.VOLUNTEER_COORDINATOR) + self.assertTrue(user.is_volunteer_coordinator) + + def test_user_password_checks(self): + user = User(email='test_pw_checks@test.com') + self.assertFalse(user.check_password('foo')) + user.set_password('foo') + self.assertTrue(user.check_password('foo')) + self.assertFalse(user.check_password('bar')) + + def test_security_token_create_and_parse(self): + # Set up a test user. + email = 'token_test@test.com' + user = User(email=email) + token = auth._create_security_token(user) + # A new token should work fine and not be stale. + cred = auth._parse_security_token(token) + self.assertEqual(email, cred.email) + self.assertFalse(cred.security_token_is_stale) + # Don't accept time-traveling tokens. + self.now -= 60 + self.assertEqual(None, auth._parse_security_token(token)) + # This token is still valid, but is stale. + self.now += 0.75 * auth._TOKEN_TIMEOUT_S + cred = auth._parse_security_token(token) + self.assertEqual(email, cred.email) + self.assertTrue(cred.security_token_is_stale) + # Now the token has expired. + self.now += 0.75 * auth._TOKEN_TIMEOUT_S + self.assertEqual(None, auth._parse_security_token(token)) + # Reject random garbage. + for garbage in (None, '', 'garbage'): + self.assertEqual(None, auth._parse_security_token(garbage)) + + def test_password_reset_token_create_and_parse(self): + email = 'password_reset_token@test.com' + user = User(email=email) + token = auth.get_password_reset_token(user) + observed_email = auth.parse_passord_reset_token(token) + self.assertEqual(email, observed_email) + + def test_attach_credentials(self): + # Set up a test user. + email = 'attach_test@test.com' + user = User(email=email) + # Attach the user's credentials to a test response. + response = http.HttpResponse('test') + auth.attach_credentials(response, user) + # Make sure the response now contains a cookie with the correct + # security token. + self.assertTrue(auth._CHIRP_SECURITY_TOKEN_COOKIE in response.cookies) + token = response.cookies[auth._CHIRP_SECURITY_TOKEN_COOKIE].value + cred = auth._parse_security_token(token) + self.assertEqual(email, cred.email) + + def test_get_current_user(self): + # Set up a test user. + email = 'get_current_user_test@test.com' + user = User(email=email) + user.save() + + # Create some security tokens. + expired_token = auth._create_security_token(user) + self.now += 0.75 * auth._TOKEN_TIMEOUT_S + stale_token = auth._create_security_token(user) + self.now += 0.75 * auth._TOKEN_TIMEOUT_S + good_token = auth._create_security_token(user) + + # Create a test HttpRequest, and test using it against our + # various tokens. + request = http.HttpRequest() + request.COOKIES[auth._CHIRP_SECURITY_TOKEN_COOKIE] = expired_token + self.assertEqual(None, auth.get_current_user(request)) + request.COOKIES[auth._CHIRP_SECURITY_TOKEN_COOKIE] = stale_token + user = auth.get_current_user(request) + self.assertEqual(email, user.email) + self.assertTrue(user._credentials.security_token_is_stale) + request.COOKIES[auth._CHIRP_SECURITY_TOKEN_COOKIE] = good_token + user = auth.get_current_user(request) + self.assertEqual(email, user.email) + self.assertFalse(user._credentials.security_token_is_stale) + + # Check that we will reject an inactive user. + user.is_active = False + user.save() + self.assertRaises(auth.UserNotAllowedError, + auth.get_current_user, request) + user.is_active = True + user.save() + + # This also tests our monkey-patching of Django to make + # client.login() and client.logout(). + def test_auth_middleware(self): + client = Client() + # When returning a user of None, we should get redirected to + # a login page. + response = client.get('/') + self.assertEqual(302, response.status_code) + # Logged out users should be able to get to the login page. + response = client.get('/auth/hello') + self.assertEqual(200, response.status_code) + # Log in as a test user. + client.login(email='test@test.com') + # Logged in, active users should be able to reach '/'. + response = client.get('/') + self.assertEqual(200, response.status_code) + # Hitting the logout URL should actually log us out. + response = client.get('/auth/goodbye') + self.assertEqual(302, response.status_code) # Redirects us off-site + # Since we are now logged out, '/' should try to redirect us to + # log in. + response = client.get('/') + self.assertEqual(302, response.status_code) + # Deactived users should get a 403 + client.login(email='test@test.com', is_active=False) + response = client.get('/') + self.assertEqual(403, response.status_code) + # Logging out should take us back where we started. + client.logout() + response = client.get('/') + self.assertEqual(302, response.status_code) + + def test_client_login_supports_roles(self): + client = Client() + # This should be rejected, since the /auth/ page requires + # special credentials. + client.login(email='test@test.com', is_active=True) + response = client.get('/auth/') + self.assertEqual(403, response.status_code) + # This should work. + client.login(email='test@test.com', + roles=[roles.VOLUNTEER_COORDINATOR]) + response = client.get('/auth/') + self.assertEqual(200, response.status_code) + + def test_bootstrapping_via_google_accounts(self): + client = Client() + # Not signed in at all? We should be redirected to a Google + # login page. + response = client.get('/auth/_bootstrap') + self.assertEqual(302, response.status_code) + # Already signed in? You should see a 403. + client.login(email='bootstrap_test_user@test.com') + response = client.get('/auth/_bootstrap') + self.assertEqual(403, response.status_code) + self.assertEqual('Already logged in', response.content) + client.logout() + # Reject people who are not superusers. + g_email = 'google_user@gmail.com' + self.g_user = google_users.User(email=g_email) + self.is_superuser = False + response = client.get('/auth/_bootstrap') + self.assertEqual(403, response.status_code) + self.assertEqual('Not a chirpradio project admin', response.content) + # Create a new User object for superusers. + self.assertEqual(None, User.get_by_email(g_email)) + self.is_superuser = True + response = client.get('/auth/_bootstrap') + self.assertEqual(302, response.status_code) # Redirect to login page. + user = User.get_by_email(g_email) + self.assertEqual(g_email, user.email) + # If the user already exists for the superuser, 403. + response = client.get('/auth/_bootstrap') + self.assertEqual(403, response.status_code) + self.assertEqual('User %s already exists' % g_email, response.content) + + def test_url_generation(self): + # This is just a smoke test. + auth.create_login_url("not actually a path") + + +class FormsTestCase(unittest.TestCase): + + def test_login_form(self): + # Set up a test user. + email = 'test_login_form@test.com' + user = User(email=email) + user.set_password('password') + user.save() + # Missing required data + form = auth_forms.LoginForm({'redirect': '/'}) + self.assertFalse(form.is_valid()) + form = auth_forms.LoginForm({'redirect': '/', 'email': email}) + self.assertFalse(form.is_valid()) + # Form should fail to validate if password is incorrect. + form = auth_forms.LoginForm({'redirect': '/', + 'email': email, + 'password': 'incorrect password'}) + self.assertFalse(form.is_valid()) + # This should succeed. + form = auth_forms.LoginForm({'redirect': '/', + 'email': email, + 'password': 'password'}) + self.assertTrue(form.is_valid()) + # The form should reject inactive users. + user.is_active = False + user.save() + form = auth_forms.LoginForm({'redirect': '/', + 'email': email, + 'password': 'password'}) + self.assertFalse(form.is_valid()) + # The form should reject unknown users. + form = auth_forms.LoginForm({'redirect': '/', + 'email': 'no_such_user@test.com', + 'password': 'password'}) + self.assertFalse(form.is_valid()) + + def test_change_password_form(self): + # Set up a test user + user = User(email='test_change_password_form@test.com') + user.set_password('password') + + # The form should fail to validate if the current password is wrong. + form = auth_forms.ChangePasswordForm({ + 'current_password': 'incorrect password', + 'new_password': 'foo', + 'confirm_new_password': 'foo', + }) + form.set_user(user) + self.assertFalse(form.is_valid()) + + # The form should fail to validate if the two versions of the + # new password do not agree. + form = auth_forms.ChangePasswordForm({ + 'current_password': 'password', + 'new_password': 'foo', + 'confirm_new_password': 'bar', + }) + form.set_user(user) + self.assertFalse(form.is_valid()) + + # This should work. + form = auth_forms.ChangePasswordForm({ + 'current_password': 'password', + 'new_password': 'foo', + 'confirm_new_password': 'foo', + }) + form.set_user(user) + self.assertTrue(form.is_valid()) + + def test_forgot_password_form(self): + # Set up a test user + user = User(email='test_forgot_password_form@test.com') + user.set_password('password') + user.save() + # The form will validate if given a known email address. + form = auth_forms.ForgotPasswordForm({'email': user.email}) + self.assertTrue(form.is_valid()) + # Check that the user gets attached to the form's user + # property. + self.assertEqual(user.email, form.user.email) + # The form will not validate for an unknown email address. + form = auth_forms.ForgotPasswordForm({'email': 'nosuchuser@test.com'}) + self.assertFalse(form.is_valid()) + self.assertEqual(None, form.user) + + def test_reset_password_form(self): + # The form should validate only if both passwords are identical. + form = auth_forms.ResetPasswordForm({ + 'token': 'token', + 'new_password': 'foo', + 'confirm_new_password': 'bar'}) + self.assertFalse(form.is_valid()) + form = auth_forms.ResetPasswordForm({ + 'token': 'token', + 'new_password': 'foo', + 'confirm_new_password': 'foo'}) + self.assertTrue(form.is_valid()) + + def test_user_edit_form(self): + # TODO: Add some tests here! + pass diff --git a/auth/urls.py b/auth/urls.py new file mode 100644 index 0000000..c4e1038 --- /dev/null +++ b/auth/urls.py @@ -0,0 +1,44 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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. +### + +"""URLs for the auth system.""" + +from django.conf import settings +from django.conf.urls.defaults import patterns + +urlpatterns = patterns( + '', + # Log in + (r'^hello/?', 'auth.views.hello'), + # Log out + (r'^goodbye/?', 'auth.views.goodbye'), + # Change your password + (r'^change_password/?', 'auth.views.change_password'), + # Send a password reset email. + (r'^forgot_password/?', 'auth.views.forgot_password'), + # Reset a forgotten password. + (r'^reset_password/?', 'auth.views.reset_password'), + # Main user management page. + (r'^/?$', 'auth.views.main_page'), + # Edit a user. + (r'^edit_user/?', 'auth.views.edit_user'), + # Add a user. + (r'^add_user/?', 'auth.views.add_user'), + + # Bootstrap a test account from a Google account. + (r'^_bootstrap/?', 'auth.views.bootstrap'), +) diff --git a/auth/views.py b/auth/views.py new file mode 100644 index 0000000..57bde19 --- /dev/null +++ b/auth/views.py @@ -0,0 +1,284 @@ +### +### Copyright 2009 The Chicago Independent Radio Project +### All Rights Reserved. +### +### 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. +### + +"""Views for the auth system.""" + +# Python imports +import base64 +import datetime +import logging +import os +# Django imports +from django import http +from django.template import loader, Context, RequestContext +# App Engine imports +from google.appengine.api import mail +from google.appengine.api import users as google_users +# Application imports +import auth +from auth import roles +from auth.decorators import require_role, require_signed_in_user +from auth import forms as auth_forms +from auth.models import User + +# Require this role in order to access any management tasks. +USER_MANAGEMENT_ROLE = roles.VOLUNTEER_COORDINATOR + + +### +### Log-in and log-out pages +### + +def hello(request): + """Implements our login page.""" + redirect = '/' + tmpl = loader.get_template('auth/hello.html') + if request.method == 'GET': + redirect = request.GET.get('redirect', '/') + # Already signed in? Then redirect immediately. + if request.user: + return http.HttpResponseRedirect(redirect) + form = auth_forms.LoginForm(initial={ + 'redirect': redirect, + }) + else: + form = auth_forms.LoginForm(request.POST) + if form.is_valid(): + response = http.HttpResponseRedirect(form.cleaned_data['redirect']) + auth.attach_credentials(response, form.user) + # Update the last login time in the User record. + form.user.last_login = datetime.datetime.now() + form.user.save() + return response + + ctx = RequestContext(request, {'form': form}) + return http.HttpResponse(tmpl.render(ctx)) + + +def goodbye(request): + """Implements our logout page.""" + # This makes our middleware handle the logout for us. + response = http.HttpResponse('Dummy') + response._logout = True + return response + + +### +### A page to let users change their password +### + +@require_signed_in_user +def change_password(request): + """Change password page.""" + tmpl = loader.get_template('auth/change_password.html') + ctx_vars = { + 'title': 'Change Password', + } + if request.method == 'GET': + ctx_vars['form'] = auth_forms.ChangePasswordForm() + else: + form = auth_forms.ChangePasswordForm(request.POST) + form.set_user(request.user) + if not form.is_valid(): + ctx_vars['form'] = form + else: + request.user.set_password(form.cleaned_data['new_password']) + request.user.save() + ctx = RequestContext(request, ctx_vars) + return http.HttpResponse(tmpl.render(ctx)) + + +### +### Pages to allow a user to reset a forgotten password. +### + +def forgot_password(request): + """Request a a password reset email. + + A user can enter an email address into a form. Submitting causes + an email containing a URL that can be clicked to restore access. + """ + if request.user: + return http.HttpResponseForbidden('Logged-in users prohibited.') + # TODO(trow): Rate-limit password reset emails? + tmpl = loader.get_template('auth/forgot_password.html') + ctx_vars = { + 'title': 'Recover Forgotten Password', + } + if request.method == 'GET': + ctx_vars['form'] = auth_forms.ForgotPasswordForm() + else: + form = auth_forms.ForgotPasswordForm(request.POST) + if not form.is_valid(): + ctx_vars['form'] = form + else: + email = ctx_vars['email'] = form.user.email + # Assemble the URL that can be used to access the password + # reset form. + token = auth.get_password_reset_token(form.user) + url = 'http://%s/auth/reset_password?token=%s' % ( + os.environ['HTTP_HOST'], token) + # Construct and send the email message + msg_tmpl = loader.get_template('auth/forgot_password_email.txt') + msg_ctx = Context({'user': form.user, 'url': url}) + msg_body = msg_tmpl.render(msg_ctx) + # Actually send the email message. + # TODO(trow): This should come from a more general account. + mail.send_mail(sender='trowbridge.jon@gmail.com', + to=form.user.email, + subject='Recovering your forgotten CHIRP password', + body=msg_body) + ctx = RequestContext(request, ctx_vars) + return http.HttpResponse(tmpl.render(ctx)) + + +def reset_password(request): + """Allow a user to reset their password. + + The user authenticates by presenting a security token. Users will + arrive at this page by clicking on the URL in the email they are + sent by the /auth/forgot_password page. + """ + if request.user: + return http.HttpResponseForbidden('Logged-in users prohibited.') + tmpl = loader.get_template('auth/reset_password.html') + ctx_vars = { + 'Title': 'Reset Password', + } + user = None + if request.method == 'GET': + token = request.GET.get('token') + if token is None: + return http.HttpResponseForbidden('Missing token') + email = auth.parse_passord_reset_token(token) + if email is None: + return http.HttpResponseForbidden('Invalid token') + ctx_vars['form'] = auth_forms.ResetPasswordForm( + initial={'token': token}) + else: + form = auth_forms.ResetPasswordForm(request.POST) + if not form.is_valid(): + ctx_vars['form'] = form + else: + token = form.cleaned_data['token'] + email = token and auth.parse_passord_reset_token(token) + if email is None: + return http.HttpResponseForbidden('Invalid token') + user = User.get_by_email(email) + if user is None: + return http.HttpResponseForbidden('No user for token') + user.set_password(form.cleaned_data['new_password']) + # We are also logging the user in automatically, so record + # the time. + user.last_login = datetime.datetime.now() + user.save() + # Attach the user to the request so that our page will + # display the chrome shown to logged-in users. + request.user = user + ctx = RequestContext(request, ctx_vars) + response = http.HttpResponse(tmpl.render(ctx)) + if request.user: + auth.attach_credentials(response, request.user) + return response + + +### +### Simple user management tools. +### + +@require_role(USER_MANAGEMENT_ROLE) +def main_page(request): + """Lists all users.""" + tmpl = loader.get_template('auth/main_page.html') + all_users = list(User.all().order('last_name').order('first_name')) + num_active_users = sum(u.is_active for u in all_users) + active = [u for u in all_users if u.is_active] + inactive = [u for u in all_users if not u.is_active] + ctx = RequestContext(request, { + 'title': 'User Management', + 'all_users': active + inactive, + 'num_active_users': num_active_users, + }) + return http.HttpResponse(tmpl.render(ctx)) + + +@require_role(USER_MANAGEMENT_ROLE) +def edit_user(request): + tmpl = loader.get_template('auth/user_form.html') + if request.method == 'GET': + email = request.GET.get('email') + user_to_edit = User.get_by_email(email) + user_form = auth_forms.UserForm.from_user(user_to_edit) + elif request.method == 'POST': + user_form = auth_forms.UserForm(request.POST) + if user_form.is_valid(): + user_to_edit = user_form.to_user() + user_to_edit.save() + # When finished, redirect user back to the user list. + return http.HttpResponseRedirect('/auth/') + ctx = RequestContext(request, { + 'title': 'Edit User', + 'user_to_edit': user_to_edit, + 'form': user_form, + }) + return http.HttpResponse(tmpl.render(ctx)) + + +@require_role(USER_MANAGEMENT_ROLE) +def add_user(request): + tmpl = loader.get_template('auth/user_form.html') + ctx_vars = { + 'title': 'Add New User', + } + if request.method == 'GET': + ctx_vars['form'] = auth_forms.UserForm() + elif request.method == 'POST': + form = auth_forms.UserForm(request.POST) + if not form.is_valid(): + ctx_vars['form'] = form + if form.is_valid(): + user = form.to_user() + user.save() + ctx_vars['message'] = 'Successfully added user %s' % user.email + ctx_vars['form'] = auth_forms.UserForm() + ctx = RequestContext(request, ctx_vars) + return http.HttpResponse(tmpl.render(ctx)) + + +### +### A backdoor for sysadmins. Useful during testing. Probably should be +### turned off in production. +### + +def bootstrap(request): + """If the visitor is a chirpradio admin, create a user for them.""" + if request.user is None: + g_user = google_users.get_current_user() + if g_user is None: + return http.HttpResponseRedirect( + google_users.create_login_url(request.path)) + if not google_users.is_current_user_admin(): + return http.HttpResponseForbidden('Not a chirpradio project admin') + user = User.get_by_email(g_user.email()) + if user: + return http.HttpResponseForbidden( + 'User %s already exists' % user.email) + user = User(email=g_user.email(), is_superuser=True) + user.set_password("test") + user.save() + return http.HttpResponseRedirect(auth.create_login_url('/')) + return http.HttpResponseForbidden("Already logged in") diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/context_processors.py b/common/context_processors.py new file mode 100644 index 0000000..15aef82 --- /dev/null +++ b/common/context_processors.py @@ -0,0 +1,12 @@ + +"""Custom context processor for CHIRP request templates.""" + +import auth + + +def base(request): + return { + 'user': request.user, + 'login_url': auth.create_login_url('/'), + 'logout_url': auth.LOGOUT_URL, + } diff --git a/common/decorators.py b/common/decorators.py new file mode 100644 index 0000000..142a9a2 --- /dev/null +++ b/common/decorators.py @@ -0,0 +1,25 @@ +import logging +import traceback + +from django.utils import simplejson +from django.http import HttpResponse + + +def respond_with_json(func): + """Convert a handler's return value into a JSON-ified HttpResponse.""" + def wrapper(*args, **kwargs): + try: + response_py = func(*args, **kwargs) + status = 200 + except Exception, err: + logging.exception('Error in JSON response') + response_py = { + 'success': False, + 'error': repr(err), + 'traceback': traceback.format_exc() + } + status = 500 + return HttpResponse(simplejson.dumps(response_py), + mimetype='application/json', + status=status ) + return wrapper diff --git a/django-extras/README b/django-extras/README new file mode 100644 index 0000000..a42bd17 --- /dev/null +++ b/django-extras/README @@ -0,0 +1,5 @@ +Modules in this tree are superimposed into the django module's +namespace. For example, the module django-extras/foo/bar.py could be +imported with: + + from django.foo import bar diff --git a/django-extras/__init__.py b/django-extras/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-extras/_monkey_patch.py b/django-extras/_monkey_patch.py new file mode 100644 index 0000000..f2ca108 --- /dev/null +++ b/django-extras/_monkey_patch.py @@ -0,0 +1,40 @@ + +"""Monkey-patching for Django. + +These are CHIRP-specific hacks that make Django 1.0 behave the way we want. +""" + +from django.test import client +import auth +from auth.models import User + + +### +### Replaces Client.login and Client.logout with versions that +### support our custom authentication. +### + +def fake_Client_login(self, **credentials): + """If the given credentials are valid, return a User object.""" + user = None + email = credentials.get('email') + if email: + user = User.get_by_email(email) + if user is None: + user = User(email=email) + for key, value in credentials.items(): + setattr(user, key, value) + user.save() + + token = '' + if user: + token = auth._create_security_token(user) + self.cookies[auth._CHIRP_SECURITY_TOKEN_COOKIE] = token + + +def fake_Client_logout(self): + del self.cookies[auth._CHIRP_SECURITY_TOKEN_COOKIE] + + +client.Client.login = fake_Client_login +client.Client.logout = fake_Client_logout diff --git a/django-extras/contrib/__init__.py b/django-extras/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django-extras/contrib/auth/__init__.py b/django-extras/contrib/auth/__init__.py new file mode 100644 index 0000000..98d232d --- /dev/null +++ b/django-extras/contrib/auth/__init__.py @@ -0,0 +1,15 @@ +"""Stubbed-out versions of django.contrib.auth functions. + +Some parts of core Django (like the testing system) depend on +django.contrib.auth. Here we put in a few stub APIs, just enough to +keep the imports from failing. +""" + +from auth.models import User + +def authenticate(**credentials): + raise NotImplementedError + + +def login(request, user): + raise NotImplementedError diff --git a/django-extras/foo.py b/django-extras/foo.py new file mode 100644 index 0000000..e69de29 diff --git a/django.zip b/django.zip new file mode 100644 index 0000000..a9679ef Binary files /dev/null and b/django.zip differ diff --git a/landing_page/__init__.py b/landing_page/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/landing_page/views.py b/landing_page/views.py new file mode 100644 index 0000000..7427d6c --- /dev/null +++ b/landing_page/views.py @@ -0,0 +1,13 @@ + +"""Views for the site landing page.""" + +from django import http +from django.template import RequestContext, loader + + +def landing_page(request): + template = loader.get_template('landing_page/landing_page.html') + ctx = RequestContext(request, { + 'title': 'Welcome to chirpradio', + }) + return http.HttpResponse(template.render(ctx)) diff --git a/main.py b/main.py new file mode 100755 index 0000000..6a5546a --- /dev/null +++ b/main.py @@ -0,0 +1,59 @@ +# 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. + +"""Bootstrap for running a Django app under Google App Engine. + +The site-specific code is all in other files: settings.py, urls.py, +models.py, views.py. And in fact, only 'settings' is referenced here +directly -- everything else is controlled from there. + +""" + +# Standard Python imports. +import os +import sys +import logging + +from appengine_django import InstallAppengineHelperForDjango +from appengine_django import have_django_zip +from appengine_django import django_zip_path +InstallAppengineHelperForDjango() + +# Google App Engine imports. +from google.appengine.ext.webapp import util + +# Import the part of Django that we use here. +import django.core.handlers.wsgi + +def main(): + # Ensure the Django zipfile is in the path if required. + if have_django_zip and django_zip_path not in sys.path: + sys.path.insert(1, django_zip_path) + + # Map the contents of the django-extras tree into the django + # module's namespace. + import django + django.__path__.append('django-extras') + + # Pull in CHIRP's monkey-patching of Django + from django import _monkey_patch + + # Create a Django application for WSGI. + application = django.core.handlers.wsgi.WSGIHandler() + + # Run the WSGI CGI handler with that application. + util.run_wsgi_app(application) + +if __name__ == '__main__': + main() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..2dd847d --- /dev/null +++ b/manage.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# 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 appengine_django import InstallAppengineHelperForDjango +InstallAppengineHelperForDjango() + +# Superimpose the contents of the django-extras tree onto the django +# module's namespace. +import django +django.__path__.append('django-extras') + +# Pull in CHIRP's monkey-patching of Django +from django import _monkey_patch + +from django.core.management import execute_manager +try: + import settings # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + + +if __name__ == "__main__": + execute_manager(settings) diff --git a/media/common/css/base.css b/media/common/css/base.css new file mode 100644 index 0000000..4054279 --- /dev/null +++ b/media/common/css/base.css @@ -0,0 +1,3 @@ + +/* TODO: Fill this in and make everything pretty. */ + diff --git a/media/ext_js/jquery-autocomplete/autocomplete_docs.txt b/media/ext_js/jquery-autocomplete/autocomplete_docs.txt new file mode 100644 index 0000000..a8b0f51 --- /dev/null +++ b/media/ext_js/jquery-autocomplete/autocomplete_docs.txt @@ -0,0 +1,153 @@ +Autocomplete - a jQuery plugin + +NOTE: This is a modification of the jQuery Autocomplete Plug-in written by Dylan Verheul. The documentation is also based on Dylan's documentation, I made additions/changes as need to support my modifications. + +Usage: +====== +$("selector").autocomplete(url [, options]); +$("selector").autocompleteArray(array [, options]); + +Demo page (search for City names in the state of Ohio (US)): +================================================================== +http://www.pengoworks.com/workshop/jquery/autocomplete.htm + +Advice: +======= +Make sure that selector selects only one element, unless you really, really know what you are doing. + +Example 1: +========== +$("#input_box").autocomplete("autocomplete_ajax.cfm"); + +In the above example, Autocomplete expects an input element with the id "input_box" to exist. When a user starts typing in the input box, the autocompleter will request autocomplete_ajax.cfm with a GET parameter named q that contains the current value of the input box. Let's assume that the user has typed "sp" (without quotes). Autocomplete will then request autocomplete_ajax.cfm?q=sp. + +You can see an example of the output here: +http://www.pengoworks.com/workshop/jquery/autocomplete_ajax.cfm?q=sp + +The backend should output possible values for the autocompleter, each on a single line. Output cannot contain the pipe symbol "|", since that is considered a separator (more on that later). + +An appropiate simple output would be: + +Sparta +Spencer +Spencerville +Spring Valley +Springboro +Springfield + +NOTE: The autocompleter will present the options in the order the backend sends them. + +Example 2: +========== +$("#input_box").autocompleteArray(["Allen","Albert","Alberto","Alladin"]); + +In the above example, and autocomplete box would be populated based on an array containing the items listed above. There are times when you have a very small subset of data you need to allow a user to select from and in those case AJAX operations are often overkill. You can load all the data locally and use an array to build your autocomplete suggestion list. + + +Plug-in Mod/Enhancements +======================== +* Supports local data array (can now use w/out AJAX). +* Limit dropdown to XX number of results (good for limiting the results to users) +* Autofill pre-populates text box as you type +* New findValue() method can be used to programmatically determine if the value in the box is a valid option. (Useful for verifying the text entered is an existing value option.) +* Dropdown options now correctly re-position themselves on each display (which means they adjust for changing to the DOM) +* Dropdown box defaults to the width of the input field its attached to (you can manually specify a larger width as well) +* Better emulates Windows autocomplete boxes (for example: hitting delete and retyping the same box will now bring back the dropdown menu) +* Miscellaneous bug fixes + + +Advanced options: +================= + +You can pass advanced options as a JavaScript object, notation { name:value, ..., name: value } +Example: $("#input_box").autocomplete("my_autocomplete_backend.php", { minChars:3 }); + +These options are available: + +autoFill (default value: false) + Whether or not the first match should be used to autofill in the input element. As you type, the first match will be filled in the input element as a best guess as to what you're looking for. Text you did not manually type will be pre-selected so typing the next character will make the guess go away and the next best match will be populated. + +inputClass (default value: "ac_input") + This class will be added to the input box. + +resultsClass (default value: "ac_results") + The class for the UL that will contain the result items (result items are LI elements). + +loadingClass = (default value: "ac_loading") + The class for the input box while results are being fetched from the server. + +lineSeparator = (default value: "\n") + The character that separates lines in the results from the backend. + +cellSeparator (default value: "|") + The character that separates cells in the results from the backend. + +minChars (default value: 1) + The minimum number of characters a user has to type before the autocompleter activates. + +delay (default value: 400) + The delay in milliseconds the autocompleter waits after a keystroke to activate itself. If you're using the data property to set a local array, you may wish to increase the delay to a shorter time frame (such as 40ms.) + +cacheLength (default value: 1) + The number of backend query results to store in cache. If set to 1 (the current result), no caching will happen. Do not set below 1. + +matchSubset (default value: 1) + Whether or not the autocompleter can use a cache for more specific queries. This means that all matches of "foot" are a subset of all matches for "foo". Usually this is true, and using this options decreases server load and increases performance. Remember to set cacheLength to a bigger number, like 10. + +matchCase (default value: 0) + Whether or not the comparison is case sensitive. Only important only if you use caching. + +matchContains = options.matchContains || 0; + Whether or not the comparison looks inside (i.e. does "ba" match "foo bar") the search results. Only important if you use caching. + +maxItemsToShow (default value: -1) + Limits the number of results that will be showed in the drop down. This is useful if you have a large dataset and don't want to provide the user with a list that could contain hundreds of items. To disable this feature, set the value to -1. + +mustMatch (default value: 0) + If set to 1 (true), the autocompleter will only allow results that are presented by the backend. Note that illegal values result in an empty input box. In the example at the beginning of this documentation, typing "footer" would result in an empty input box. + +extraParams (default value: {}) + Extra parameters for the backend. If you were to specify { bar:4 }, the autocompleter would call my_autocomplete_backend.php?q=foo&bar=4 (assuming the input box contains "foo"). + +width (default value: 0) + Sets the width of the drop down layer. If a non-positive integer is specified, then the width of the box will be determined by the width of the input element. Generally speaking, you'll want to leave this value alone. However, in some circumstances you may have a small input element where the drop down layer needs to display a lot of options. In that case, you can specify a larger size. + +selectFirst (default value: false) + If this is set to true, the first autocomplete value will be automatically selected on tab/return, even if it has not been handpicked by keyboard or mouse action. If there is a handpicked (highlighted) result, that result will take precedence. + +selectOnly (default value: false) + If this is set to true, and there is only one autocomplete when the user hits tab/return, it will be selected even if it has not been handpicked by keyboard or mouse action. This overrides selectFirst. + +formatItem (default value: none) + A JavaScript funcion that can provide advanced markup for an item. For each row of results, this function will be called. The returned value will be displayed inside an LI element in the results list. Autocompleter will provide 3 parameters: the results row, the position of the row in the list of results, and the number of items in the list of results. See the source code of http://www.dyve.net/jquery?autocomplete for an example. + +onSelectItem (default value: none) + A JavaScript function that will be called when an item is selected. The autocompleter will specify a single argument, being the LI element selected. This LI element will have an attribute "extra" that contains an array of all cells that the backend specified. See the source code of http://www.dyve.net/jquery?autocomplete for an example. + +onFindValue (default value: none) + A JavaScript function that will be called when the findValue() method is called. The function will be passed the select LI element--just like the onSelectItem function is. + +More advanced options +===================== + +If you want to do more with your autocompleter, you can change some options on the fly. +The autocompleter is accessed as an attribute of the input box. + +Example: +// set the autocompleter +var ac = $("#input_box").autocomplete("my_autocomplete_backend.php"); +// would look up the value of the autocomplete box based on the text in the input element +ac[0].autocompleter.findValue(); + +There following functions that can be called to influence the behaviour at run-time: + +findValue() + This will examine the value currently in the input element and look it's value up to see if it can find a matching value. This function can potentially perform an AJAX operation, therefore the findValue() function does not return a value. Instead, you need to specific a onFindValue callback function that will run. This method is valuable if you need to set the Autocomplete input element to a value via JavaScript and the "value" of the text field is mapped to extended properties stored in the LI element's "extra" property. + +flushCache() + This flushes the cache. + +setExtraParams(obj) + This sets the extra parameters of the autocompleter to obj (which should be a JavaScript object, see above). + +It's often wise to flush the cache after calling setExtraParameters. diff --git a/media/ext_js/jquery-autocomplete/jquery.autocomplete.css b/media/ext_js/jquery-autocomplete/jquery.autocomplete.css new file mode 100644 index 0000000..e1a26be --- /dev/null +++ b/media/ext_js/jquery-autocomplete/jquery.autocomplete.css @@ -0,0 +1,46 @@ +.ac_results { + padding: 0px; + border: 1px solid WindowFrame; + background-color: Window; + overflow: hidden; +} + +.ac_results ul { + width: 100%; + list-style-position: outside; + list-style: none; + padding: 0; + margin: 0; +} + +.ac_results iframe { + display:none;/*sorry for IE5*/ + display/**/:block;/*sorry for IE5*/ + position:absolute; + top:0; + left:0; + z-index:-1; + filter:mask(); + width:3000px; + height:3000px; +} + +.ac_results li { + margin: 0px; + padding: 2px 5px; + cursor: pointer; + display: block; + width: 100%; + font: menu; + font-size: 12px; + overflow: hidden; +} + +.ac_loading { + background : Window url('./indicator.gif') right center no-repeat; +} + +.ac_over { + background-color: Highlight; + color: HighlightText; +} diff --git a/media/ext_js/jquery-autocomplete/jquery.autocomplete.js b/media/ext_js/jquery-autocomplete/jquery.autocomplete.js new file mode 100644 index 0000000..76af765 --- /dev/null +++ b/media/ext_js/jquery-autocomplete/jquery.autocomplete.js @@ -0,0 +1,502 @@ +jQuery.autocomplete = function(input, options) { + // Create a link to self + var me = this; + + // Create jQuery object for input element + var $input = $(input).attr("autocomplete", "off"); + + // Apply inputClass if necessary + if (options.inputClass) $input.addClass(options.inputClass); + + // Create results + var results = document.createElement("div"); + // Create jQuery object for results + var $results = $(results); + $results.hide().addClass(options.resultsClass).css("position", "absolute"); + if( options.width > 0 ) $results.css("width", options.width); + + // Add to body element + $("body").append(results); + + input.autocompleter = me; + + var timeout = null; + var prev = ""; + var active = -1; + var cache = {}; + var keyb = false; + var hasFocus = false; + var lastKeyPressCode = null; + + // flush cache + function flushCache(){ + cache = {}; + cache.data = {}; + cache.length = 0; + }; + + // flush cache + flushCache(); + + // if there is a data array supplied + if( options.data != null ){ + var sFirstChar = "", stMatchSets = {}, row = []; + + // no url was specified, we need to adjust the cache length to make sure it fits the local data store + if( typeof options.url != "string" ) options.cacheLength = 1; + + // loop through the array and create a lookup structure + for( var i=0; i < options.data.length; i++ ){ + // if row is a string, make an array otherwise just reference the array + row = ((typeof options.data[i] == "string") ? [options.data[i]] : options.data[i]); + + // if the length is zero, don't add to list + if( row[0].length > 0 ){ + // get the first character + sFirstChar = row[0].substring(0, 1).toLowerCase(); + // if no lookup array for this character exists, look it up now + if( !stMatchSets[sFirstChar] ) stMatchSets[sFirstChar] = []; + // if the match is a string + stMatchSets[sFirstChar].push(row); + } + } + + // add the data items to the cache + for( var k in stMatchSets ){ + // increase the cache size + options.cacheLength++; + // add to the cache + addToCache(k, stMatchSets[k]); + } + } + + $input + .keydown(function(e) { + // track last key pressed + lastKeyPressCode = e.keyCode; + switch(e.keyCode) { + case 38: // up + e.preventDefault(); + moveSelect(-1); + break; + case 40: // down + e.preventDefault(); + moveSelect(1); + break; + case 9: // tab + case 13: // return + if( selectCurrent() ){ + // make sure to blur off the current field + $input.get(0).blur(); + e.preventDefault(); + } + break; + default: + active = -1; + if (timeout) clearTimeout(timeout); + timeout = setTimeout(function(){onChange();}, options.delay); + break; + } + }) + .focus(function(){ + // track whether the field has focus, we shouldn't process any results if the field no longer has focus + hasFocus = true; + }) + .blur(function() { + // track whether the field has focus + hasFocus = false; + hideResults(); + }); + + hideResultsNow(); + + function onChange() { + // ignore if the following keys are pressed: [del] [shift] [capslock] + if( lastKeyPressCode == 46 || (lastKeyPressCode > 8 && lastKeyPressCode < 32) ) return $results.hide(); + var v = $input.val(); + if (v == prev) return; + prev = v; + if (v.length >= options.minChars) { + $input.addClass(options.loadingClass); + requestData(v); + } else { + $input.removeClass(options.loadingClass); + $results.hide(); + } + }; + + function moveSelect(step) { + + var lis = $("li", results); + if (!lis) return; + + active += step; + + if (active < 0) { + active = 0; + } else if (active >= lis.size()) { + active = lis.size() - 1; + } + + lis.removeClass("ac_over"); + + $(lis[active]).addClass("ac_over"); + + // Weird behaviour in IE + // if (lis[active] && lis[active].scrollIntoView) { + // lis[active].scrollIntoView(false); + // } + + }; + + function selectCurrent() { + var li = $("li.ac_over", results)[0]; + if (!li) { + var $li = $("li", results); + if (options.selectOnly) { + if ($li.length == 1) li = $li[0]; + } else if (options.selectFirst) { + li = $li[0]; + } + } + if (li) { + selectItem(li); + return true; + } else { + return false; + } + }; + + function selectItem(li) { + if (!li) { + li = document.createElement("li"); + li.extra = []; + li.selectValue = ""; + } + var v = $.trim(li.selectValue ? li.selectValue : li.innerHTML); + input.lastSelected = v; + prev = v; + $results.html(""); + $input.val(v); + hideResultsNow(); + if (options.onItemSelect) setTimeout(function() { options.onItemSelect(li) }, 1); + }; + + // selects a portion of the input string + function createSelection(start, end){ + // get a reference to the input element + var field = $input.get(0); + if( field.createTextRange ){ + var selRange = field.createTextRange(); + selRange.collapse(true); + selRange.moveStart("character", start); + selRange.moveEnd("character", end); + selRange.select(); + } else if( field.setSelectionRange ){ + field.setSelectionRange(start, end); + } else { + if( field.selectionStart ){ + field.selectionStart = start; + field.selectionEnd = end; + } + } + field.focus(); + }; + + // fills in the input box w/the first match (assumed to be the best match) + function autoFill(sValue){ + // if the last user key pressed was backspace, don't autofill + if( lastKeyPressCode != 8 ){ + // fill in the value (keep the case the user has typed) + $input.val($input.val() + sValue.substring(prev.length)); + // select the portion of the value not typed by the user (so the next character will erase) + createSelection(prev.length, sValue.length); + } + }; + + function showResults() { + // get the position of the input field right now (in case the DOM is shifted) + var pos = findPos(input); + // either use the specified width, or autocalculate based on form element + var iWidth = (options.width > 0) ? options.width : $input.width(); + // reposition + $results.css({ + width: parseInt(iWidth) + "px", + top: (pos.y + input.offsetHeight) + "px", + left: pos.x + "px" + }).show(); + }; + + function hideResults() { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(hideResultsNow, 200); + }; + + function hideResultsNow() { + if (timeout) clearTimeout(timeout); + $input.removeClass(options.loadingClass); + if ($results.is(":visible")) { + $results.hide(); + } + if (options.mustMatch) { + var v = $input.val(); + if (v != input.lastSelected) { + selectItem(null); + } + } + }; + + function receiveData(q, data) { + if (data) { + $input.removeClass(options.loadingClass); + results.innerHTML = ""; + + // if the field no longer has focus or if there are no matches, do not display the drop down + if( !hasFocus || data.length == 0 ) return hideResultsNow(); + + if ($.browser.msie) { + // we put a styled iframe behind the calendar so HTML SELECT elements don't show through + $results.append(document.createElement('iframe')); + } + results.appendChild(dataToDom(data)); + // autofill in the complete box w/the first match as long as the user hasn't entered in more data + if( options.autoFill && ($input.val().toLowerCase() == q.toLowerCase()) ) autoFill(data[0][0]); + showResults(); + } else { + hideResultsNow(); + } + }; + + function parseData(data) { + if (!data) return null; + var parsed = []; + var rows = data.split(options.lineSeparator); + for (var i=0; i < rows.length; i++) { + var row = $.trim(rows[i]); + if (row) { + parsed[parsed.length] = row.split(options.cellSeparator); + } + } + return parsed; + }; + + function dataToDom(data) { + var ul = document.createElement("ul"); + var num = data.length; + + // limited results to a max number + if( (options.maxItemsToShow > 0) && (options.maxItemsToShow < num) ) num = options.maxItemsToShow; + + for (var i=0; i < num; i++) { + var row = data[i]; + if (!row) continue; + var li = document.createElement("li"); + if (options.formatItem) { + li.innerHTML = options.formatItem(row, i, num); + li.selectValue = row[0]; + } else { + li.innerHTML = row[0]; + li.selectValue = row[0]; + } + var extra = null; + if (row.length > 1) { + extra = []; + for (var j=1; j < row.length; j++) { + extra[extra.length] = row[j]; + } + } + li.extra = extra; + ul.appendChild(li); + $(li).hover( + function() { $("li", ul).removeClass("ac_over"); $(this).addClass("ac_over"); active = $("li", ul).indexOf($(this).get(0)); }, + function() { $(this).removeClass("ac_over"); } + ).click(function(e) { e.preventDefault(); e.stopPropagation(); selectItem(this) }); + } + return ul; + }; + + function requestData(q) { + if (!options.matchCase) q = q.toLowerCase(); + var data = options.cacheLength ? loadFromCache(q) : null; + // recieve the cached data + if (data) { + receiveData(q, data); + // if an AJAX url has been supplied, try loading the data now + } else if( (typeof options.url == "string") && (options.url.length > 0) ){ + $.get(makeUrl(q), function(data) { + data = parseData(data); + addToCache(q, data); + receiveData(q, data); + }); + // if there's been no data found, remove the loading class + } else { + $input.removeClass(options.loadingClass); + } + }; + + function makeUrl(q) { + var url = options.url + "?q=" + encodeURI(q); + for (var i in options.extraParams) { + url += "&" + i + "=" + encodeURI(options.extraParams[i]); + } + return url; + }; + + function loadFromCache(q) { + if (!q) return null; + if (cache.data[q]) return cache.data[q]; + if (options.matchSubset) { + for (var i = q.length - 1; i >= options.minChars; i--) { + var qs = q.substr(0, i); + var c = cache.data[qs]; + if (c) { + var csub = []; + for (var j = 0; j < c.length; j++) { + var x = c[j]; + var x0 = x[0]; + if (matchSubset(x0, q)) { + csub[csub.length] = x; + } + } + return csub; + } + } + } + return null; + }; + + function matchSubset(s, sub) { + if (!options.matchCase) s = s.toLowerCase(); + var i = s.indexOf(sub); + if (i == -1) return false; + return i == 0 || options.matchContains; + }; + + this.flushCache = function() { + flushCache(); + }; + + this.setExtraParams = function(p) { + options.extraParams = p; + }; + + this.findValue = function(){ + var q = $input.val(); + + if (!options.matchCase) q = q.toLowerCase(); + var data = options.cacheLength ? loadFromCache(q) : null; + if (data) { + findValueCallback(q, data); + } else if( (typeof options.url == "string") && (options.url.length > 0) ){ + $.get(makeUrl(q), function(data) { + data = parseData(data) + addToCache(q, data); + findValueCallback(q, data); + }); + } else { + // no matches + findValueCallback(q, null); + } + } + + function findValueCallback(q, data){ + if (data) $input.removeClass(options.loadingClass); + + var num = (data) ? data.length : 0; + var li = null; + + for (var i=0; i < num; i++) { + var row = data[i]; + + if( row[0].toLowerCase() == q.toLowerCase() ){ + li = document.createElement("li"); + if (options.formatItem) { + li.innerHTML = options.formatItem(row, i, num); + li.selectValue = row[0]; + } else { + li.innerHTML = row[0]; + li.selectValue = row[0]; + } + var extra = null; + if( row.length > 1 ){ + extra = []; + for (var j=1; j < row.length; j++) { + extra[extra.length] = row[j]; + } + } + li.extra = extra; + } + } + + if( options.onFindValue ) setTimeout(function() { options.onFindValue(li) }, 1); + } + + function addToCache(q, data) { + if (!data || !q || !options.cacheLength) return; + if (!cache.length || cache.length > options.cacheLength) { + flushCache(); + cache.length++; + } else if (!cache[q]) { + cache.length++; + } + cache.data[q] = data; + }; + + function findPos(obj) { + var curleft = obj.offsetLeft || 0; + var curtop = obj.offsetTop || 0; + while (obj = obj.offsetParent) { + curleft += obj.offsetLeft + curtop += obj.offsetTop + } + return {x:curleft,y:curtop}; + } +} + +jQuery.fn.autocomplete = function(url, options, data) { + // Make sure options exists + options = options || {}; + // Set url as option + options.url = url; + // set some bulk local data + options.data = ((typeof data == "object") && (data.constructor == Array)) ? data : null; + + // Set default values for required options + options.inputClass = options.inputClass || "ac_input"; + options.resultsClass = options.resultsClass || "ac_results"; + options.lineSeparator = options.lineSeparator || "\n"; + options.cellSeparator = options.cellSeparator || "|"; + options.minChars = options.minChars || 1; + options.delay = options.delay || 400; + options.matchCase = options.matchCase || 0; + options.matchSubset = options.matchSubset || 1; + options.matchContains = options.matchContains || 0; + options.cacheLength = options.cacheLength || 1; + options.mustMatch = options.mustMatch || 0; + options.extraParams = options.extraParams || {}; + options.loadingClass = options.loadingClass || "ac_loading"; + options.selectFirst = options.selectFirst || false; + options.selectOnly = options.selectOnly || false; + options.maxItemsToShow = options.maxItemsToShow || -1; + options.autoFill = options.autoFill || false; + options.width = parseInt(options.width, 10) || 0; + + this.each(function() { + var input = this; + new jQuery.autocomplete(input, options); + }); + + // Don't break the chain + return this; +} + +jQuery.fn.autocompleteArray = function(data, options) { + return this.autocomplete(null, options, data); +} + +jQuery.fn.indexOf = function(e){ + for( var i=0; i + + + + Themeroller Demo + + + + + + + + + + + +

Accordion

+
+

Section 1

+
+

Mauris mauris ante, blandit et, ultrices a, suscipit eget, quam. Integer ut neque. Vivamus nisi metus, molestie vel, gravida in, condimentum sit amet, nunc. Nam a nibh. Donec suscipit eros. Nam mi. Proin viverra leo ut odio. Curabitur malesuada. Vestibulum a velit eu ante scelerisque vulputate.

+
+

Section 2

+
+

Sed non urna. Donec et ante. Phasellus eu ligula. Vestibulum sit amet purus. Vivamus hendrerit, dolor at aliquet laoreet, mauris turpis porttitor velit, faucibus interdum tellus libero ac justo. Vivamus non quam. In suscipit faucibus urna.

+
+

Section 3

+
+

Nam enim risus, molestie et, porta ac, aliquam ac, risus. Quisque lobortis. Phasellus pellentesque purus in massa. Aenean in pede. Phasellus ac libero ac tellus pellentesque semper. Sed ac felis. Sed commodo, magna quis lacinia ornare, quam ante aliquam nisi, eu iaculis leo purus venenatis dui.

+
    +
  • List item one
  • +
  • List item two
  • +
  • List item three
  • +
+
+
+ + +

Tabs

+
+ +
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+
Phasellus mattis tincidunt nibh. Cras orci urna, blandit id, pretium vel, aliquet ornare, felis. Maecenas scelerisque sem non nisl. Fusce sed lorem in enim dictum bibendum.
+
Nam dui erat, auctor a, dignissim quis, sollicitudin eu, felis. Pellentesque nisi urna, interdum eget, sagittis et, consequat vestibulum, lacus. Mauris porttitor ullamcorper augue.
+
+ + +

Dialog

+

Open Dialog

+ + +

Overlay and Shadow Classes

+
+

Lorem ipsum dolor sit amet, Nulla nec tortor. Donec id elit quis purus consectetur consequat.

Nam congue semper tellus. Sed erat dolor, dapibus sit amet, venenatis ornare, ultrices ut, nisi. Aliquam ante. Suspendisse scelerisque dui nec velit. Duis augue augue, gravida euismod, vulputate ac, facilisis id, sem. Morbi in orci.

Nulla purus lacus, pulvinar vel, malesuada ac, mattis nec, quam. Nam molestie scelerisque quam. Nullam feugiat cursus lacus.orem ipsum dolor sit amet, consectetur adipiscing elit. Donec libero risus, commodo vitae, pharetra mollis, posuere eu, pede. Nulla nec tortor. Donec id elit quis purus consectetur consequat.

Nam congue semper tellus. Sed erat dolor, dapibus sit amet, venenatis ornare, ultrices ut, nisi. Aliquam ante. Suspendisse scelerisque dui nec velit. Duis augue augue, gravida euismod, vulputate ac, facilisis id, sem. Morbi in orci. Nulla purus lacus, pulvinar vel, malesuada ac, mattis nec, quam. Nam molestie scelerisque quam.

Nullam feugiat cursus lacus.orem ipsum dolor sit amet, consectetur adipiscing elit. Donec libero risus, commodo vitae, pharetra mollis, posuere eu, pede. Nulla nec tortor. Donec id elit quis purus consectetur consequat. Nam congue semper tellus. Sed erat dolor, dapibus sit amet, venenatis ornare, ultrices ut, nisi. Aliquam ante.

Suspendisse scelerisque dui nec velit. Duis augue augue, gravida euismod, vulputate ac, facilisis id, sem. Morbi in orci. Nulla purus lacus, pulvinar vel, malesuada ac, mattis nec, quam. Nam molestie scelerisque quam. Nullam feugiat cursus lacus.orem ipsum dolor sit amet, consectetur adipiscing elit. Donec libero risus, commodo vitae, pharetra mollis, posuere eu, pede. Nulla nec tortor. Donec id elit quis purus consectetur consequat. Nam congue semper tellus. Sed erat dolor, dapibus sit amet, venenatis ornare, ultrices ut, nisi.

+ + +
+
+
+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+
+
+ +
+ + + +
+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+
+ + + +

Framework Icons (content color preview)

+
    + +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + +
  • +
  • +
  • +
  • +
  • +
+ + + + + +

Slider

+
+ + +

Datepicker

+
+ + +

Progressbar

+
+ + +

Highlight / Error

+
+
+

+ Hey! Sample ui-state-highlight style.

+
+
+
+
+
+

+ Alert: Sample ui-state-error style.

+
+
+ + + + + + + diff --git a/media/ext_js/jquery-ui-themeroller/jquery-1.3.1.js b/media/ext_js/jquery-ui-themeroller/jquery-1.3.1.js new file mode 100755 index 0000000..3a4badd --- /dev/null +++ b/media/ext_js/jquery-ui-themeroller/jquery-1.3.1.js @@ -0,0 +1,4241 @@ +/*! + * jQuery JavaScript Library v1.3.1 + * http://jquery.com/ + * + * Copyright (c) 2009 John Resig + * Dual licensed under the MIT and GPL licenses. + * http://docs.jquery.com/License + * + * Date: 2009-01-21 20:42:16 -0500 (Wed, 21 Jan 2009) + * Revision: 6158 + */ +(function(){ + +var + // Will speed up references to window, and allows munging its name. + window = this, + // Will speed up references to undefined, and allows munging its name. + undefined, + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + // Map over the $ in case of overwrite + _$ = window.$, + + jQuery = window.jQuery = window.$ = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context ); + }, + + // A simple way to check for HTML strings or ID strings + // (both of which we optimize for) + quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#([\w-]+)$/, + // Is it a simple selector + isSimple = /^.[^:#\[\.,]*$/; + +jQuery.fn = jQuery.prototype = { + init: function( selector, context ) { + // Make sure that a selection was provided + selector = selector || document; + + // Handle $(DOMElement) + if ( selector.nodeType ) { + this[0] = selector; + this.length = 1; + this.context = selector; + return this; + } + // Handle HTML strings + if ( typeof selector === "string" ) { + // Are we dealing with HTML string or an ID? + var match = quickExpr.exec( selector ); + + // Verify a match, and that no context was specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) + selector = jQuery.clean( [ match[1] ], context ); + + // HANDLE: $("#id") + else { + var elem = document.getElementById( match[3] ); + + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem && elem.id != match[3] ) + return jQuery().find( selector ); + + // Otherwise, we inject the element directly into the jQuery object + var ret = jQuery( elem || [] ); + ret.context = document; + ret.selector = selector; + return ret; + } + + // HANDLE: $(expr, [context]) + // (which is just equivalent to: $(content).find(expr) + } else + return jQuery( context ).find( selector ); + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) + return jQuery( document ).ready( selector ); + + // Make sure that old selector state is passed along + if ( selector.selector && selector.context ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return this.setArray(jQuery.makeArray(selector)); + }, + + // Start with an empty selector + selector: "", + + // The current version of jQuery being used + jquery: "1.3.1", + + // The number of elements contained in the matched element set + size: function() { + return this.length; + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num === undefined ? + + // Return a 'clean' array + jQuery.makeArray( this ) : + + // Return just the object + this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems, name, selector ) { + // Build a new jQuery matched element set + var ret = jQuery( elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + ret.context = this.context; + + if ( name === "find" ) + ret.selector = this.selector + (this.selector ? " " : "") + selector; + else if ( name ) + ret.selector = this.selector + "." + name + "(" + selector + ")"; + + // Return the newly-formed element set + return ret; + }, + + // Force the current matched set of elements to become + // the specified array of elements (destroying the stack in the process) + // You should use pushStack() in order to do this, but maintain the stack + setArray: function( elems ) { + // Resetting the length to 0, then using the native Array push + // is a super-fast way to populate an object with array-like properties + this.length = 0; + Array.prototype.push.apply( this, elems ); + + return this; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem && elem.jquery ? elem[0] : elem + , this ); + }, + + attr: function( name, value, type ) { + var options = name; + + // Look for the case where we're accessing a style value + if ( typeof name === "string" ) + if ( value === undefined ) + return this[0] && jQuery[ type || "attr" ]( this[0], name ); + + else { + options = {}; + options[ name ] = value; + } + + // Check to see if we're setting style values + return this.each(function(i){ + // Set all the styles + for ( name in options ) + jQuery.attr( + type ? + this.style : + this, + name, jQuery.prop( this, options[ name ], type, i, name ) + ); + }); + }, + + css: function( key, value ) { + // ignore negative width and height values + if ( (key == 'width' || key == 'height') && parseFloat(value) < 0 ) + value = undefined; + return this.attr( key, value, "curCSS" ); + }, + + text: function( text ) { + if ( typeof text !== "object" && text != null ) + return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); + + var ret = ""; + + jQuery.each( text || this, function(){ + jQuery.each( this.childNodes, function(){ + if ( this.nodeType != 8 ) + ret += this.nodeType != 1 ? + this.nodeValue : + jQuery.fn.text( [ this ] ); + }); + }); + + return ret; + }, + + wrapAll: function( html ) { + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).clone(); + + if ( this[0].parentNode ) + wrap.insertBefore( this[0] ); + + wrap.map(function(){ + var elem = this; + + while ( elem.firstChild ) + elem = elem.firstChild; + + return elem; + }).append(this); + } + + return this; + }, + + wrapInner: function( html ) { + return this.each(function(){ + jQuery( this ).contents().wrapAll( html ); + }); + }, + + wrap: function( html ) { + return this.each(function(){ + jQuery( this ).wrapAll( html ); + }); + }, + + append: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.appendChild( elem ); + }); + }, + + prepend: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.insertBefore( elem, this.firstChild ); + }); + }, + + before: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this ); + }); + }, + + after: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this.nextSibling ); + }); + }, + + end: function() { + return this.prevObject || jQuery( [] ); + }, + + // For internal use only. + // Behaves like an Array's .push method, not like a jQuery method. + push: [].push, + + find: function( selector ) { + if ( this.length === 1 && !/,/.test(selector) ) { + var ret = this.pushStack( [], "find", selector ); + ret.length = 0; + jQuery.find( selector, this[0], ret ); + return ret; + } else { + var elems = jQuery.map(this, function(elem){ + return jQuery.find( selector, elem ); + }); + + return this.pushStack( /[^+>] [^+>]/.test( selector ) ? + jQuery.unique( elems ) : + elems, "find", selector ); + } + }, + + clone: function( events ) { + // Do the clone + var ret = this.map(function(){ + if ( !jQuery.support.noCloneEvent && !jQuery.isXMLDoc(this) ) { + // IE copies events bound via attachEvent when + // using cloneNode. Calling detachEvent on the + // clone will also remove the events from the orignal + // In order to get around this, we use innerHTML. + // Unfortunately, this means some modifications to + // attributes in IE that are actually only stored + // as properties will not be copied (such as the + // the name attribute on an input). + var clone = this.cloneNode(true), + container = document.createElement("div"); + container.appendChild(clone); + return jQuery.clean([container.innerHTML])[0]; + } else + return this.cloneNode(true); + }); + + // Need to set the expando to null on the cloned set if it exists + // removeData doesn't work here, IE removes it from the original as well + // this is primarily for IE but the data expando shouldn't be copied over in any browser + var clone = ret.find("*").andSelf().each(function(){ + if ( this[ expando ] !== undefined ) + this[ expando ] = null; + }); + + // Copy the events from the original to the clone + if ( events === true ) + this.find("*").andSelf().each(function(i){ + if (this.nodeType == 3) + return; + var events = jQuery.data( this, "events" ); + + for ( var type in events ) + for ( var handler in events[ type ] ) + jQuery.event.add( clone[ i ], type, events[ type ][ handler ], events[ type ][ handler ].data ); + }); + + // Return the cloned set + return ret; + }, + + filter: function( selector ) { + return this.pushStack( + jQuery.isFunction( selector ) && + jQuery.grep(this, function(elem, i){ + return selector.call( elem, i ); + }) || + + jQuery.multiFilter( selector, jQuery.grep(this, function(elem){ + return elem.nodeType === 1; + }) ), "filter", selector ); + }, + + closest: function( selector ) { + var pos = jQuery.expr.match.POS.test( selector ) ? jQuery(selector) : null; + + return this.map(function(){ + var cur = this; + while ( cur && cur.ownerDocument ) { + if ( pos ? pos.index(cur) > -1 : jQuery(cur).is(selector) ) + return cur; + cur = cur.parentNode; + } + }); + }, + + not: function( selector ) { + if ( typeof selector === "string" ) + // test special case where just one selector is passed in + if ( isSimple.test( selector ) ) + return this.pushStack( jQuery.multiFilter( selector, this, true ), "not", selector ); + else + selector = jQuery.multiFilter( selector, this ); + + var isArrayLike = selector.length && selector[selector.length - 1] !== undefined && !selector.nodeType; + return this.filter(function() { + return isArrayLike ? jQuery.inArray( this, selector ) < 0 : this != selector; + }); + }, + + add: function( selector ) { + return this.pushStack( jQuery.unique( jQuery.merge( + this.get(), + typeof selector === "string" ? + jQuery( selector ) : + jQuery.makeArray( selector ) + ))); + }, + + is: function( selector ) { + return !!selector && jQuery.multiFilter( selector, this ).length > 0; + }, + + hasClass: function( selector ) { + return !!selector && this.is( "." + selector ); + }, + + val: function( value ) { + if ( value === undefined ) { + var elem = this[0]; + + if ( elem ) { + if( jQuery.nodeName( elem, 'option' ) ) + return (elem.attributes.value || {}).specified ? elem.value : elem.text; + + // We need to handle select boxes special + if ( jQuery.nodeName( elem, "select" ) ) { + var index = elem.selectedIndex, + values = [], + options = elem.options, + one = elem.type == "select-one"; + + // Nothing was selected + if ( index < 0 ) + return null; + + // Loop through all the selected options + for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) { + var option = options[ i ]; + + if ( option.selected ) { + // Get the specifc value for the option + value = jQuery(option).val(); + + // We don't need an array for one selects + if ( one ) + return value; + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + } + + // Everything else, we just grab the value + return (elem.value || "").replace(/\r/g, ""); + + } + + return undefined; + } + + if ( typeof value === "number" ) + value += ''; + + return this.each(function(){ + if ( this.nodeType != 1 ) + return; + + if ( jQuery.isArray(value) && /radio|checkbox/.test( this.type ) ) + this.checked = (jQuery.inArray(this.value, value) >= 0 || + jQuery.inArray(this.name, value) >= 0); + + else if ( jQuery.nodeName( this, "select" ) ) { + var values = jQuery.makeArray(value); + + jQuery( "option", this ).each(function(){ + this.selected = (jQuery.inArray( this.value, values ) >= 0 || + jQuery.inArray( this.text, values ) >= 0); + }); + + if ( !values.length ) + this.selectedIndex = -1; + + } else + this.value = value; + }); + }, + + html: function( value ) { + return value === undefined ? + (this[0] ? + this[0].innerHTML : + null) : + this.empty().append( value ); + }, + + replaceWith: function( value ) { + return this.after( value ).remove(); + }, + + eq: function( i ) { + return this.slice( i, +i + 1 ); + }, + + slice: function() { + return this.pushStack( Array.prototype.slice.apply( this, arguments ), + "slice", Array.prototype.slice.call(arguments).join(",") ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function(elem, i){ + return callback.call( elem, i, elem ); + })); + }, + + andSelf: function() { + return this.add( this.prevObject ); + }, + + domManip: function( args, table, callback ) { + if ( this[0] ) { + var fragment = (this[0].ownerDocument || this[0]).createDocumentFragment(), + scripts = jQuery.clean( args, (this[0].ownerDocument || this[0]), fragment ), + first = fragment.firstChild, + extra = this.length > 1 ? fragment.cloneNode(true) : fragment; + + if ( first ) + for ( var i = 0, l = this.length; i < l; i++ ) + callback.call( root(this[i], first), i > 0 ? extra.cloneNode(true) : fragment ); + + if ( scripts ) + jQuery.each( scripts, evalScript ); + } + + return this; + + function root( elem, cur ) { + return table && jQuery.nodeName(elem, "table") && jQuery.nodeName(cur, "tr") ? + (elem.getElementsByTagName("tbody")[0] || + elem.appendChild(elem.ownerDocument.createElement("tbody"))) : + elem; + } + } +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +function evalScript( i, elem ) { + if ( elem.src ) + jQuery.ajax({ + url: elem.src, + async: false, + dataType: "script" + }); + + else + jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" ); + + if ( elem.parentNode ) + elem.parentNode.removeChild( elem ); +} + +function now(){ + return +new Date; +} + +jQuery.extend = jQuery.fn.extend = function() { + // copy reference to target object + var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) + target = {}; + + // extend jQuery itself if only one argument is passed + if ( length == i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) + // Extend the base object + for ( var name in options ) { + var src = target[ name ], copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) + continue; + + // Recurse if we're merging object values + if ( deep && copy && typeof copy === "object" && !copy.nodeType ) + target[ name ] = jQuery.extend( deep, + // Never move original objects, clone them + src || ( copy.length != null ? [ ] : { } ) + , copy ); + + // Don't bring in undefined values + else if ( copy !== undefined ) + target[ name ] = copy; + + } + + // Return the modified object + return target; +}; + +// exclude the following css properties to add px +var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i, + // cache defaultView + defaultView = document.defaultView || {}, + toString = Object.prototype.toString; + +jQuery.extend({ + noConflict: function( deep ) { + window.$ = _$; + + if ( deep ) + window.jQuery = _jQuery; + + return jQuery; + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return toString.call(obj) === "[object Function]"; + }, + + isArray: function( obj ) { + return toString.call(obj) === "[object Array]"; + }, + + // check if an element is in a (or is an) XML document + isXMLDoc: function( elem ) { + return elem.nodeType === 9 && elem.documentElement.nodeName !== "HTML" || + !!elem.ownerDocument && jQuery.isXMLDoc( elem.ownerDocument ); + }, + + // Evalulates a script in a global context + globalEval: function( data ) { + data = jQuery.trim( data ); + + if ( data ) { + // Inspired by code by Andrea Giammarchi + // http://webreflection.blogspot.com/2007/08/global-scope-evaluation-and-dom.html + var head = document.getElementsByTagName("head")[0] || document.documentElement, + script = document.createElement("script"); + + script.type = "text/javascript"; + if ( jQuery.support.scriptEval ) + script.appendChild( document.createTextNode( data ) ); + else + script.text = data; + + // Use insertBefore instead of appendChild to circumvent an IE6 bug. + // This arises when a base node is used (#2709). + head.insertBefore( script, head.firstChild ); + head.removeChild( script ); + } + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase(); + }, + + // args is for internal usage only + each: function( object, callback, args ) { + var name, i = 0, length = object.length; + + if ( args ) { + if ( length === undefined ) { + for ( name in object ) + if ( callback.apply( object[ name ], args ) === false ) + break; + } else + for ( ; i < length; ) + if ( callback.apply( object[ i++ ], args ) === false ) + break; + + // A special, fast, case for the most common use of each + } else { + if ( length === undefined ) { + for ( name in object ) + if ( callback.call( object[ name ], name, object[ name ] ) === false ) + break; + } else + for ( var value = object[0]; + i < length && callback.call( value, i, value ) !== false; value = object[++i] ){} + } + + return object; + }, + + prop: function( elem, value, type, i, name ) { + // Handle executable functions + if ( jQuery.isFunction( value ) ) + value = value.call( elem, i ); + + // Handle passing in a number to a CSS property + return typeof value === "number" && type == "curCSS" && !exclude.test( name ) ? + value + "px" : + value; + }, + + className: { + // internal only, use addClass("class") + add: function( elem, classNames ) { + jQuery.each((classNames || "").split(/\s+/), function(i, className){ + if ( elem.nodeType == 1 && !jQuery.className.has( elem.className, className ) ) + elem.className += (elem.className ? " " : "") + className; + }); + }, + + // internal only, use removeClass("class") + remove: function( elem, classNames ) { + if (elem.nodeType == 1) + elem.className = classNames !== undefined ? + jQuery.grep(elem.className.split(/\s+/), function(className){ + return !jQuery.className.has( classNames, className ); + }).join(" ") : + ""; + }, + + // internal only, use hasClass("class") + has: function( elem, className ) { + return elem && jQuery.inArray( className, (elem.className || elem).toString().split(/\s+/) ) > -1; + } + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations + swap: function( elem, options, callback ) { + var old = {}; + // Remember the old values, and insert the new ones + for ( var name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + callback.call( elem ); + + // Revert the old values + for ( var name in options ) + elem.style[ name ] = old[ name ]; + }, + + css: function( elem, name, force ) { + if ( name == "width" || name == "height" ) { + var val, props = { position: "absolute", visibility: "hidden", display:"block" }, which = name == "width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ]; + + function getWH() { + val = name == "width" ? elem.offsetWidth : elem.offsetHeight; + var padding = 0, border = 0; + jQuery.each( which, function() { + padding += parseFloat(jQuery.curCSS( elem, "padding" + this, true)) || 0; + border += parseFloat(jQuery.curCSS( elem, "border" + this + "Width", true)) || 0; + }); + val -= Math.round(padding + border); + } + + if ( jQuery(elem).is(":visible") ) + getWH(); + else + jQuery.swap( elem, props, getWH ); + + return Math.max(0, val); + } + + return jQuery.curCSS( elem, name, force ); + }, + + curCSS: function( elem, name, force ) { + var ret, style = elem.style; + + // We need to handle opacity special in IE + if ( name == "opacity" && !jQuery.support.opacity ) { + ret = jQuery.attr( style, "opacity" ); + + return ret == "" ? + "1" : + ret; + } + + // Make sure we're using the right name for getting the float value + if ( name.match( /float/i ) ) + name = styleFloat; + + if ( !force && style && style[ name ] ) + ret = style[ name ]; + + else if ( defaultView.getComputedStyle ) { + + // Only "float" is needed here + if ( name.match( /float/i ) ) + name = "float"; + + name = name.replace( /([A-Z])/g, "-$1" ).toLowerCase(); + + var computedStyle = defaultView.getComputedStyle( elem, null ); + + if ( computedStyle ) + ret = computedStyle.getPropertyValue( name ); + + // We should always get a number back from opacity + if ( name == "opacity" && ret == "" ) + ret = "1"; + + } else if ( elem.currentStyle ) { + var camelCase = name.replace(/\-(\w)/g, function(all, letter){ + return letter.toUpperCase(); + }); + + ret = elem.currentStyle[ name ] || elem.currentStyle[ camelCase ]; + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + if ( !/^\d+(px)?$/i.test( ret ) && /^\d/.test( ret ) ) { + // Remember the original values + var left = style.left, rsLeft = elem.runtimeStyle.left; + + // Put in the new values to get a computed value out + elem.runtimeStyle.left = elem.currentStyle.left; + style.left = ret || 0; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + elem.runtimeStyle.left = rsLeft; + } + } + + return ret; + }, + + clean: function( elems, context, fragment ) { + context = context || document; + + // !context.createElement fails in IE with an error but returns typeof 'object' + if ( typeof context.createElement === "undefined" ) + context = context.ownerDocument || context[0] && context[0].ownerDocument || document; + + // If a single string is passed in and it's a single tag + // just do a createElement and skip the rest + if ( !fragment && elems.length === 1 && typeof elems[0] === "string" ) { + var match = /^<(\w+)\s*\/?>$/.exec(elems[0]); + if ( match ) + return [ context.createElement( match[1] ) ]; + } + + var ret = [], scripts = [], div = context.createElement("div"); + + jQuery.each(elems, function(i, elem){ + if ( typeof elem === "number" ) + elem += ''; + + if ( !elem ) + return; + + // Convert html string into DOM nodes + if ( typeof elem === "string" ) { + // Fix "XHTML"-style tags in all browsers + elem = elem.replace(/(<(\w+)[^>]*?)\/>/g, function(all, front, tag){ + return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area|embed)$/i) ? + all : + front + ">"; + }); + + // Trim whitespace, otherwise indexOf won't work as expected + var tags = jQuery.trim( elem ).toLowerCase(); + + var wrap = + // option or optgroup + !tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + tags.match(/^<(thead|tbody|tfoot|colg|cap)/) && + [ 1, "", "
" ] || + + !tags.indexOf("", "" ] || + + // matched above + (!tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + // IE can't serialize and