Move djblets sources to murano-dashboard repository.
Change-Id: Idb44f2b4111629139ae1f3afd5f490d5270e72e1 Fixes: bug/MRN-1045
|
@ -0,0 +1,12 @@
|
|||
build
|
||||
dist
|
||||
djblets/static/*
|
||||
Djblets.egg-info
|
||||
|
||||
.coverage
|
||||
|
||||
*.rej
|
||||
*.orig
|
||||
.*.sw*
|
||||
*.pyc
|
||||
.DS_Store
|
|
@ -0,0 +1,3 @@
|
|||
REVIEWBOARD_URL = "http://reviews.reviewboard.org"
|
||||
REPOSITORY = "Djblets"
|
||||
BRANCH = "master"
|
|
@ -0,0 +1,34 @@
|
|||
Lead Developers:
|
||||
|
||||
* Christian Hammond
|
||||
* David Trowbridge
|
||||
|
||||
|
||||
Contributors:
|
||||
|
||||
* Alexander Artemenko
|
||||
* Anthony Mok
|
||||
* Ben Hollis
|
||||
* Brad Taylor
|
||||
* Cory McWilliams
|
||||
* Dave Druska
|
||||
* Hongbin Lu
|
||||
* Hussain Bohra
|
||||
* Jesus Zambrano
|
||||
* Jim Chen
|
||||
* Kalil Amlani
|
||||
* Kevin Quinn
|
||||
* Lee Loucks
|
||||
* Micah Dowty
|
||||
* Niklas Hambuechen
|
||||
* Onkar Shinde
|
||||
* Paolo Borelli
|
||||
* Patrick Uiterwijk
|
||||
* Raja Venkataraman
|
||||
* Simon Wu
|
||||
* Stephen Gallagher
|
||||
* Steven MacLeod
|
||||
* Surya Nallu
|
||||
* Thilo-Alexander Ginkel
|
||||
* Vlad Filippov
|
||||
* Yazan Medanat
|
|
@ -0,0 +1,5 @@
|
|||
recursive-include contrib *
|
||||
recursive-include djblets *.txt *.html *.css *.js *.htc *.png *.jpg *.gif
|
||||
include AUTHORS
|
||||
include NEWS
|
||||
include ez_setup.py
|
|
@ -0,0 +1,76 @@
|
|||
Djblets is a set of utility classes and functions for web applications
|
||||
written using Django and Python.
|
||||
|
||||
This is part of the Review Board project. To report a bug, please use the
|
||||
Review Board bug tracker at http://www.reviewboard.org/bugs/
|
||||
|
||||
|
||||
Modules
|
||||
=======
|
||||
|
||||
auth
|
||||
----
|
||||
|
||||
Flexible forms for registration processing and other useful forms.
|
||||
|
||||
|
||||
datagrid
|
||||
--------
|
||||
|
||||
Customizable datagrids that represent paginated lists of database objects.
|
||||
Users can customize the list of columns they list, reorder them, and sort
|
||||
them. Datagrids take care of all the hard work behind the scenes.
|
||||
|
||||
|
||||
feedview
|
||||
--------
|
||||
|
||||
Views and templates for providing a simple RSS reader. Handles caching the
|
||||
feeds for quick access.
|
||||
|
||||
|
||||
gravatars
|
||||
---------
|
||||
|
||||
Template tags for inserting gravatars in a page.
|
||||
|
||||
|
||||
log
|
||||
---
|
||||
|
||||
Provides middleware that handles log initialization (with support for
|
||||
reopening the log file after log rotation). If enabled in settings,
|
||||
all pages can take a ?profiling=1 parameter that will log both code and SQL
|
||||
profiling information to a separate log file.
|
||||
|
||||
|
||||
siteconfig
|
||||
----------
|
||||
|
||||
A powerful module for offering dynamic settings configuration in a web UI.
|
||||
It wraps the Django settings module, stores serialized data in the database,
|
||||
and loads it into the in-memory settings module. With siteconfig, webapps
|
||||
can provide rich settings UIs without requiring that the adminsitrator
|
||||
modify their settings.py.
|
||||
|
||||
|
||||
testing
|
||||
-------
|
||||
|
||||
Helper classes for unit tests. Includes some small classes for testing basic
|
||||
template tags, and classes for easily integrating Selenium unit tests.
|
||||
|
||||
|
||||
util
|
||||
----
|
||||
|
||||
A collection of various utility functions, template tags, and more.
|
||||
|
||||
|
||||
webapi
|
||||
------
|
||||
|
||||
A rich framework for implementing RESTful APIs in a web application.
|
||||
Webapps can either provide a complete resource tree through the
|
||||
WebAPIResource classes, or custom, simplified API handlers using
|
||||
the basic WebAPIResponse classes.
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.core.management import call_command, setup_environ
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
scripts_dir = os.path.abspath(os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(scripts_dir, '..', '..')))
|
||||
|
||||
os.putenv('FORCE_BUILD_MEDIA', '1')
|
||||
|
||||
import djblets.settings
|
||||
setup_environ(djblets.settings)
|
||||
|
||||
ret = call_command('collectstatic', interactive=False, verbosity=2)
|
||||
sys.exit(ret)
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Performs a release of Review Board. This can only be run by the core
|
||||
# developers with release permissions.
|
||||
#
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from djblets import __version__, __version_info__, is_release
|
||||
|
||||
|
||||
PY_VERSIONS = ["2.5", "2.6", "2.7"]
|
||||
|
||||
LATEST_PY_VERSION = PY_VERSIONS[-1]
|
||||
|
||||
PACKAGE_NAME = 'Djblets'
|
||||
|
||||
RELEASES_URL = \
|
||||
'reviewboard.org:/var/www/downloads.reviewboard.org/' \
|
||||
'htdocs/releases/%s/%s.%s/' % (PACKAGE_NAME,
|
||||
__version_info__[0],
|
||||
__version_info__[1])
|
||||
|
||||
|
||||
built_files = []
|
||||
|
||||
|
||||
def execute(cmdline):
|
||||
print ">>> %s" % cmdline
|
||||
if os.system(cmdline) != 0:
|
||||
sys.stderr.write('!!! Error invoking command.\n')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_setup(target, pyver=LATEST_PY_VERSION):
|
||||
execute("python%s ./setup.py release %s" % (pyver, target))
|
||||
|
||||
|
||||
def clone_git_tree(git_dir):
|
||||
new_git_dir = tempfile.mkdtemp(prefix='djblets-release.')
|
||||
|
||||
os.chdir(new_git_dir)
|
||||
execute('git clone %s .' % git_dir)
|
||||
|
||||
return new_git_dir
|
||||
|
||||
|
||||
def build_targets():
|
||||
for pyver in PY_VERSIONS:
|
||||
run_setup("bdist_egg", pyver)
|
||||
built_files.append("dist/%s-%s-py%s.egg" %
|
||||
(PACKAGE_NAME, __version__, pyver))
|
||||
|
||||
run_setup("sdist")
|
||||
built_files.append("dist/%s-%s.tar.gz" %
|
||||
(PACKAGE_NAME, __version__))
|
||||
|
||||
|
||||
def build_news():
|
||||
def linkify_bugs(line):
|
||||
return re.sub(r'(Bug #(\d+))',
|
||||
r'<a href="http://www.reviewboard.org/bug/\2">\1</a>',
|
||||
line)
|
||||
|
||||
content = ""
|
||||
html_content = ""
|
||||
|
||||
saw_version = False
|
||||
in_list = False
|
||||
in_item = False
|
||||
|
||||
fp = open("NEWS", "r")
|
||||
|
||||
for line in fp.xreadlines():
|
||||
line = line.rstrip()
|
||||
|
||||
if line.startswith("version "):
|
||||
if saw_version:
|
||||
# We're done.
|
||||
break
|
||||
|
||||
saw_version = True
|
||||
elif line.startswith("\t* "):
|
||||
if in_item:
|
||||
html_content += "</li>\n"
|
||||
in_item = False
|
||||
|
||||
if in_list:
|
||||
html_content += "</ul>\n"
|
||||
|
||||
html_content += "<p><b>%s</b></p>\n" % line[3:]
|
||||
html_content += "<ul>\n"
|
||||
in_list = True
|
||||
elif line.startswith("\t\t* "):
|
||||
if not in_list:
|
||||
sys.stderr.write("*** Found a list item without a list!\n")
|
||||
continue
|
||||
|
||||
if in_item:
|
||||
html_content += "</li>\n"
|
||||
|
||||
html_content += " <li>%s" % linkify_bugs(line[4:])
|
||||
in_item = True
|
||||
elif line.startswith("\t\t "):
|
||||
if not in_item:
|
||||
sys.stderr.write("*** Found list item content without "
|
||||
"a list item!\n")
|
||||
continue
|
||||
|
||||
html_content += " " + linkify_bugs(line[4:])
|
||||
|
||||
content += line + "\n"
|
||||
|
||||
fp.close()
|
||||
|
||||
if in_item:
|
||||
html_content += "</li>\n"
|
||||
|
||||
if in_list:
|
||||
html_content += "</ul>\n"
|
||||
|
||||
content = content.rstrip()
|
||||
|
||||
filename = "dist/%s-%s.NEWS" % (PACKAGE_NAME, __version__)
|
||||
built_files.append(filename)
|
||||
fp = open(filename, "w")
|
||||
fp.write(content)
|
||||
fp.close()
|
||||
|
||||
filename = "dist/%s-%s.NEWS.html" % (PACKAGE_NAME, __version__)
|
||||
fp = open(filename, "w")
|
||||
fp.write(html_content)
|
||||
fp.close()
|
||||
|
||||
|
||||
def upload_files():
|
||||
execute("scp %s %s" % (" ".join(built_files), RELEASES_URL))
|
||||
|
||||
|
||||
def tag_release():
|
||||
execute("git tag release-%s" % __version__)
|
||||
|
||||
|
||||
def register_release():
|
||||
run_setup("register")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists("setup.py"):
|
||||
sys.stderr.write("This must be run from the root of the "
|
||||
"Djblets tree.\n")
|
||||
sys.exit(1)
|
||||
|
||||
if not is_release():
|
||||
sys.stderr.write('This has not been marked as a release in '
|
||||
'djblets/__init__.py\n')
|
||||
sys.exit(1)
|
||||
|
||||
cur_dir = os.getcwd()
|
||||
git_dir = clone_git_tree(cur_dir)
|
||||
|
||||
build_targets()
|
||||
build_news()
|
||||
upload_files()
|
||||
|
||||
os.chdir(cur_dir)
|
||||
shutil.rmtree(git_dir)
|
||||
|
||||
tag_release()
|
||||
register_release()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -0,0 +1,72 @@
|
|||
#
|
||||
# __init__.py -- Basic version and package information
|
||||
#
|
||||
# Copyright (c) 2007-2013 Christian Hammond
|
||||
# Copyright (c) 2007-2013 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
# The version of Djblets
|
||||
#
|
||||
# This is in the format of:
|
||||
#
|
||||
# (Major, Minor, Micro, alpha/beta/rc/final, Release Number, Released)
|
||||
#
|
||||
VERSION = (0, 7, 16, 'final', 0, True)
|
||||
|
||||
|
||||
def get_version_string():
|
||||
version = '%s.%s' % (VERSION[0], VERSION[1])
|
||||
|
||||
if VERSION[2]:
|
||||
version += ".%s" % VERSION[2]
|
||||
|
||||
if VERSION[3] != 'final':
|
||||
if VERSION[3] == 'rc':
|
||||
version += ' RC%s' % VERSION[4]
|
||||
else:
|
||||
version += ' %s %s' % (VERSION[3], VERSION[4])
|
||||
|
||||
if not is_release():
|
||||
version += " (dev)"
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def get_package_version():
|
||||
version = '%s.%s' % (VERSION[0], VERSION[1])
|
||||
|
||||
if VERSION[2]:
|
||||
version += ".%s" % VERSION[2]
|
||||
|
||||
if VERSION[3] != 'final':
|
||||
version += '%s%s' % (VERSION[3], VERSION[4])
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def is_release():
|
||||
return VERSION[5]
|
||||
|
||||
|
||||
__version_info__ = VERSION[:-1]
|
||||
__version__ = get_package_version()
|
|
@ -0,0 +1,98 @@
|
|||
#
|
||||
# forms.py -- Forms for authentication
|
||||
#
|
||||
# Copyright (c) 2007-2009 Christian Hammond
|
||||
# Copyright (c) 2007-2009 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.contrib import auth
|
||||
|
||||
from djblets.auth.util import get_user
|
||||
|
||||
|
||||
class RegistrationForm(forms.Form):
|
||||
"""Registration form that should be appropriate for most cases."""
|
||||
|
||||
username = forms.RegexField(r"^[a-zA-Z0-9_\-\.]*$",
|
||||
max_length=30,
|
||||
error_message='Only A-Z, 0-9, "_", "-", and "." allowed.')
|
||||
password1 = forms.CharField(min_length=5,
|
||||
max_length=30,
|
||||
widget=forms.PasswordInput)
|
||||
password2 = forms.CharField(widget=forms.PasswordInput)
|
||||
email = forms.EmailField()
|
||||
first_name = forms.CharField(max_length=30, required=False)
|
||||
last_name = forms.CharField(max_length=30, required=False)
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super(RegistrationForm, self).__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
def clean_password2(self):
|
||||
formdata = self.cleaned_data
|
||||
if 'password1' in formdata:
|
||||
if formdata['password1'] != formdata['password2']:
|
||||
raise forms.ValidationError('Passwords must match')
|
||||
return formdata['password2']
|
||||
|
||||
def save(self):
|
||||
if not self.errors:
|
||||
formdata = self.cleaned_data
|
||||
d = dict((k, v.encode("utf8")) for k, v in formdata.iteritems())
|
||||
try:
|
||||
user = auth.models.User.objects.create_user(d['username'],
|
||||
d['email'],
|
||||
d['password1'])
|
||||
user.first_name = d['first_name']
|
||||
user.last_name = d['last_name']
|
||||
user.save()
|
||||
return user
|
||||
except:
|
||||
# We check for duplicate users here instead of clean, since it's
|
||||
# possible that two users could race for a name.
|
||||
if get_user(username=d['username']):
|
||||
self.errors['username'] = \
|
||||
forms.util.ErrorList(["Sorry, this username is taken."])
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
class ChangePasswordForm(forms.Form):
|
||||
old_password = forms.CharField(widget=forms.PasswordInput)
|
||||
new_password1 = forms.CharField(min_length=5,
|
||||
max_length=30,
|
||||
widget=forms.PasswordInput)
|
||||
new_password2 = forms.CharField(widget=forms.PasswordInput)
|
||||
|
||||
def clean_new_password2(self):
|
||||
formdata = self.cleaned_data
|
||||
if 'new_password1' in formdata:
|
||||
if formdata['new_password1'] != formdata['new_password2']:
|
||||
raise forms.ValidationError('Passwords must match')
|
||||
return formdata['new_password2']
|
||||
|
||||
|
||||
class ChangeProfileForm(forms.Form):
|
||||
first_name = forms.CharField(max_length=30)
|
||||
last_name = forms.CharField(max_length=30)
|
||||
email = forms.EmailField()
|
|
@ -0,0 +1,89 @@
|
|||
#
|
||||
# util.py - Helper utilities for authentication
|
||||
#
|
||||
# Copyright (c) 2007-2009 Christian Hammond
|
||||
# Copyright (c) 2007-2009 David Trowbridge
|
||||
# Copyright (c) 2007 Micah Dowty
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django import forms
|
||||
from django.contrib import auth
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from djblets.util.dates import get_tz_aware_utcnow
|
||||
from djblets.util.decorators import simple_decorator
|
||||
|
||||
|
||||
@simple_decorator
|
||||
def login_required(view_func):
|
||||
"""Simplified version of auth.decorators.login_required,
|
||||
which works with our LOGIN_URL and removes the 'next'
|
||||
parameter which we don't need yet.
|
||||
"""
|
||||
def _checklogin(request, *args, **kwargs):
|
||||
if request.user.is_authenticated():
|
||||
return view_func(request, *args, **kwargs)
|
||||
else:
|
||||
return HttpResponseRedirect('%s?next_page=%s' % \
|
||||
(settings.LOGIN_URL, request.path))
|
||||
return _checklogin
|
||||
|
||||
def get_user(username):
|
||||
try:
|
||||
return auth.models.User.objects.get(username=username)
|
||||
except auth.models.User.DoesNotExist:
|
||||
return None
|
||||
|
||||
def internal_login(request, username, password):
|
||||
try:
|
||||
user = auth.authenticate(username=username, password=password)
|
||||
except:
|
||||
user = None
|
||||
if not user:
|
||||
return "Incorrect username or password."
|
||||
elif not user.is_active:
|
||||
return "This account is inactive."
|
||||
elif not request.session.test_cookie_worked():
|
||||
return "Cookies must be enabled."
|
||||
|
||||
auth.login(request, user)
|
||||
if request.session.test_cookie_worked():
|
||||
request.session.delete_test_cookie()
|
||||
|
||||
if settings.USE_TZ:
|
||||
user.last_login = get_tz_aware_utcnow()
|
||||
else:
|
||||
user.last_login = datetime.now()
|
||||
|
||||
user.save()
|
||||
|
||||
def validate_test_cookie(form, request):
|
||||
if not request.session.test_cookie_worked():
|
||||
form.errors['submit'] = forms.util.ErrorList(["Cookies must be enabled."])
|
||||
|
||||
def validate_old_password(form, user, field_name='password'):
|
||||
if not form.errors.get(field_name) and \
|
||||
not user.check_password(form.data.get(field_name)):
|
||||
form.errors[field_name] = forms.util.ErrorList(["Incorrect password."])
|
|
@ -0,0 +1,153 @@
|
|||
#
|
||||
# views.py -- Views for the authentication app
|
||||
#
|
||||
# Copyright (c) 2007-2009 Christian Hammond
|
||||
# Copyright (c) 2007-2009 David Trowbridge
|
||||
# Copyright (C) 2007 Micah Dowty
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from djblets.auth.forms import RegistrationForm, ChangePasswordForm, \
|
||||
ChangeProfileForm
|
||||
from djblets.auth.util import internal_login, validate_test_cookie, \
|
||||
validate_old_password
|
||||
|
||||
|
||||
###########################
|
||||
# User Login #
|
||||
###########################
|
||||
|
||||
def login(request, next_page, template_name="accounts/login.html",
|
||||
extra_context={}):
|
||||
"""Simple login form view which doesn't rely on Django's current
|
||||
inflexible oldforms-based auth view.
|
||||
"""
|
||||
if request.POST:
|
||||
error = internal_login(request,
|
||||
request.POST.get('username'),
|
||||
request.POST.get('password'))
|
||||
if not error:
|
||||
return HttpResponseRedirect(request.REQUEST.get("next_page",
|
||||
next_page))
|
||||
else:
|
||||
error = None
|
||||
|
||||
request.session.set_test_cookie()
|
||||
context = RequestContext(request, {
|
||||
'error' : error,
|
||||
'login_url' : settings.LOGIN_URL,
|
||||
'next_page' : request.REQUEST.get("next_page", next_page)
|
||||
})
|
||||
|
||||
if extra_context is not None:
|
||||
# Copied from Django's generic views.
|
||||
# The reason we don't simply call context.update(extra_context) is
|
||||
# that there are times when you may want to pass a function in the
|
||||
# URL handler that you want called at the time of render, rather than
|
||||
# being forced to expose it as a template tag or calling it upon
|
||||
# URL handler creation (which may be too early and only happens once).
|
||||
for key, value in extra_context.items():
|
||||
if callable(value):
|
||||
context[key] = value()
|
||||
else:
|
||||
context[key] = value
|
||||
|
||||
return render_to_response(template_name, context)
|
||||
|
||||
###########################
|
||||
# User Registration #
|
||||
###########################
|
||||
|
||||
def register(request, next_page, form_class=RegistrationForm,
|
||||
extra_context={},
|
||||
template_name="accounts/register.html"):
|
||||
if request.POST:
|
||||
form = form_class(data=request.POST, request=request)
|
||||
form.full_clean()
|
||||
validate_test_cookie(form, request)
|
||||
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
if user:
|
||||
# XXX Compatibility with Django 0.96 and 1.0
|
||||
formdata = getattr(form, "cleaned_data",
|
||||
getattr(form, "clean_data", None))
|
||||
user = auth.authenticate(username=formdata['username'],
|
||||
password=formdata['password1'])
|
||||
assert user
|
||||
auth.login(request, user)
|
||||
try:
|
||||
request.session.delete_test_cookie()
|
||||
except KeyError:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
return HttpResponseRedirect(next_page)
|
||||
else:
|
||||
form = form_class(request=request)
|
||||
|
||||
request.session.set_test_cookie()
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
}
|
||||
context.update(extra_context)
|
||||
|
||||
return render_to_response(template_name, RequestContext(request, context))
|
||||
|
||||
###########################
|
||||
# Profile Editing #
|
||||
###########################
|
||||
|
||||
def do_change_password(request):
|
||||
form = ChangePasswordForm(request.POST)
|
||||
form.full_clean()
|
||||
validate_old_password(form, request.user, 'old_password')
|
||||
if not form.errors:
|
||||
# XXX Compatibility with Django 0.96 and 1.0
|
||||
formdata = getattr(form, "cleaned_data",
|
||||
getattr(form, "clean_data", None))
|
||||
|
||||
request.user.set_password(formdata['new_password1'])
|
||||
request.user.save()
|
||||
request.user.message_set.create(message="Your password was changed successfully.")
|
||||
return form
|
||||
|
||||
def do_change_profile(request):
|
||||
form = ChangeProfileForm(request.POST)
|
||||
form.full_clean()
|
||||
if not form.errors:
|
||||
# XXX Compatibility with Django 0.96 and 1.0
|
||||
formdata = getattr(form, "cleaned_data",
|
||||
getattr(form, "clean_data", None))
|
||||
|
||||
for key, value in formdata.items():
|
||||
setattr(request.user, key, value)
|
||||
request.user.save()
|
||||
request.user.message_set.create(message="Your profile was updated successfully.")
|
||||
return form
|
|
@ -0,0 +1,823 @@
|
|||
#
|
||||
# grids.py -- Basic definitions for datagrids
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
# Copyright (c) 2008-2009 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
import urllib
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import SiteProfileNotAvailable
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.paginator import InvalidPage, QuerySetPaginator
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext, Context
|
||||
from django.template.defaultfilters import date, timesince
|
||||
from django.template.loader import render_to_string, get_template
|
||||
from django.utils.cache import patch_cache_control
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext as _
|
||||
import pytz
|
||||
|
||||
|
||||
class Column(object):
|
||||
"""
|
||||
A column in a data grid.
|
||||
|
||||
The column is the primary component of the data grid. It is used to
|
||||
display not only the column header but the HTML for the cell as well.
|
||||
|
||||
Columns can be tied to database fields and can be used for sorting.
|
||||
Not all columns have to allow for this, though.
|
||||
|
||||
Columns can have an image, text, or both in the column header. The
|
||||
contents of the cells can be instructed to link to the object on the
|
||||
row or the data in the cell.
|
||||
|
||||
If a Column defines an image_class, then it will be assumed that the
|
||||
class represents an icon, perhaps as part of a spritesheet, and will
|
||||
display it in a <div>. An image_url cannot also be defined.
|
||||
"""
|
||||
SORT_DESCENDING = 0
|
||||
SORT_ASCENDING = 1
|
||||
|
||||
def __init__(self, label=None, detailed_label=None,
|
||||
field_name=None, db_field=None,
|
||||
image_url=None, image_class=None, image_width=None,
|
||||
image_height=None, image_alt="", shrink=False, expand=False,
|
||||
sortable=False,
|
||||
default_sort_dir=SORT_DESCENDING, link=False,
|
||||
link_func=None, cell_clickable=False, css_class=""):
|
||||
assert not (image_class and image_url)
|
||||
|
||||
self.id = None
|
||||
self.datagrid = None
|
||||
self.field_name = field_name
|
||||
self.db_field = db_field or field_name
|
||||
self.label = label
|
||||
self.detailed_label = detailed_label or self.label
|
||||
self.image_url = image_url
|
||||
self.image_class = image_class
|
||||
self.image_width = image_width
|
||||
self.image_height = image_height
|
||||
self.image_alt = image_alt
|
||||
self.shrink = shrink
|
||||
self.expand = expand
|
||||
self.sortable = sortable
|
||||
self.default_sort_dir = default_sort_dir
|
||||
self.cell_clickable = False
|
||||
self.link = link
|
||||
self.link_func = link_func or \
|
||||
(lambda x, y: self.datagrid.link_to_object(x, y))
|
||||
self.css_class = css_class
|
||||
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
# State
|
||||
self.active = False
|
||||
self.last = False
|
||||
self.width = 0
|
||||
self.data_cache = {}
|
||||
self.cell_render_cache = {}
|
||||
|
||||
def get_toggle_url(self):
|
||||
"""
|
||||
Returns the URL of the current page with this column's visibility
|
||||
toggled.
|
||||
"""
|
||||
columns = [column.id for column in self.datagrid.columns]
|
||||
|
||||
if self.active:
|
||||
try:
|
||||
columns.remove(self.id)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
columns.append(self.id)
|
||||
|
||||
return "?%scolumns=%s" % (self.get_url_params_except("columns"),
|
||||
",".join(columns))
|
||||
toggle_url = property(get_toggle_url)
|
||||
|
||||
def get_header(self):
|
||||
"""
|
||||
Displays a sortable column header.
|
||||
|
||||
The column header will include the current sort indicator, if it
|
||||
belongs in the sort list. It will also be made clickable in order
|
||||
to modify the sort order appropriately, if sortable.
|
||||
"""
|
||||
in_sort = False
|
||||
sort_direction = self.SORT_DESCENDING
|
||||
sort_primary = False
|
||||
sort_url = ""
|
||||
unsort_url = ""
|
||||
|
||||
if self.sortable:
|
||||
sort_list = list(self.datagrid.sort_list)
|
||||
|
||||
if sort_list:
|
||||
rev_column_id = "-%s" % self.id
|
||||
new_column_id = self.id
|
||||
cur_column_id = ""
|
||||
|
||||
if self.id in sort_list:
|
||||
# This column is currently being sorted in
|
||||
# ascending order.
|
||||
sort_direction = self.SORT_ASCENDING
|
||||
cur_column_id = self.id
|
||||
new_column_id = rev_column_id
|
||||
elif rev_column_id in sort_list:
|
||||
# This column is currently being sorted in
|
||||
# descending order.
|
||||
sort_direction = self.SORT_DESCENDING
|
||||
cur_column_id = rev_column_id
|
||||
new_column_id = self.id
|
||||
|
||||
if cur_column_id:
|
||||
in_sort = True
|
||||
sort_primary = (sort_list[0] == cur_column_id)
|
||||
|
||||
if not sort_primary:
|
||||
# If this is not the primary column, we want to keep
|
||||
# the sort order intact.
|
||||
new_column_id = cur_column_id
|
||||
|
||||
# Remove this column from the current location in the list
|
||||
# so we can move it to the front of the list.
|
||||
sort_list.remove(cur_column_id)
|
||||
|
||||
# Insert the column name into the beginning of the sort list.
|
||||
sort_list.insert(0, new_column_id)
|
||||
else:
|
||||
# There's no sort list to begin with. Make this column
|
||||
# the only entry.
|
||||
sort_list = [self.id]
|
||||
|
||||
# We can only support two entries in the sort list, so truncate
|
||||
# this.
|
||||
del(sort_list[2:])
|
||||
|
||||
url_prefix = "?%ssort=" % self.get_url_params_except("sort",
|
||||
"datagrid-id",
|
||||
"gridonly",
|
||||
"columns")
|
||||
unsort_url = url_prefix + ','.join(sort_list[1:])
|
||||
sort_url = url_prefix + ','.join(sort_list)
|
||||
|
||||
if not self.datagrid.column_header_template_obj:
|
||||
self.datagrid.column_header_template_obj = \
|
||||
get_template(self.datagrid.column_header_template)
|
||||
|
||||
ctx = Context({
|
||||
'column': self,
|
||||
'in_sort': in_sort,
|
||||
'sort_ascending': sort_direction == self.SORT_ASCENDING,
|
||||
'sort_primary': sort_primary,
|
||||
'sort_url': sort_url,
|
||||
'unsort_url': unsort_url,
|
||||
})
|
||||
|
||||
return mark_safe(self.datagrid.column_header_template_obj.render(ctx))
|
||||
header = property(get_header)
|
||||
|
||||
def get_url_params_except(self, *params):
|
||||
"""
|
||||
Utility function to return a string containing URL parameters to
|
||||
this page with the specified parameter filtered out.
|
||||
"""
|
||||
result = urllib.urlencode([
|
||||
(key, value)
|
||||
for key, value in self.datagrid.request.GET.items()
|
||||
if key not in params
|
||||
])
|
||||
return result + '&'
|
||||
|
||||
def collect_objects(self, object_list):
|
||||
"""Iterates through the objects and builds a cache of data to display.
|
||||
|
||||
This optimizes the fetching of data in the grid by grabbing all the
|
||||
IDs of related objects that will be queried for rendering, loading
|
||||
them all at once, and populating the cache.
|
||||
"""
|
||||
id_field = '%s_id' % self.field_name
|
||||
ids = set()
|
||||
model = None
|
||||
|
||||
for obj in object_list:
|
||||
if not hasattr(obj, id_field):
|
||||
# This isn't the field type you're looking for.
|
||||
return
|
||||
|
||||
ids.add(getattr(obj, id_field))
|
||||
|
||||
if not model:
|
||||
field = getattr(obj.__class__, self.field_name).field
|
||||
|
||||
try:
|
||||
model = field.rel.to
|
||||
except AttributeError:
|
||||
# No idea what this is. Bail.
|
||||
return
|
||||
|
||||
if model:
|
||||
for obj in model.objects.filter(pk__in=ids):
|
||||
self.data_cache[obj.pk] = obj
|
||||
|
||||
def render_cell(self, obj, render_context):
|
||||
"""
|
||||
Renders the table cell containing column data.
|
||||
"""
|
||||
rendered_data = self.render_data(obj)
|
||||
url = ''
|
||||
css_class = ''
|
||||
|
||||
if self.link:
|
||||
try:
|
||||
url = self.link_func(obj, rendered_data)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if self.css_class:
|
||||
if callable(self.css_class):
|
||||
css_class = self.css_class(obj)
|
||||
else:
|
||||
css_class = self.css_class
|
||||
|
||||
key = "%s:%s:%s:%s" % (self.last, rendered_data, url, css_class)
|
||||
|
||||
if key not in self.cell_render_cache:
|
||||
if not self.datagrid.cell_template_obj:
|
||||
self.datagrid.cell_template_obj = \
|
||||
get_template(self.datagrid.cell_template)
|
||||
|
||||
if not self.datagrid.cell_template_obj:
|
||||
logging.error("Unable to load template '%s' for datagrid "
|
||||
"cell. This may be an installation issue." %
|
||||
self.datagrid.cell_template,
|
||||
extra={
|
||||
'request': self.datagrid.request,
|
||||
})
|
||||
|
||||
ctx = Context(render_context)
|
||||
ctx.update({
|
||||
'column': self,
|
||||
'css_class': css_class,
|
||||
'url': url,
|
||||
'data': mark_safe(rendered_data)
|
||||
})
|
||||
|
||||
self.cell_render_cache[key] = \
|
||||
mark_safe(self.datagrid.cell_template_obj.render(ctx))
|
||||
|
||||
return self.cell_render_cache[key]
|
||||
|
||||
def render_data(self, obj):
|
||||
"""
|
||||
Renders the column data to a string. This may contain HTML.
|
||||
"""
|
||||
id_field = '%s_id' % self.field_name
|
||||
|
||||
# Look for this directly so that we don't end up fetching the
|
||||
# data for the object.
|
||||
if id_field in obj.__dict__:
|
||||
pk = obj.__dict__[id_field]
|
||||
|
||||
if pk in self.data_cache:
|
||||
return self.data_cache[pk]
|
||||
else:
|
||||
value = getattr(obj, self.field_name)
|
||||
self.data_cache[pk] = escape(value)
|
||||
return value
|
||||
else:
|
||||
# Follow . separators like in the django template library
|
||||
value = obj
|
||||
for field_name in filter(None, self.field_name.split('.')):
|
||||
value = getattr(value, field_name)
|
||||
|
||||
if callable(value):
|
||||
value = value()
|
||||
|
||||
return escape(value)
|
||||
|
||||
def augment_queryset(self, queryset):
|
||||
"""Augments a queryset with new queries.
|
||||
|
||||
Subclasses can override this to extend the queryset to provide
|
||||
additional information, usually using queryset.extra(). This must
|
||||
return a queryset based on the original queryset.
|
||||
|
||||
This should not restrict the query in any way, or the datagrid may
|
||||
not operate properly. It must only add additional data to the
|
||||
queryset.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
|
||||
class DateTimeColumn(Column):
|
||||
"""
|
||||
A column that renders a date or time.
|
||||
"""
|
||||
def __init__(self, label, format=None, sortable=True,
|
||||
timezone=pytz.utc, *args, **kwargs):
|
||||
Column.__init__(self, label, sortable=sortable, *args, **kwargs)
|
||||
self.format = format
|
||||
self.timezone = timezone
|
||||
|
||||
def render_data(self, obj):
|
||||
# If the datetime object is tz aware, conver it to local time
|
||||
datetime = getattr(obj, self.field_name)
|
||||
if settings.USE_TZ:
|
||||
datetime = pytz.utc.normalize(datetime).\
|
||||
astimezone(self.timezone)
|
||||
|
||||
return date(datetime, self.format)
|
||||
|
||||
|
||||
class DateTimeSinceColumn(Column):
|
||||
"""
|
||||
A column that renders a date or time relative to now.
|
||||
"""
|
||||
def __init__(self, label, sortable=True, timezone=pytz.utc,
|
||||
*args, **kwargs):
|
||||
Column.__init__(self, label, sortable=sortable, *args, **kwargs)
|
||||
|
||||
def render_data(self, obj):
|
||||
return _("%s ago") % timesince(getattr(obj, self.field_name))
|
||||
|
||||
|
||||
class DataGrid(object):
|
||||
"""
|
||||
A representation of a list of objects, sorted and organized by
|
||||
columns. The sort order and column lists can be customized. allowing
|
||||
users to view this data however they prefer.
|
||||
|
||||
This is meant to be subclassed for specific uses. The subclasses are
|
||||
responsible for defining one or more column types. It can also set
|
||||
one or more of the following optional variables:
|
||||
|
||||
* 'title': The title of the grid.
|
||||
* 'profile_sort_field': The variable name in the user profile
|
||||
where the sort order can be loaded and
|
||||
saved.
|
||||
* 'profile_columns_field": The variable name in the user profile
|
||||
where the columns list can be loaded and
|
||||
saved.
|
||||
* 'paginate_by': The number of items to show on each page
|
||||
of the grid. The default is 50.
|
||||
* 'paginate_orphans': If this number of objects or fewer are
|
||||
on the last page, it will be rolled into
|
||||
the previous page. The default is 3.
|
||||
* 'page': The page to display. If this is not
|
||||
specified, the 'page' variable passed
|
||||
in the URL will be used, or 1 if that is
|
||||
not specified.
|
||||
* 'listview_template': The template used to render the list view.
|
||||
The default is 'datagrid/listview.html'
|
||||
* 'column_header_template': The template used to render each column
|
||||
header. The default is
|
||||
'datagrid/column_header.html'
|
||||
* 'cell_template': The template used to render a cell of
|
||||
data. The default is 'datagrid/cell.html'
|
||||
* 'optimize_sorts': Whether or not to optimize queries when
|
||||
using multiple sorts. This can offer a
|
||||
speed improvement, but may need to be
|
||||
turned off for more advanced querysets
|
||||
(such as when using extra()).
|
||||
The default is True.
|
||||
"""
|
||||
def __init__(self, request, queryset=None, title="", extra_context={},
|
||||
optimize_sorts=True):
|
||||
self.request = request
|
||||
self.queryset = queryset
|
||||
self.rows = []
|
||||
self.columns = []
|
||||
self.all_columns = []
|
||||
self.db_field_map = {}
|
||||
self.id_list = []
|
||||
self.paginator = None
|
||||
self.page = None
|
||||
self.sort_list = None
|
||||
self.state_loaded = False
|
||||
self.page_num = 0
|
||||
self.id = None
|
||||
self.extra_context = dict(extra_context)
|
||||
self.optimize_sorts = optimize_sorts
|
||||
self.cell_template_obj = None
|
||||
self.column_header_template_obj = None
|
||||
|
||||
if not hasattr(request, "datagrid_count"):
|
||||
request.datagrid_count = 0
|
||||
|
||||
self.id = "datagrid-%s" % request.datagrid_count
|
||||
request.datagrid_count += 1
|
||||
|
||||
# Customizable variables
|
||||
self.title = title
|
||||
self.profile_sort_field = None
|
||||
self.profile_columns_field = None
|
||||
self.paginate_by = 50
|
||||
self.paginate_orphans = 3
|
||||
self.listview_template = 'datagrid/listview.html'
|
||||
self.column_header_template = 'datagrid/column_header.html'
|
||||
self.cell_template = 'datagrid/cell.html'
|
||||
|
||||
for attr in dir(self):
|
||||
column = getattr(self, attr)
|
||||
if isinstance(column, Column):
|
||||
self.all_columns.append(column)
|
||||
column.datagrid = self
|
||||
column.id = attr
|
||||
|
||||
# Reset the column.
|
||||
column.reset()
|
||||
|
||||
if not column.field_name:
|
||||
column.field_name = column.id
|
||||
|
||||
if not column.db_field:
|
||||
column.db_field = column.field_name
|
||||
|
||||
self.db_field_map[column.id] = column.db_field
|
||||
|
||||
self.all_columns.sort(key=lambda x: x.label)
|
||||
|
||||
def load_state(self, render_context=None):
|
||||
"""
|
||||
Loads the state of the datagrid.
|
||||
|
||||
This will retrieve the user-specified or previously stored
|
||||
sorting order and columns list, as well as any state a subclass
|
||||
may need.
|
||||
"""
|
||||
if self.state_loaded:
|
||||
return
|
||||
|
||||
profile_sort_list = None
|
||||
profile_columns_list = None
|
||||
profile = None
|
||||
profile_dirty = False
|
||||
|
||||
# Get the saved settings for this grid in the profile. These will
|
||||
# work as defaults and allow us to determine if we need to save
|
||||
# the profile.
|
||||
if self.request.user.is_authenticated():
|
||||
try:
|
||||
profile = self.request.user.get_profile()
|
||||
|
||||
if self.profile_sort_field:
|
||||
profile_sort_list = \
|
||||
getattr(profile, self.profile_sort_field, None)
|
||||
|
||||
if self.profile_columns_field:
|
||||
profile_columns_list = \
|
||||
getattr(profile, self.profile_columns_field, None)
|
||||
except SiteProfileNotAvailable:
|
||||
pass
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# Figure out the columns we're going to display
|
||||
# We're also going to calculate the column widths based on the
|
||||
# shrink and expand values.
|
||||
colnames_str = self.request.GET.get('columns', profile_columns_list)
|
||||
|
||||
if colnames_str:
|
||||
colnames = colnames_str.split(',')
|
||||
else:
|
||||
colnames = self.default_columns
|
||||
colnames_str = ",".join(colnames)
|
||||
|
||||
expand_columns = []
|
||||
normal_columns = []
|
||||
|
||||
for colname in colnames:
|
||||
try:
|
||||
column = getattr(self, colname)
|
||||
except AttributeError:
|
||||
# The user specified a column that doesn't exist. Skip it.
|
||||
continue
|
||||
|
||||
self.columns.append(column)
|
||||
column.active = True
|
||||
|
||||
if column.expand:
|
||||
# This column is requesting all remaining space. Save it for
|
||||
# later so we can tell how much to give it. Each expanded
|
||||
# column will count as two normal columns when calculating
|
||||
# the normal sized columns.
|
||||
expand_columns.append(column)
|
||||
elif column.shrink:
|
||||
# Make this as small as possible.
|
||||
column.width = 0
|
||||
else:
|
||||
# We'll divide the column widths equally after we've built
|
||||
# up the lists of expanded and normal sized columns.
|
||||
normal_columns.append(column)
|
||||
|
||||
self.columns[-1].last = True
|
||||
|
||||
# Try to figure out the column widths for each column.
|
||||
# We'll start with the normal sized columns.
|
||||
total_pct = 100
|
||||
|
||||
# Each expanded column counts as two normal columns.
|
||||
normal_column_width = total_pct / (len(self.columns) +
|
||||
len(expand_columns))
|
||||
|
||||
for column in normal_columns:
|
||||
column.width = normal_column_width
|
||||
total_pct -= normal_column_width
|
||||
|
||||
if len(expand_columns) > 0:
|
||||
expanded_column_width = total_pct / len(expand_columns)
|
||||
else:
|
||||
expanded_column_width = 0
|
||||
|
||||
for column in expand_columns:
|
||||
column.width = expanded_column_width
|
||||
|
||||
|
||||
# Now get the sorting order for the columns.
|
||||
sort_str = self.request.GET.get('sort', profile_sort_list)
|
||||
|
||||
if sort_str:
|
||||
self.sort_list = sort_str.split(',')
|
||||
else:
|
||||
self.sort_list = self.default_sort
|
||||
sort_str = ",".join(self.sort_list)
|
||||
|
||||
|
||||
# A subclass might have some work to do for loading and saving
|
||||
# as well.
|
||||
if self.load_extra_state(profile):
|
||||
profile_dirty = True
|
||||
|
||||
|
||||
# Now that we have all that, figure out if we need to save new
|
||||
# settings back to the profile.
|
||||
if profile:
|
||||
if self.profile_columns_field and \
|
||||
colnames_str != profile_columns_list:
|
||||
setattr(profile, self.profile_columns_field, colnames_str)
|
||||
profile_dirty = True
|
||||
|
||||
if self.profile_sort_field and sort_str != profile_sort_list:
|
||||
setattr(profile, self.profile_sort_field, sort_str)
|
||||
profile_dirty = True
|
||||
|
||||
if profile_dirty:
|
||||
profile.save()
|
||||
|
||||
self.state_loaded = True
|
||||
|
||||
# Fetch the list of objects and have it ready.
|
||||
self.precompute_objects(render_context)
|
||||
|
||||
def load_extra_state(self, profile):
|
||||
"""
|
||||
Loads any extra state needed for this grid.
|
||||
|
||||
This is used by subclasses that may have additional data to load
|
||||
and save. This should return True if any profile-stored state has
|
||||
changed, or False otherwise.
|
||||
"""
|
||||
return False
|
||||
|
||||
def precompute_objects(self, render_context=None):
|
||||
"""
|
||||
Builds the queryset and stores the list of objects for use in
|
||||
rendering the datagrid.
|
||||
"""
|
||||
query = self.queryset
|
||||
use_select_related = False
|
||||
|
||||
# Generate the actual list of fields we'll be sorting by
|
||||
sort_list = []
|
||||
for sort_item in self.sort_list:
|
||||
if sort_item[0] == "-":
|
||||
base_sort_item = sort_item[1:]
|
||||
prefix = "-"
|
||||
else:
|
||||
base_sort_item = sort_item
|
||||
prefix = ""
|
||||
|
||||
if sort_item and base_sort_item in self.db_field_map:
|
||||
db_field = self.db_field_map[base_sort_item]
|
||||
sort_list.append(prefix + db_field)
|
||||
|
||||
# Lookups spanning tables require that we query from those
|
||||
# tables. In order to keep things simple, we'll just use
|
||||
# select_related so that we don't have to figure out the
|
||||
# table relationships. We only do this if we have a lookup
|
||||
# spanning tables.
|
||||
if '.' in db_field:
|
||||
use_select_related = True
|
||||
|
||||
if sort_list:
|
||||
query = query.order_by(*sort_list)
|
||||
|
||||
self.paginator = QuerySetPaginator(query.distinct(), self.paginate_by,
|
||||
self.paginate_orphans)
|
||||
|
||||
page_num = self.request.GET.get('page', 1)
|
||||
|
||||
# Accept either "last" or a valid page number.
|
||||
if page_num == "last":
|
||||
page_num = self.paginator.num_pages
|
||||
|
||||
try:
|
||||
self.page = self.paginator.page(page_num)
|
||||
except InvalidPage:
|
||||
raise Http404
|
||||
|
||||
self.id_list = []
|
||||
|
||||
if self.optimize_sorts and len(sort_list) > 0:
|
||||
# This can be slow when sorting by multiple columns. If we
|
||||
# have multiple items in the sort list, we'll request just the
|
||||
# IDs and then fetch the actual details from that.
|
||||
self.id_list = list(self.page.object_list.values_list(
|
||||
'pk', flat=True))
|
||||
|
||||
# Make sure to unset the order. We can't meaningfully order these
|
||||
# results in the query, as what we really want is to keep it in
|
||||
# the order specified in id_list, and we certainly don't want
|
||||
# the database to do any special ordering (possibly slowing things
|
||||
# down). We'll set the order properly in a minute.
|
||||
self.page.object_list = self.post_process_queryset(
|
||||
self.queryset.model.objects.filter(
|
||||
pk__in=self.id_list).order_by())
|
||||
|
||||
if use_select_related:
|
||||
self.page.object_list = \
|
||||
self.page.object_list.select_related(depth=1)
|
||||
|
||||
if self.id_list:
|
||||
# The database will give us the items in a more or less random
|
||||
# order, since it doesn't know to keep it in the order provided by
|
||||
# the ID list. This will place the results back in the order we
|
||||
# expect.
|
||||
index = dict([(id, pos) for (pos, id) in enumerate(self.id_list)])
|
||||
object_list = [None] * len(self.id_list)
|
||||
|
||||
for obj in list(self.page.object_list):
|
||||
object_list[index[obj.pk]] = obj
|
||||
else:
|
||||
# Grab the whole list at once. We know it won't be too large,
|
||||
# and it will prevent one query per row.
|
||||
object_list = list(self.page.object_list)
|
||||
|
||||
for column in self.columns:
|
||||
column.collect_objects(object_list)
|
||||
|
||||
if render_context is None:
|
||||
render_context = self._build_render_context()
|
||||
|
||||
self.rows = [
|
||||
{
|
||||
'object': obj,
|
||||
'cells': [column.render_cell(obj, render_context)
|
||||
for column in self.columns]
|
||||
}
|
||||
for obj in object_list if obj is not None
|
||||
]
|
||||
|
||||
def post_process_queryset(self, queryset):
|
||||
"""
|
||||
Processes a QuerySet after the initial query has been built and
|
||||
pagination applied. This is only used when optimizing a sort.
|
||||
|
||||
By default, this just returns the existing queryset. Custom datagrid
|
||||
subclasses can override this to add additional queries (such as
|
||||
subqueries in an extra() call) for use in the cell renderers.
|
||||
|
||||
When optimize_sorts is True, subqueries (using extra()) on the initial
|
||||
QuerySet passed to the datagrid will be stripped from the final
|
||||
result. This function can be used to re-add those subqueries.
|
||||
"""
|
||||
for column in self.columns:
|
||||
queryset = column.augment_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def render_listview(self, render_context=None):
|
||||
"""
|
||||
Renders the standard list view of the grid.
|
||||
|
||||
This can be called from templates.
|
||||
"""
|
||||
try:
|
||||
if render_context is None:
|
||||
render_context = self._build_render_context()
|
||||
|
||||
self.load_state(render_context)
|
||||
|
||||
context = Context({
|
||||
'datagrid': self,
|
||||
'is_paginated': self.page.has_other_pages(),
|
||||
'results_per_page': self.paginate_by,
|
||||
'has_next': self.page.has_next(),
|
||||
'has_previous': self.page.has_previous(),
|
||||
'page': self.page.number,
|
||||
'next': self.page.next_page_number(),
|
||||
'previous': self.page.previous_page_number(),
|
||||
'last_on_page': self.page.end_index(),
|
||||
'first_on_page': self.page.start_index(),
|
||||
'pages': self.paginator.num_pages,
|
||||
'hits': self.paginator.count,
|
||||
'page_range': self.paginator.page_range,
|
||||
})
|
||||
context.update(self.extra_context)
|
||||
context.update(render_context)
|
||||
|
||||
return mark_safe(render_to_string(self.listview_template,
|
||||
context))
|
||||
except Exception:
|
||||
trace = traceback.format_exc();
|
||||
logging.error('Failed to render datagrid:\n%s' % trace,
|
||||
extra={
|
||||
'request': self.request,
|
||||
})
|
||||
return mark_safe('<pre>%s</pre>' % trace)
|
||||
|
||||
def render_listview_to_response(self, request=None, render_context=None):
|
||||
"""
|
||||
Renders the listview to a response, preventing caching in the
|
||||
process.
|
||||
"""
|
||||
response = HttpResponse(unicode(self.render_listview(render_context)))
|
||||
patch_cache_control(response, no_cache=True, no_store=True, max_age=0,
|
||||
must_revalidate=True)
|
||||
return response
|
||||
|
||||
def render_to_response(self, template_name, extra_context={}):
|
||||
"""
|
||||
Renders a template containing this datagrid as a context variable.
|
||||
"""
|
||||
render_context = self._build_render_context()
|
||||
self.load_state(render_context)
|
||||
|
||||
# If the caller is requesting just this particular grid, return it.
|
||||
if self.request.GET.get('gridonly', False) and \
|
||||
self.request.GET.get('datagrid-id', None) == self.id:
|
||||
return self.render_listview_to_response(
|
||||
render_context=render_context)
|
||||
|
||||
context = Context({
|
||||
'datagrid': self
|
||||
})
|
||||
context.update(extra_context)
|
||||
context.update(render_context)
|
||||
|
||||
return render_to_response(template_name, context)
|
||||
|
||||
def _build_render_context(self):
|
||||
"""Builds a dictionary containing RequestContext contents.
|
||||
|
||||
A RequestContext can be expensive, so it's best to reuse the
|
||||
contents of one when possible. This is not easy with a standard
|
||||
RequestContext, but it's possible to build one and then pull out
|
||||
the contents into a dictionary.
|
||||
"""
|
||||
request_context = RequestContext(self.request)
|
||||
render_context = {}
|
||||
|
||||
for d in request_context:
|
||||
render_context.update(d)
|
||||
|
||||
return render_context
|
||||
|
||||
@staticmethod
|
||||
def link_to_object(obj, value):
|
||||
return obj.get_absolute_url()
|
||||
|
||||
@staticmethod
|
||||
def link_to_value(obj, value):
|
||||
return value.get_absolute_url()
|
|
@ -0,0 +1,7 @@
|
|||
<td{% if css_class %} class="{{css_class}}"{% endif %}{% if column.last %} colspan="2"{% endif %}{% if url and column.cell_clickable %} onclick="javascript:window.location = '{{url}}'; return false;"{% endif %}>
|
||||
{% if url %}
|
||||
<a href="{{url}}">{{data}}</a>
|
||||
{% else %}
|
||||
{{data}}
|
||||
{% endif %}
|
||||
</td>
|
|
@ -0,0 +1,34 @@
|
|||
{% load djblets_utils i18n staticfiles %}
|
||||
{% if column.sortable %}
|
||||
<th onclick="javascript:window.location = '{{sort_url}}';" class="datagrid-header">
|
||||
<a href="{{sort_url}}">{% if column.label %}{{column.label}}{% endif %}
|
||||
{% if column.image_url %}
|
||||
<img src="{{column.image_url}}" width="{{column.image_width}}"
|
||||
height="{{column.image_height}}" alt="{{column.image_alt}}"
|
||||
title="{{column.image_alt}}" />
|
||||
{% elif column.image_class %}
|
||||
<div class="{{column.image_class}}" title="{{column.image_alt}}"></div>
|
||||
{% endif %}
|
||||
{% if in_sort %}
|
||||
{% definevar "sort_image" %}djblets/images/datagrid/sort_{% if sort_ascending %}asc{% else %}desc{% endif %}_{% if sort_primary %}primary{% else %}secondary{% endif %}.png{% enddefinevar %}
|
||||
<img src="{% static sort_image %}"
|
||||
alt="({% if sort_ascending %}{% trans "Ascending" %}{% else %}{% trans "Descending" %}{% endif %})"
|
||||
width="9" height="5" border="0" />
|
||||
</a>
|
||||
<a class="datagrid-unsort" href="{{unsort_url}}">
|
||||
<img src="{% static "images/datagrid/unsort.png" %}" width="7" height="7" border="0" alt="{% trans "Unsort" %}" />
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
{% else %}
|
||||
<th class="datagrid-header">
|
||||
{% if column.label %}{{column.label}}{% endif %}
|
||||
{% if column.image_url %}
|
||||
<img src="{{column.image_url}}" width="{{column.image_width}}"
|
||||
height="{{column.image_height}}" alt="{{column.image_alt}}"
|
||||
title="{{column.image_alt}}" />
|
||||
{% elif column.image_class %}
|
||||
<div class="{{column.image_class}}" title="{{column.image_alt}}"></div>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endif %}
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
{% load djblets_deco %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{{datagrid.title}}{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static "css/datagrid.css" %}" />
|
||||
{% include "js/jquery.html" %}
|
||||
{% include "js/jquery-ui.html" %}
|
||||
<script type="text/javascript" src="{% static "js/jquery.gravy.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "js/datagrid.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% box %}
|
||||
{{datagrid.render_listview}}
|
||||
{% endbox %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,55 @@
|
|||
{% load datagrid %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
<div class="datagrid-wrapper" id="{{datagrid.id}}">
|
||||
<div class="datagrid-titlebox">
|
||||
{% block datagrid_title %}
|
||||
<h1 class="datagrid-title">{{datagrid.title}}</h1>
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="datagrid-main">
|
||||
<table class="datagrid">
|
||||
<colgroup>
|
||||
{% for column in datagrid.columns %}
|
||||
<col class="{{column.id}}"{% ifnotequal column.width 0 %} width="{{column.width}}%"{% endifnotequal %} />
|
||||
{% endfor %}
|
||||
<col class="datagrid-customize" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="datagrid-headers">
|
||||
{% for column in datagrid.columns %}
|
||||
{{column.get_header}}{% endfor %}
|
||||
<th class="edit-columns datagrid-header" id="{{datagrid.id}}-edit"><img src="{% static "images/datagrid/edit.png" %}" border="0" width="20" height="14" alt="{% trans "Edit columns" %}" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in datagrid.rows %}
|
||||
<tr class="{% cycle odd,even %}">
|
||||
{% for cell in row.cells %}
|
||||
{{cell}}{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if is_paginated %}
|
||||
{% paginator %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="datagrid-menu" id="{{datagrid.id}}-menu" style="display:none;position:absolute;">
|
||||
{% for column in datagrid.all_columns %}
|
||||
{% with column.toggle_url as toggle_url %}
|
||||
<tr class="{{column.id}}">
|
||||
<td><div class="datagrid-menu-checkbox">{% if column.active %}<img src="{% static "images/datagrid/checkmark.png" %}" width="8" height="8" border="0" alt="X" />{% endif %}</div></td>
|
||||
<td class="datagrid-menu-label"><a href="#">
|
||||
{% if column.image_url %}
|
||||
<img src="{{column.image_url}}" width="{{column.image_width}}" height="{{column.image_height}}" alt="{{column.image_alt}}" />
|
||||
{% elif column.image_class %}
|
||||
<div class="{{column.image_class}}"></div>
|
||||
{% endif %}
|
||||
{{column.detailed_label|default_if_none:""}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
<div class="paginator">
|
||||
{% if show_first %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page=1" title="First Page">«</a>{% endif %}
|
||||
{% if has_previous %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{previous}}" title="Previous Page"><</a></span>{% endif %}
|
||||
{% for pagenum in page_numbers %}
|
||||
{% ifequal pagenum page %}
|
||||
<span class="current-page">{{pagenum}}</span>
|
||||
{% else %}
|
||||
<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{pagenum}}" title="Page {{pagenum}}">{{pagenum}}</a>
|
||||
{% endifequal %}
|
||||
{% endfor %}
|
||||
{% if has_next %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{next}}" title="Next Page">></a></span>{% endif %}
|
||||
{% if show_last %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{pages}}" title="Last Page">»</a></span>{% endif %}
|
||||
<span class="page-count">{{pages}} pages</span>
|
||||
</div>
|
|
@ -0,0 +1,58 @@
|
|||
#
|
||||
# datagrid.py -- Template tags used in datagrids
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
# Copyright (c) 2008-2009 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
# Heavily based on paginator by insin
|
||||
# http://www.djangosnippets.org/snippets/73/
|
||||
@register.inclusion_tag('datagrid/paginator.html', takes_context=True)
|
||||
def paginator(context, adjacent_pages=3):
|
||||
"""
|
||||
Renders a paginator used for jumping between pages of results.
|
||||
"""
|
||||
page_nums = range(max(1, context['page'] - adjacent_pages),
|
||||
min(context['pages'], context['page'] + adjacent_pages)
|
||||
+ 1)
|
||||
|
||||
return {
|
||||
'hits': context['hits'],
|
||||
'results_per_page': context['results_per_page'],
|
||||
'page': context['page'],
|
||||
'pages': context['pages'],
|
||||
'page_numbers': page_nums,
|
||||
'next': context['next'],
|
||||
'previous': context['previous'],
|
||||
'has_next': context['has_next'],
|
||||
'has_previous': context['has_previous'],
|
||||
'show_first': 1 not in page_nums,
|
||||
'show_last': context['pages'] not in page_nums,
|
||||
'extra_query': context.get('extra_query', None),
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
#
|
||||
# tests.py -- Unit tests for classes in djblets.datagrid
|
||||
#
|
||||
# Copyright (c) 2007-2008 Christian Hammond
|
||||
# Copyright (c) 2007-2008 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.http import HttpRequest
|
||||
|
||||
from djblets.datagrid.grids import Column, DataGrid, DateTimeSinceColumn
|
||||
from djblets.testing.testcases import TestCase
|
||||
from djblets.util.dates import get_tz_aware_utcnow
|
||||
|
||||
|
||||
def populate_groups():
|
||||
for i in range(1, 100):
|
||||
group = Group(name="Group %02d" % i)
|
||||
group.save()
|
||||
|
||||
|
||||
class GroupDataGrid(DataGrid):
|
||||
objid = Column("ID", link=True, sortable=True, field_name="id")
|
||||
name = Column("Group Name", link=True, sortable=True, expand=True)
|
||||
|
||||
def __init__(self, request):
|
||||
DataGrid.__init__(self, request, Group.objects.all(), "All Groups")
|
||||
self.default_sort = []
|
||||
self.default_columns = [
|
||||
"objid", "name"
|
||||
]
|
||||
|
||||
|
||||
class ColumnsTest(TestCase):
|
||||
def testDateTimeSinceColumn(self):
|
||||
"""Testing DateTimeSinceColumn"""
|
||||
class DummyObj:
|
||||
time = None
|
||||
|
||||
column = DateTimeSinceColumn("Test", field_name='time')
|
||||
if settings.USE_TZ:
|
||||
now = get_tz_aware_utcnow()
|
||||
else:
|
||||
now = datetime.now()
|
||||
|
||||
obj = DummyObj()
|
||||
obj.time = now
|
||||
self.assertEqual(column.render_data(obj), "0 minutes ago")
|
||||
|
||||
obj.time = now - timedelta(days=5)
|
||||
self.assertEqual(column.render_data(obj), "5 days ago")
|
||||
|
||||
obj.time = now - timedelta(days=7)
|
||||
self.assertEqual(column.render_data(obj), "1 week ago")
|
||||
|
||||
|
||||
class DataGridTest(TestCase):
|
||||
def setUp(self):
|
||||
self.old_auth_profile_module = getattr(settings, "AUTH_PROFILE_MODULE",
|
||||
None)
|
||||
settings.AUTH_PROFILE_MODULE = None
|
||||
populate_groups()
|
||||
self.user = User(username="testuser")
|
||||
self.request = HttpRequest()
|
||||
self.request.user = self.user
|
||||
self.datagrid = GroupDataGrid(self.request)
|
||||
|
||||
def tearDown(self):
|
||||
settings.AUTH_PROFILE_MODULE = self.old_auth_profile_module
|
||||
|
||||
def testRender(self):
|
||||
"""Testing basic datagrid rendering"""
|
||||
self.datagrid.render_listview()
|
||||
|
||||
def testRenderToResponse(self):
|
||||
"""Testing rendering datagrid to HTTPResponse"""
|
||||
self.datagrid.render_listview_to_response()
|
||||
|
||||
def testSortAscending(self):
|
||||
"""Testing datagrids with ascending sort"""
|
||||
self.request.GET['sort'] = "name,objid"
|
||||
self.datagrid.load_state()
|
||||
|
||||
self.assertEqual(self.datagrid.sort_list, ["name", "objid"])
|
||||
self.assertEqual(len(self.datagrid.rows), self.datagrid.paginate_by)
|
||||
self.assertEqual(self.datagrid.rows[0]['object'].name, "Group 01")
|
||||
self.assertEqual(self.datagrid.rows[1]['object'].name, "Group 02")
|
||||
self.assertEqual(self.datagrid.rows[2]['object'].name, "Group 03")
|
||||
|
||||
# Exercise the code paths when rendering
|
||||
self.datagrid.render_listview()
|
||||
|
||||
def testSortDescending(self):
|
||||
"""Testing datagrids with descending sort"""
|
||||
self.request.GET['sort'] = "-name"
|
||||
self.datagrid.load_state()
|
||||
|
||||
self.assertEqual(self.datagrid.sort_list, ["-name"])
|
||||
self.assertEqual(len(self.datagrid.rows), self.datagrid.paginate_by)
|
||||
self.assertEqual(self.datagrid.rows[0]['object'].name, "Group 99")
|
||||
self.assertEqual(self.datagrid.rows[1]['object'].name, "Group 98")
|
||||
self.assertEqual(self.datagrid.rows[2]['object'].name, "Group 97")
|
||||
|
||||
# Exercise the code paths when rendering
|
||||
self.datagrid.render_listview()
|
||||
|
||||
|
||||
def testCustomColumns(self):
|
||||
"""Testing datagrids with custom column orders"""
|
||||
self.request.GET['columns'] = "objid"
|
||||
self.datagrid.load_state()
|
||||
|
||||
self.assertEqual(len(self.datagrid.rows), self.datagrid.paginate_by)
|
||||
self.assertEqual(len(self.datagrid.rows[0]['cells']), 1)
|
||||
|
||||
# Exercise the code paths when rendering
|
||||
self.datagrid.render_listview()
|
|
@ -0,0 +1,35 @@
|
|||
#
|
||||
# admin.py -- Admin UI model registration.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from djblets.extensions.models import RegisteredExtension
|
||||
|
||||
|
||||
class RegisteredExtensionAdmin(admin.ModelAdmin):
|
||||
list_display = ('class_name', 'name', 'enabled')
|
||||
|
||||
|
||||
admin.site.register(RegisteredExtension, RegisteredExtensionAdmin)
|
|
@ -0,0 +1,835 @@
|
|||
#
|
||||
# base.py -- Base classes for extensions.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pkg_resources
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.defaults import patterns, include
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.urlresolvers import get_mod_func, reverse
|
||||
from django.db.models import loading
|
||||
from django.template.loader import template_source_loaders
|
||||
from django.utils.importlib import import_module
|
||||
from django.utils.module_loading import module_has_submodule
|
||||
from django_evolution.management.commands.evolve import Command as Evolution
|
||||
from setuptools.command import easy_install
|
||||
|
||||
from djblets.extensions.errors import EnablingExtensionError, \
|
||||
InstallExtensionError, \
|
||||
InvalidExtensionError
|
||||
from djblets.extensions.models import RegisteredExtension
|
||||
from djblets.extensions.signals import extension_initialized, \
|
||||
extension_uninitialized
|
||||
from djblets.util.misc import make_cache_key
|
||||
from djblets.util.urlresolvers import DynamicURLResolver
|
||||
|
||||
|
||||
|
||||
if not hasattr(settings, "EXTENSIONS_STATIC_ROOT"):
|
||||
raise ImproperlyConfigured, \
|
||||
"settings.EXTENSIONS_STATIC_ROOT must be defined"
|
||||
|
||||
|
||||
_extension_managers = []
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
"""
|
||||
Settings data for an extension. This is a glorified dictionary that
|
||||
acts as a proxy for the extension's stored settings in the database.
|
||||
|
||||
Callers must call save() when they want to make the settings persistent.
|
||||
|
||||
If a key is not found in the dictionary, extension.default_settings
|
||||
will be checked as well.
|
||||
"""
|
||||
def __init__(self, extension):
|
||||
dict.__init__(self)
|
||||
self.extension = extension
|
||||
self.load()
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Retrieve an item from the dictionary.
|
||||
|
||||
This will attempt to return a default value from
|
||||
extension.default_settings if the setting has not
|
||||
been set.
|
||||
"""
|
||||
if super(Settings, self).__contains__(key):
|
||||
return super(Settings, self).__getitem__(key)
|
||||
|
||||
if key in self.extension.default_settings:
|
||||
return self.extension.default_settings[key]
|
||||
|
||||
raise KeyError(
|
||||
'The settings key "%(key)s" was not found in extension %(ext)s' % {
|
||||
'key': key,
|
||||
'ext': self.extension.id
|
||||
})
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Indicate if the setting is present.
|
||||
|
||||
If the key is not present in the settings dictionary
|
||||
check the default settings as well.
|
||||
"""
|
||||
if super(Settings, self).__contains__(key):
|
||||
return True
|
||||
|
||||
return key in self.extension.default_settings
|
||||
|
||||
def set(self, key, value):
|
||||
self[key] = value
|
||||
|
||||
def load(self):
|
||||
"""Loads the settings from the database."""
|
||||
try:
|
||||
self.update(self.extension.registration.settings)
|
||||
except ValueError:
|
||||
# The settings in the database are invalid. We'll have to discard
|
||||
# it. Note that this should never happen unless the user
|
||||
# hand-modifies the entries and breaks something.
|
||||
pass
|
||||
|
||||
def save(self):
|
||||
"""Saves all current settings to the database."""
|
||||
registration = self.extension.registration
|
||||
registration.settings = dict(self)
|
||||
registration.save()
|
||||
|
||||
# Make sure others are aware that the configuration changed.
|
||||
self.extension.extension_manager._bump_sync_gen()
|
||||
|
||||
|
||||
class Extension(object):
|
||||
"""Base class for an extension.
|
||||
|
||||
Extensions must subclass for this class. They'll automatically have
|
||||
support for settings, adding hooks, and plugging into the administration
|
||||
UI.
|
||||
|
||||
If an extension supports configuration in the UI, it should set
|
||||
:py:attr:`is_configurable` to True.
|
||||
|
||||
If an extension would like to specify defaults for the settings
|
||||
dictionary it should provide a dictionary in :py:attr:`default_settings`.
|
||||
|
||||
If an extension would like a django admin site for modifying the database,
|
||||
it should set :py:attr:`has_admin_site` to True.
|
||||
|
||||
Extensions should list all other extension names that they require in
|
||||
:py:attr:`requirements`.
|
||||
"""
|
||||
metadata = None
|
||||
is_configurable = False
|
||||
default_settings = {}
|
||||
has_admin_site = False
|
||||
requirements = []
|
||||
resources = []
|
||||
apps = []
|
||||
|
||||
def __init__(self, extension_manager):
|
||||
self.extension_manager = extension_manager
|
||||
self.hooks = set()
|
||||
self.settings = Settings(self)
|
||||
self.admin_site = None
|
||||
|
||||
def shutdown(self):
|
||||
"""Shuts down the extension.
|
||||
|
||||
This will shut down every registered hook.
|
||||
|
||||
Subclasses should override this if they need custom shutdown behavior.
|
||||
"""
|
||||
for hook in self.hooks:
|
||||
hook.shutdown()
|
||||
|
||||
def _get_admin_urlconf(self):
|
||||
if not hasattr(self, "_admin_urlconf_module"):
|
||||
try:
|
||||
name = "%s.%s" % (get_mod_func(self.__class__.__module__)[0],
|
||||
"admin_urls")
|
||||
self._admin_urlconf_module = __import__(name, {}, {}, [''])
|
||||
except Exception, e:
|
||||
raise ImproperlyConfigured, \
|
||||
"Error while importing extension's admin URLconf %r: %s" % \
|
||||
(name, e)
|
||||
|
||||
return self._admin_urlconf_module
|
||||
admin_urlconf = property(_get_admin_urlconf)
|
||||
|
||||
|
||||
class ExtensionInfo(object):
|
||||
"""Information on an extension.
|
||||
|
||||
This class stores the information and metadata on an extension. This
|
||||
includes the name, version, author information, where it can be downloaded,
|
||||
whether or not it's enabled or installed, and anything else that may be
|
||||
in the Python package for the extension.
|
||||
"""
|
||||
def __init__(self, entrypoint, ext_class):
|
||||
metadata = {}
|
||||
|
||||
for line in entrypoint.dist.get_metadata_lines("PKG-INFO"):
|
||||
key, value = line.split(": ", 1)
|
||||
|
||||
if value != "UNKNOWN":
|
||||
metadata[key] = value
|
||||
|
||||
if ext_class.metadata is not None:
|
||||
metadata.update(ext_class.metadata)
|
||||
|
||||
self.metadata = metadata
|
||||
self.name = metadata.get('Name')
|
||||
self.version = metadata.get('Version')
|
||||
self.summary = metadata.get('Summary')
|
||||
self.description = metadata.get('Description')
|
||||
self.author = metadata.get('Author')
|
||||
self.author_email = metadata.get('Author-email')
|
||||
self.license = metadata.get('License')
|
||||
self.url = metadata.get('Home-page')
|
||||
self.author_url = metadata.get('Author-home-page', self.url)
|
||||
self.app_name = '.'.join(ext_class.__module__.split('.')[:-1])
|
||||
self.enabled = False
|
||||
self.installed = False
|
||||
self.is_configurable = ext_class.is_configurable
|
||||
self.has_admin_site = ext_class.has_admin_site
|
||||
self.htdocs_path = os.path.join(settings.EXTENSIONS_STATIC_ROOT,
|
||||
self.name)
|
||||
|
||||
def __unicode__(self):
|
||||
return "%s %s (enabled = %s)" % (self.name, self.version, self.enabled)
|
||||
|
||||
|
||||
class ExtensionHook(object):
|
||||
"""The base class for a hook into some part of the project.
|
||||
|
||||
ExtensionHooks are classes that can hook into an
|
||||
:py:class:`ExtensionHookPoint` to provide some level of functionality
|
||||
in a project. A project should provide a subclass of ExtensionHook that
|
||||
will provide functions for getting data or anything else that's needed,
|
||||
and then extensions will subclass that specific ExtensionHook.
|
||||
|
||||
A base ExtensionHook subclass must use :py:class:`ExtensionHookPoint`
|
||||
as a metaclass. For example::
|
||||
|
||||
class NavigationHook(ExtensionHook):
|
||||
__metaclass__ = ExtensionHookPoint
|
||||
"""
|
||||
def __init__(self, extension):
|
||||
self.extension = extension
|
||||
self.extension.hooks.add(self)
|
||||
self.__class__.add_hook(self)
|
||||
|
||||
def shutdown(self):
|
||||
self.__class__.remove_hook(self)
|
||||
|
||||
|
||||
class ExtensionHookPoint(type):
|
||||
"""A metaclass used for base Extension Hooks.
|
||||
|
||||
Base :py:class:`ExtensionHook` classes use :py:class:`ExtensionHookPoint`
|
||||
as a metaclass. This metaclass stores the list of registered hooks that
|
||||
an :py:class:`ExtensionHook` will automatically register with.
|
||||
"""
|
||||
def __init__(cls, name, bases, attrs):
|
||||
super(ExtensionHookPoint, cls).__init__(name, bases, attrs)
|
||||
|
||||
if not hasattr(cls, "hooks"):
|
||||
cls.hooks = []
|
||||
|
||||
def add_hook(cls, hook):
|
||||
"""Adds an ExtensionHook to the list of active hooks.
|
||||
|
||||
This is called automatically by :py:class:`ExtensionHook`.
|
||||
"""
|
||||
cls.hooks.append(hook)
|
||||
|
||||
def remove_hook(cls, hook):
|
||||
"""Removes an ExtensionHook from the list of active hooks.
|
||||
|
||||
This is called automatically by :py:class:`ExtensionHook`.
|
||||
"""
|
||||
cls.hooks.remove(hook)
|
||||
|
||||
|
||||
class ExtensionManager(object):
|
||||
"""A manager for all extensions.
|
||||
|
||||
ExtensionManager manages the extensions available to a project. It can
|
||||
scan for new extensions, enable or disable them, determine dependencies,
|
||||
install into the database, and uninstall.
|
||||
|
||||
An installed extension is one that has been installed by a Python package
|
||||
on the system.
|
||||
|
||||
A registered extension is one that has been installed and information then
|
||||
placed in the database. This happens automatically after scanning for
|
||||
an installed extension. The registration data stores whether or not it's
|
||||
enabled, and stores various pieces of information on the extension.
|
||||
|
||||
An enabled extension is one that is actively enabled and hooked into the
|
||||
project.
|
||||
|
||||
Each project should have one ExtensionManager.
|
||||
"""
|
||||
def __init__(self, key):
|
||||
self.key = key
|
||||
|
||||
self.pkg_resources = None
|
||||
|
||||
self._extension_classes = {}
|
||||
self._extension_instances = {}
|
||||
|
||||
# State synchronization
|
||||
self._sync_key = make_cache_key('extensionmgr:%s:gen' % key)
|
||||
self._last_sync_gen = None
|
||||
|
||||
self.dynamic_urls = DynamicURLResolver()
|
||||
|
||||
_extension_managers.append(self)
|
||||
|
||||
def get_url_patterns(self):
|
||||
"""Returns the URL patterns for the Extension Manager.
|
||||
|
||||
This should be included in the root urlpatterns for the site.
|
||||
"""
|
||||
return patterns('', self.dynamic_urls)
|
||||
|
||||
def is_expired(self):
|
||||
"""Returns whether or not the extension state is possibly expired.
|
||||
|
||||
Extension state covers the lists of extensions and each extension's
|
||||
configuration. It can expire if the state synchronization value
|
||||
falls out of cache or is changed.
|
||||
|
||||
Each ExtensionManager has its own state synchronization cache key.
|
||||
"""
|
||||
sync_gen = cache.get(self._sync_key)
|
||||
|
||||
return (sync_gen is None or
|
||||
(type(sync_gen) is int and sync_gen != self._last_sync_gen))
|
||||
|
||||
def clear_sync_cache(self):
|
||||
cache.delete(self._sync_key)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("djblets.extensions.views.extension_list")
|
||||
|
||||
def get_enabled_extension(self, extension_id):
|
||||
"""Returns an enabled extension with the given ID."""
|
||||
if extension_id in self._extension_instances:
|
||||
return self._extension_instances[extension_id]
|
||||
|
||||
return None
|
||||
|
||||
def get_enabled_extensions(self):
|
||||
"""Returns the list of all enabled extensions."""
|
||||
return self._extension_instances.values()
|
||||
|
||||
def get_installed_extensions(self):
|
||||
"""Returns the list of all installed extensions."""
|
||||
return self._extension_classes.values()
|
||||
|
||||
def get_installed_extension(self, extension_id):
|
||||
"""Returns the installed extension with the given ID."""
|
||||
if extension_id not in self._extension_classes:
|
||||
raise InvalidExtensionError(extension_id)
|
||||
|
||||
return self._extension_classes[extension_id]
|
||||
|
||||
def get_dependent_extensions(self, dependency_extension_id):
|
||||
"""Returns a list of all extensions required by an extension."""
|
||||
if dependency_extension_id not in self._extension_instances:
|
||||
raise InvalidExtensionError(dependency_extension_id)
|
||||
|
||||
dependency = self.get_installed_extension(dependency_extension_id)
|
||||
result = []
|
||||
|
||||
for extension_id, extension in self._extension_classes.iteritems():
|
||||
if extension_id == dependency_extension_id:
|
||||
continue
|
||||
|
||||
for ext_requirement in extension.info.requirements:
|
||||
if ext_requirement == dependency:
|
||||
result.append(extension_id)
|
||||
|
||||
return result
|
||||
|
||||
def enable_extension(self, extension_id):
|
||||
"""Enables an extension.
|
||||
|
||||
Enabling an extension will install any data files the extension
|
||||
may need, any tables in the database, perform any necessary
|
||||
database migrations, and then will start up the extension.
|
||||
"""
|
||||
if extension_id in self._extension_instances:
|
||||
# It's already enabled.
|
||||
return
|
||||
|
||||
if extension_id not in self._extension_classes:
|
||||
raise InvalidExtensionError(extension_id)
|
||||
|
||||
ext_class = self._extension_classes[extension_id]
|
||||
|
||||
# Enable extension dependencies
|
||||
for requirement_id in ext_class.requirements:
|
||||
self.enable_extension(requirement_id)
|
||||
|
||||
try:
|
||||
self._install_extension(ext_class)
|
||||
except InstallExtensionError, e:
|
||||
raise EnablingExtensionError(e.message)
|
||||
|
||||
ext_class.registration.enabled = True
|
||||
ext_class.registration.save()
|
||||
extension = self._init_extension(ext_class)
|
||||
|
||||
self._clear_template_cache()
|
||||
self._bump_sync_gen()
|
||||
|
||||
return extension
|
||||
|
||||
def disable_extension(self, extension_id):
|
||||
"""Disables an extension.
|
||||
|
||||
Disabling an extension will remove any data files the extension
|
||||
installed and then shut down the extension and all of its hooks.
|
||||
|
||||
It will not delete any data from the database.
|
||||
"""
|
||||
if extension_id not in self._extension_instances:
|
||||
# It's not enabled.
|
||||
return
|
||||
|
||||
if extension_id not in self._extension_classes:
|
||||
raise InvalidExtensionError(extension_id)
|
||||
|
||||
extension = self._extension_instances[extension_id]
|
||||
|
||||
for dependent_id in self.get_dependent_extensions(extension_id):
|
||||
self.disable_extension(dependent_id)
|
||||
|
||||
self._uninstall_extension(extension)
|
||||
self._uninit_extension(extension)
|
||||
extension.registration.enabled = False
|
||||
extension.registration.save()
|
||||
|
||||
self._clear_template_cache()
|
||||
self._bump_sync_gen()
|
||||
|
||||
def install_extension(self, install_url, package_name):
|
||||
"""Install an extension from a remote source.
|
||||
|
||||
Installs an extension from a remote URL containing the
|
||||
extension egg. Installation may fail if a malformed install_url
|
||||
or package_name is passed, which will cause an InstallExtensionError
|
||||
exception to be raised. It is also assumed that the extension is not
|
||||
already installed.
|
||||
"""
|
||||
|
||||
try:
|
||||
easy_install.main(["-U", install_url])
|
||||
|
||||
# Update the entry points.
|
||||
dist = pkg_resources.get_distribution(package_name)
|
||||
dist.activate()
|
||||
pkg_resources.working_set.add(dist)
|
||||
except pkg_resources.DistributionNotFound:
|
||||
raise InstallExtensionError("Invalid package name.")
|
||||
except SystemError:
|
||||
raise InstallExtensionError("Installation failed "
|
||||
"(probably malformed URL).")
|
||||
|
||||
# Refresh the extension manager.
|
||||
self.load(True)
|
||||
|
||||
def load(self, full_reload=False):
|
||||
"""
|
||||
Loads all known extensions, initializing any that are recorded as
|
||||
being enabled.
|
||||
|
||||
If this is called a second time, it will refresh the list of
|
||||
extensions, adding new ones and removing deleted ones.
|
||||
|
||||
If full_reload is passed, all state is cleared and we reload all
|
||||
extensions and state from scratch.
|
||||
"""
|
||||
if full_reload:
|
||||
# We're reloading everything, so nuke all the cached copies.
|
||||
self._clear_extensions()
|
||||
self._clear_template_cache()
|
||||
|
||||
# Preload all the RegisteredExtension objects
|
||||
registered_extensions = {}
|
||||
for registered_ext in RegisteredExtension.objects.all():
|
||||
registered_extensions[registered_ext.class_name] = registered_ext
|
||||
|
||||
found_extensions = {}
|
||||
|
||||
for entrypoint in self._entrypoint_iterator():
|
||||
registered_ext = None
|
||||
|
||||
try:
|
||||
ext_class = entrypoint.load()
|
||||
|
||||
# Don't override the info if we've previously loaded this
|
||||
# class.
|
||||
if not getattr(ext_class, "info", None):
|
||||
ext_class.info = ExtensionInfo(entrypoint, ext_class)
|
||||
except Exception, e:
|
||||
logging.error("Error loading extension %s: %s" %
|
||||
(entrypoint.name, e))
|
||||
continue
|
||||
|
||||
# A class's extension ID is its class name. We want to
|
||||
# make this easier for users to access by giving it an 'id'
|
||||
# variable, which will be accessible both on the class and on
|
||||
# instances.
|
||||
class_name = ext_class.id = "%s.%s" % (ext_class.__module__,
|
||||
ext_class.__name__)
|
||||
self._extension_classes[class_name] = ext_class
|
||||
found_extensions[class_name] = ext_class
|
||||
|
||||
# If the ext_class has a registration variable that's set, then
|
||||
# it's already been loaded. We don't want to bother creating a
|
||||
# new one.
|
||||
if not hasattr(ext_class, "registration"):
|
||||
if class_name in registered_extensions:
|
||||
registered_ext = registered_extensions[class_name]
|
||||
else:
|
||||
registered_ext, is_new = \
|
||||
RegisteredExtension.objects.get_or_create(
|
||||
class_name=class_name,
|
||||
defaults={
|
||||
'name': entrypoint.dist.project_name
|
||||
})
|
||||
|
||||
ext_class.registration = registered_ext
|
||||
|
||||
if (ext_class.registration.enabled and
|
||||
ext_class.id not in self._extension_instances):
|
||||
self._init_extension(ext_class)
|
||||
|
||||
# At this point, if we're reloading, it's possible that the user
|
||||
# has removed some extensions. Go through and remove any that we
|
||||
# can no longer find.
|
||||
#
|
||||
# While we're at it, since we're at a point where we've seen all
|
||||
# extensions, we can set the ExtensionInfo.requirements for
|
||||
# each extension
|
||||
for class_name, ext_class in self._extension_classes.iteritems():
|
||||
if class_name not in found_extensions:
|
||||
if class_name in self._extension_instances:
|
||||
self.disable_extension(class_name)
|
||||
|
||||
del self._extension_classes[class_name]
|
||||
else:
|
||||
ext_class.info.requirements = \
|
||||
[self.get_installed_extension(requirement_id)
|
||||
for requirement_id in ext_class.requirements]
|
||||
|
||||
# Add the sync generation if it doesn't already exist.
|
||||
self._add_new_sync_gen()
|
||||
self._last_sync_gen = cache.get(self._sync_key)
|
||||
|
||||
def _clear_extensions(self):
|
||||
"""Clear the entire list of known extensions.
|
||||
|
||||
This will bring the ExtensionManager back to the state where
|
||||
it doesn't yet know about any extensions, requiring a re-load.
|
||||
"""
|
||||
for extension in self._extension_instances.values():
|
||||
self._uninit_extension(extension)
|
||||
|
||||
for extension_class in self._extension_classes.values():
|
||||
if hasattr(extension_class, 'info'):
|
||||
delattr(extension_class, 'info')
|
||||
|
||||
if hasattr(extension_class, 'registration'):
|
||||
delattr(extension_class, 'registration')
|
||||
|
||||
self._extension_classes = {}
|
||||
self._extension_instances = {}
|
||||
|
||||
def _clear_template_cache(self):
|
||||
"""Clears the Django template caches."""
|
||||
if template_source_loaders:
|
||||
for template_loader in template_source_loaders:
|
||||
if hasattr(template_loader, 'reset'):
|
||||
template_loader.reset()
|
||||
|
||||
def _init_extension(self, ext_class):
|
||||
"""Initializes an extension.
|
||||
|
||||
This will register the extension, install any URLs that it may need,
|
||||
and make it available in Django's list of apps. It will then notify
|
||||
that the extension has been initialized.
|
||||
"""
|
||||
assert ext_class.id not in self._extension_instances
|
||||
|
||||
try:
|
||||
extension = ext_class(extension_manager=self)
|
||||
except Exception, e:
|
||||
logging.error('Unable to initialize extension %s: %s'
|
||||
% (ext_class, e), exc_info=1)
|
||||
raise EnablingExtensionError('Error initializing extension: %s'
|
||||
% e)
|
||||
|
||||
self._extension_instances[extension.id] = extension
|
||||
|
||||
if extension.has_admin_site:
|
||||
self._init_admin_site(extension)
|
||||
|
||||
# Installing the urls must occur after _init_admin_site(). The urls
|
||||
# for the admin site will not be generated until it is called.
|
||||
self._install_admin_urls(extension)
|
||||
|
||||
extension.info.installed = extension.registration.installed
|
||||
extension.info.enabled = True
|
||||
self._add_to_installed_apps(extension)
|
||||
self._reset_templatetags_cache()
|
||||
extension_initialized.send(self, ext_class=extension)
|
||||
|
||||
return extension
|
||||
|
||||
def _uninit_extension(self, extension):
|
||||
"""Uninitializes the extension.
|
||||
|
||||
This will shut down the extension, remove any URLs, remove it from
|
||||
Django's list of apps, and send a signal saying the extension was
|
||||
shut down.
|
||||
"""
|
||||
extension.shutdown()
|
||||
|
||||
if hasattr(extension, "admin_urlpatterns"):
|
||||
self.dynamic_urls.remove_patterns(
|
||||
extension.admin_urlpatterns)
|
||||
|
||||
if hasattr(extension, "admin_site_urlpatterns"):
|
||||
self.dynamic_urls.remove_patterns(
|
||||
extension.admin_site_urlpatterns)
|
||||
|
||||
if extension.has_admin_site:
|
||||
del extension.admin_site
|
||||
|
||||
self._remove_from_installed_apps(extension)
|
||||
self._reset_templatetags_cache()
|
||||
extension.info.enabled = False
|
||||
extension_uninitialized.send(self, ext_class=extension)
|
||||
|
||||
del self._extension_instances[extension.id]
|
||||
|
||||
def _reset_templatetags_cache(self):
|
||||
"""Clears the Django templatetags_modules cache."""
|
||||
# We'll import templatetags_modules here because
|
||||
# we want the most recent copy of templatetags_modules
|
||||
from django.template.base import get_templatetags_modules, \
|
||||
templatetags_modules
|
||||
# Wipe out the contents
|
||||
del(templatetags_modules[:])
|
||||
|
||||
# And reload the cache
|
||||
get_templatetags_modules()
|
||||
|
||||
def _install_extension(self, ext_class):
|
||||
"""Installs extension data.
|
||||
|
||||
Performs any installation necessary for an extension.
|
||||
This will install the contents of htdocs into the
|
||||
EXTENSIONS_STATIC_ROOT directory.
|
||||
"""
|
||||
ext_path = ext_class.info.htdocs_path
|
||||
ext_path_exists = os.path.exists(ext_path)
|
||||
|
||||
if ext_path_exists:
|
||||
# First, get rid of the old htdocs contents, so we can start
|
||||
# fresh.
|
||||
shutil.rmtree(ext_path, ignore_errors=True)
|
||||
|
||||
if pkg_resources.resource_exists(ext_class.__module__, "htdocs"):
|
||||
# Now install any new htdocs contents.
|
||||
extracted_path = \
|
||||
pkg_resources.resource_filename(ext_class.__module__, "htdocs")
|
||||
|
||||
shutil.copytree(extracted_path, ext_path, symlinks=True)
|
||||
|
||||
# Mark the extension as installed
|
||||
ext_class.registration.installed = True
|
||||
ext_class.registration.save()
|
||||
|
||||
# Now let's build any tables that this extension might need
|
||||
self._add_to_installed_apps(ext_class)
|
||||
|
||||
# Call syncdb to create the new tables
|
||||
loading.cache.loaded = False
|
||||
call_command('syncdb', verbosity=0, interactive=False)
|
||||
|
||||
# Run evolve to do any table modification
|
||||
try:
|
||||
evolution = Evolution()
|
||||
evolution.evolve(verbosity=0, interactive=False,
|
||||
execute=True, hint=False,
|
||||
compile_sql=False, purge=False,
|
||||
database=False)
|
||||
except CommandError, e:
|
||||
# Something went wrong while running django-evolution, so
|
||||
# grab the output. We can't raise right away because we
|
||||
# still need to put stdout back the way it was
|
||||
logging.error(e.message)
|
||||
raise InstallExtensionError(e.message)
|
||||
|
||||
# Remove this again, since we only needed it for syncdb and
|
||||
# evolve. _init_extension will add it again later in
|
||||
# the install.
|
||||
self._remove_from_installed_apps(ext_class)
|
||||
|
||||
# Mark the extension as installed
|
||||
ext_class.registration.installed = True
|
||||
ext_class.registration.save()
|
||||
|
||||
def _uninstall_extension(self, extension):
|
||||
"""Uninstalls extension data.
|
||||
|
||||
Performs any uninstallation necessary for an extension.
|
||||
This will uninstall the contents of
|
||||
EXTENSIONS_STATIC_ROOT/extension-name/.
|
||||
"""
|
||||
ext_path = extension.info.htdocs_path
|
||||
ext_path_exists = os.path.exists(ext_path)
|
||||
|
||||
if ext_path_exists:
|
||||
shutil.rmtree(ext_path, ignore_errors=True)
|
||||
|
||||
def _install_admin_urls(self, extension):
|
||||
"""Installs administration URLs.
|
||||
|
||||
This provides URLs for configuring an extension, plus any additional
|
||||
admin urlpatterns that the extension provides.
|
||||
"""
|
||||
prefix = self.get_absolute_url()
|
||||
|
||||
if hasattr(settings, 'SITE_ROOT'):
|
||||
prefix = prefix.lstrip(settings.SITE_ROOT)
|
||||
|
||||
# Note that we're adding to the resolve list on the root of the
|
||||
# install, and prefixing it with the admin extensions path.
|
||||
# The reason we're not just making this a child of our extensions
|
||||
# urlconf is that everything in there gets passed an
|
||||
# extension_manager variable, and we don't want to force extensions
|
||||
# to handle this.
|
||||
|
||||
if extension.is_configurable:
|
||||
urlconf = extension.admin_urlconf
|
||||
if hasattr(urlconf, "urlpatterns"):
|
||||
extension.admin_urlpatterns = patterns('',
|
||||
(r'^%s%s/config/' % (prefix, extension.id),
|
||||
include(urlconf.__name__)))
|
||||
|
||||
self.dynamic_urls.add_patterns(
|
||||
extension.admin_urlpatterns)
|
||||
|
||||
if extension.has_admin_site:
|
||||
extension.admin_site_urlpatterns = patterns('',
|
||||
(r'^%s%s/db/' % (prefix, extension.id),
|
||||
include(extension.admin_site.urls)))
|
||||
|
||||
self.dynamic_urls.add_patterns(
|
||||
extension.admin_site_urlpatterns)
|
||||
|
||||
def _init_admin_site(self, extension):
|
||||
"""Creates and initializes an admin site for an extension.
|
||||
|
||||
This creates the admin site and imports the extensions admin
|
||||
module to register the models.
|
||||
|
||||
The url patterns for the admin site are generated in
|
||||
_install_admin_urls().
|
||||
"""
|
||||
extension.admin_site = AdminSite(extension.info.app_name)
|
||||
|
||||
# Import the extension's admin module.
|
||||
try:
|
||||
admin_module_name = '%s.admin' % extension.info.app_name
|
||||
if admin_module_name in sys.modules:
|
||||
# If the extension has been loaded previously and
|
||||
# we are re-enabling it, we must reload the module.
|
||||
# Just importing again will not cause the ModelAdmins
|
||||
# to be registered.
|
||||
reload(sys.modules[admin_module_name])
|
||||
else:
|
||||
import_module(admin_module_name)
|
||||
except ImportError:
|
||||
mod = import_module(extension.info.app_name)
|
||||
|
||||
# Decide whether to bubble up this error. If the app just
|
||||
# doesn't have an admin module, we can ignore the error
|
||||
# attempting to import it, otherwise we want it to bubble up.
|
||||
if module_has_submodule(mod, 'admin'):
|
||||
raise ImportError(
|
||||
"Importing admin module for extension %s failed"
|
||||
% extension.info.app_name)
|
||||
|
||||
def _add_to_installed_apps(self, extension):
|
||||
for app in extension.apps or [extension.info.app_name]:
|
||||
if app not in settings.INSTALLED_APPS:
|
||||
settings.INSTALLED_APPS.append(app)
|
||||
|
||||
def _remove_from_installed_apps(self, extension):
|
||||
for app in extension.apps or [extension.info.app_name]:
|
||||
if app in settings.INSTALLED_APPS:
|
||||
settings.INSTALLED_APPS.remove(app)
|
||||
|
||||
def _entrypoint_iterator(self):
|
||||
return pkg_resources.iter_entry_points(self.key)
|
||||
|
||||
def _bump_sync_gen(self):
|
||||
"""Bumps the synchronization generation value.
|
||||
|
||||
If there's an existing synchronization generation in cache,
|
||||
increment it. Otherwise, start fresh with a new one.
|
||||
"""
|
||||
try:
|
||||
self._last_sync_gen = cache.incr(self._sync_key)
|
||||
except ValueError:
|
||||
self._last_sync_gen = self._add_new_sync_gen()
|
||||
|
||||
def _add_new_sync_gen(self):
|
||||
val = time.mktime(datetime.datetime.now().timetuple())
|
||||
return cache.add(self._sync_key, int(val))
|
||||
|
||||
|
||||
def get_extension_managers():
|
||||
return _extension_managers
|
|
@ -0,0 +1,45 @@
|
|||
#
|
||||
# errors.py -- Extension errors.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
class EnablingExtensionError(Exception):
|
||||
"""An extension could not be enabled."""
|
||||
pass
|
||||
|
||||
|
||||
class DisablingExtensionError(Exception):
|
||||
"""An extension could not be disabled."""
|
||||
pass
|
||||
|
||||
|
||||
class InstallExtensionError(Exception):
|
||||
"""An extension could not be installed."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidExtensionError(Exception):
|
||||
"""An extension does not exist."""
|
||||
def __init__(self, extension_id):
|
||||
super(InvalidExtensionError, self).__init__()
|
||||
self.message = "Cannot find extension with id %s" % extension_id
|
|
@ -0,0 +1,41 @@
|
|||
#
|
||||
# forms.py -- Form classes useful for extensions
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django import forms
|
||||
|
||||
from djblets.siteconfig.forms import SiteSettingsForm
|
||||
|
||||
|
||||
class SettingsForm(SiteSettingsForm):
|
||||
"""Settings form for extension configuration.
|
||||
|
||||
A base form for loading/saving settings for an extension. This is meant
|
||||
to be overridden by extensions to provide configuration pages. Any fields
|
||||
defined by the form will be loaded and saved automatically.
|
||||
"""
|
||||
def __init__(self, extension, *args, **kwargs):
|
||||
self.extension = extension
|
||||
|
||||
super(SettingsForm, self).__init__(extension.settings, *args, **kwargs)
|
|
@ -0,0 +1,122 @@
|
|||
#
|
||||
# hooks.py -- Common extension hook points.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django.core.urlresolvers import NoReverseMatch, reverse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from djblets.extensions.base import ExtensionHook, ExtensionHookPoint
|
||||
|
||||
|
||||
class URLHook(ExtensionHook):
|
||||
"""Custom URL hook.
|
||||
|
||||
A hook that installs custom URLs. These URLs reside in a project-specified
|
||||
parent URL.
|
||||
"""
|
||||
__metaclass__ = ExtensionHookPoint
|
||||
|
||||
def __init__(self, extension, patterns):
|
||||
super(URLHook, self).__init__(extension)
|
||||
self.patterns = patterns
|
||||
self.dynamic_urls = self.extension.extension_manager.dynamic_urls
|
||||
self.dynamic_urls.add_patterns(patterns)
|
||||
|
||||
def shutdown(self):
|
||||
super(URLHook, self).shutdown()
|
||||
|
||||
self.dynamic_urls.remove_patterns(self.patterns)
|
||||
|
||||
|
||||
class TemplateHook(ExtensionHook):
|
||||
"""Custom templates hook.
|
||||
|
||||
A hook that renders a template at hook points defined in another template.
|
||||
"""
|
||||
__metaclass__ = ExtensionHookPoint
|
||||
|
||||
_by_name = {}
|
||||
|
||||
def __init__(self, extension, name, template_name=None, apply_to=[]):
|
||||
super(TemplateHook, self).__init__(extension)
|
||||
self.name = name
|
||||
self.template_name = template_name
|
||||
self.apply_to = apply_to
|
||||
|
||||
if not name in self.__class__._by_name:
|
||||
self.__class__._by_name[name] = [self]
|
||||
else:
|
||||
self.__class__._by_name[name].append(self)
|
||||
|
||||
def shutdown(self):
|
||||
super(TemplateHook, self).shutdown()
|
||||
|
||||
self.__class__._by_name[self.name].remove(self)
|
||||
|
||||
def render_to_string(self, request, context):
|
||||
"""Renders the content for the hook.
|
||||
|
||||
By default, this renders the provided template name to a string
|
||||
and returns it.
|
||||
"""
|
||||
return render_to_string(self.template_name, context)
|
||||
|
||||
def applies_to(self, context):
|
||||
"""Returns whether or not this TemplateHook should be applied given the
|
||||
current context.
|
||||
"""
|
||||
|
||||
# If apply_to is empty, this means we apply to all - so
|
||||
# return true
|
||||
if not self.apply_to:
|
||||
return True
|
||||
|
||||
# Extensions Middleware stashes the kwargs into the context
|
||||
kwargs = context['request']._djblets_extensions_kwargs
|
||||
current_url = context['request'].path_info
|
||||
|
||||
# For each URL name in apply_to, check to see if the reverse
|
||||
# URL matches the current URL.
|
||||
for applicable in self.apply_to:
|
||||
try:
|
||||
reverse_url = reverse(applicable, args=(), kwargs=kwargs)
|
||||
except NoReverseMatch:
|
||||
# It's possible that the URL we're reversing doesn't take
|
||||
# any arguments.
|
||||
try:
|
||||
reverse_url = reverse(applicable)
|
||||
except NoReverseMatch:
|
||||
# No matches here, move along.
|
||||
continue
|
||||
|
||||
# If we got here, we found a reversal. Let's compare to the
|
||||
# current URL
|
||||
if reverse_url == current_url:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def by_name(cls, name):
|
||||
return cls._by_name.get(name, [])
|
|
@ -0,0 +1,50 @@
|
|||
#
|
||||
# loaders.py -- Loaders for extension data.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from pkg_resources import _manager as manager
|
||||
|
||||
from django.template import TemplateDoesNotExist
|
||||
|
||||
from djblets.extensions.base import get_extension_managers
|
||||
|
||||
|
||||
def load_template_source(template_name, template_dirs=None):
|
||||
"""Loads templates from enabled extensions."""
|
||||
if manager:
|
||||
resource = "templates/" + template_name
|
||||
|
||||
for extmgr in get_extension_managers():
|
||||
for ext in extmgr.get_enabled_extensions():
|
||||
package = ext.info.app_name
|
||||
|
||||
try:
|
||||
return (manager.resource_string(package, resource),
|
||||
'extension:%s:%s ' % (package, resource))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise TemplateDoesNotExist, template_name
|
||||
|
||||
load_template_source.is_usable = manager is not None
|
|
@ -0,0 +1,50 @@
|
|||
#
|
||||
# middleware.py -- Middleware for extensions.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from djblets.extensions.base import get_extension_managers
|
||||
|
||||
|
||||
class ExtensionsMiddleware(object):
|
||||
"""Middleware to manage extension lifecycles and data."""
|
||||
def process_request(self, request):
|
||||
self._check_expired()
|
||||
|
||||
def process_view(self, request, view, args, kwargs):
|
||||
request._djblets_extensions_kwargs = kwargs
|
||||
|
||||
def _check_expired(self):
|
||||
"""Checks each ExtensionManager for expired extension state.
|
||||
|
||||
When the list of extensions on an ExtensionManager changes, or when
|
||||
the configuration of an extension changes, any other threads/processes
|
||||
holding onto extensions and configuration will go stale. This function
|
||||
will check each of those to see if they need to re-load their
|
||||
state.
|
||||
|
||||
This is meant to be called before every HTTP request.
|
||||
"""
|
||||
for extension_manager in get_extension_managers():
|
||||
if extension_manager.is_expired():
|
||||
extension_manager.load(full_reload=True)
|
|
@ -0,0 +1,67 @@
|
|||
#
|
||||
# models.py -- Extension models.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django.db import models
|
||||
|
||||
from djblets.extensions.errors import InvalidExtensionError
|
||||
from djblets.util.fields import JSONField
|
||||
|
||||
|
||||
class RegisteredExtension(models.Model):
|
||||
"""Extension registration info.
|
||||
|
||||
An extension that was both installed and enabled at least once. This
|
||||
may contain settings for the extension.
|
||||
|
||||
This does not contain full information for the extension, such as the
|
||||
author or description. That is provided by the Extension object itself.
|
||||
"""
|
||||
class_name = models.CharField(max_length=128, unique=True)
|
||||
name = models.CharField(max_length=32)
|
||||
enabled = models.BooleanField(default=False)
|
||||
installed = models.BooleanField(default=False)
|
||||
settings = JSONField()
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_extension_class(self):
|
||||
"""Retrieves the python object for the extensions class."""
|
||||
try:
|
||||
# Import the function here to avoid a mutual
|
||||
# dependency.
|
||||
from djblets.extensions.base import get_extension_managers
|
||||
except:
|
||||
return None
|
||||
|
||||
managers = get_extension_managers()
|
||||
for manager in managers:
|
||||
try:
|
||||
extension = manager.get_installed_extension(self.class_name)
|
||||
return extension
|
||||
except InvalidExtensionError:
|
||||
continue
|
||||
|
||||
return None
|
|
@ -0,0 +1,198 @@
|
|||
from django.conf.urls.defaults import patterns, include
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from djblets.extensions.base import RegisteredExtension
|
||||
from djblets.extensions.errors import DisablingExtensionError, \
|
||||
EnablingExtensionError, \
|
||||
InvalidExtensionError
|
||||
from djblets.util.urlresolvers import DynamicURLResolver
|
||||
from djblets.webapi.decorators import webapi_login_required, \
|
||||
webapi_permission_required, \
|
||||
webapi_request_fields
|
||||
from djblets.webapi.errors import DOES_NOT_EXIST, \
|
||||
ENABLE_EXTENSION_FAILED, \
|
||||
DISABLE_EXTENSION_FAILED
|
||||
from djblets.webapi.resources import WebAPIResource
|
||||
|
||||
|
||||
class ExtensionResource(WebAPIResource):
|
||||
"""A default resource for representing an Extension model."""
|
||||
model = RegisteredExtension
|
||||
fields = ('class_name', 'name', 'enabled', 'installed')
|
||||
name = 'extension'
|
||||
plural_name = 'extensions'
|
||||
uri_object_key = 'extension_name'
|
||||
uri_object_key_regex = '[.A-Za-z0-9_-]+'
|
||||
model_object_key = 'class_name'
|
||||
|
||||
allowed_methods = ('GET', 'PUT',)
|
||||
|
||||
def __init__(self, extension_manager):
|
||||
super(ExtensionResource, self).__init__()
|
||||
self._extension_manager = extension_manager
|
||||
self._dynamic_patterns = DynamicURLResolver()
|
||||
self._resource_url_patterns_map = {}
|
||||
|
||||
# We want ExtensionResource to notice when extensions are
|
||||
# initialized or uninitialized, so connect some methods to
|
||||
# those signals.
|
||||
from djblets.extensions.signals import extension_initialized, \
|
||||
extension_uninitialized
|
||||
extension_initialized.connect(self._on_extension_initialized)
|
||||
extension_uninitialized.connect(self._on_extension_uninitialized)
|
||||
|
||||
@webapi_login_required
|
||||
def get_list(self, request, *args, **kwargs):
|
||||
return WebAPIResource.get_list(self, request, *args, **kwargs)
|
||||
|
||||
@webapi_login_required
|
||||
@webapi_permission_required('extensions.change_registeredextension')
|
||||
@webapi_request_fields(
|
||||
required={
|
||||
'enabled': {
|
||||
'type': bool,
|
||||
'description': 'Whether or not to make the extension active.'
|
||||
},
|
||||
},
|
||||
)
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Try to find the registered extension
|
||||
try:
|
||||
registered_extension = self.get_object(request, *args, **kwargs)
|
||||
except ObjectDoesNotExist:
|
||||
return DOES_NOT_EXIST
|
||||
|
||||
try:
|
||||
ext_class = self._extension_manager.get_installed_extension(
|
||||
registered_extension.class_name)
|
||||
except InvalidExtensionError:
|
||||
return DOES_NOT_EXIST
|
||||
|
||||
if kwargs.get('enabled'):
|
||||
try:
|
||||
self._extension_manager.enable_extension(ext_class.id)
|
||||
except (EnablingExtensionError, InvalidExtensionError), e:
|
||||
return ENABLE_EXTENSION_FAILED.with_message(e.message)
|
||||
else:
|
||||
try:
|
||||
self._extension_manager.disable_extension(ext_class.id)
|
||||
except (DisablingExtensionError, InvalidExtensionError), e:
|
||||
return DISABLE_EXTENSION_FAILED.with_message(e.message)
|
||||
|
||||
# Refetch extension, since the ExtensionManager may have changed
|
||||
# the model.
|
||||
registered_extension = self.get_object(request, *args, **kwargs)
|
||||
|
||||
return 200, {
|
||||
self.item_result_key: registered_extension
|
||||
}
|
||||
|
||||
def get_url_patterns(self):
|
||||
# We want extension resource URLs to be dynamically modifiable,
|
||||
# so we override get_url_patterns in order to capture and store
|
||||
# a reference to the url_patterns at /api/extensions/.
|
||||
url_patterns = super(ExtensionResource, self).get_url_patterns()
|
||||
url_patterns += patterns('', self._dynamic_patterns)
|
||||
|
||||
return url_patterns
|
||||
|
||||
def get_related_links(self, obj=None, request=None, *args, **kwargs):
|
||||
"""Returns links to the resources provided by the extension.
|
||||
|
||||
The result should be a dictionary of link names to a dictionary of
|
||||
information. The information should contain:
|
||||
|
||||
* 'method' - The HTTP method
|
||||
* 'href' - The URL
|
||||
* 'title' - The title of the link (optional)
|
||||
* 'resource' - The WebAPIResource instance
|
||||
* 'list-resource' - True if this links to a list resource (optional)
|
||||
"""
|
||||
links = {}
|
||||
|
||||
if obj and obj.enabled:
|
||||
extension = obj.get_extension_class()
|
||||
|
||||
if not extension:
|
||||
return links
|
||||
|
||||
for resource in extension.resources:
|
||||
links[resource.name_plural] = {
|
||||
'method': 'GET',
|
||||
'href': "%s%s/" % (
|
||||
self.get_href(obj, request, *args, **kwargs),
|
||||
resource.uri_name),
|
||||
'resource': resource,
|
||||
'list-resource': not resource.singleton,
|
||||
}
|
||||
|
||||
return links
|
||||
|
||||
def _attach_extension_resources(self, extension):
|
||||
"""
|
||||
Attaches an extensions resources to
|
||||
/api/extensions/{extension.id}/.
|
||||
"""
|
||||
|
||||
# Bail out if there are no resources to attach
|
||||
if not extension.resources:
|
||||
return
|
||||
|
||||
if extension in self._resource_url_patterns_map:
|
||||
# This extension already had its urlpatterns
|
||||
# mapped and attached. Nothing to do here.
|
||||
return
|
||||
|
||||
# We're going to store references to the URL patterns
|
||||
# that are generated for this extension's resources.
|
||||
self._resource_url_patterns_map[extension] = []
|
||||
|
||||
# For each resource, generate the URLs
|
||||
for resource in extension.resources:
|
||||
self._resource_url_patterns_map[extension].extend(patterns('',
|
||||
(r'^%s/%s/' % (extension.id, resource.uri_name),
|
||||
include(resource.get_url_patterns()))))
|
||||
|
||||
self._dynamic_patterns.add_patterns(
|
||||
self._resource_url_patterns_map[extension])
|
||||
|
||||
def _unattach_extension_resources(self, extension):
|
||||
"""
|
||||
Unattaches an extensions resources from
|
||||
/api/extensions/{extension.id}/.
|
||||
"""
|
||||
|
||||
# Bail out if there are no resources for this extension
|
||||
if not extension.resources:
|
||||
return
|
||||
|
||||
# If this extension has never had its resource URLs
|
||||
# generated, then we don't have anything to worry
|
||||
# about.
|
||||
if not extension in self._resource_url_patterns_map:
|
||||
return
|
||||
|
||||
# Remove the URL patterns
|
||||
self._dynamic_patterns.remove_patterns(
|
||||
self._resource_url_patterns_map[extension])
|
||||
|
||||
# Delete the URL patterns so that we can regenerate
|
||||
# them when the extension is re-enabled. This is to
|
||||
# avoid caching incorrect URL patterns during extension
|
||||
# development, when extension resources are likely to
|
||||
# change.
|
||||
del self._resource_url_patterns_map[extension]
|
||||
|
||||
def _on_extension_initialized(self, sender, ext_class=None, **kwargs):
|
||||
"""
|
||||
Signal handler that notices when an extension has
|
||||
been initialized.
|
||||
"""
|
||||
self._attach_extension_resources(ext_class)
|
||||
|
||||
def _on_extension_uninitialized(self, sender, ext_class=None, **kwargs):
|
||||
"""
|
||||
Signal handler that notices and reacts when an extension
|
||||
has been uninitialized.
|
||||
"""
|
||||
self._unattach_extension_resources(ext_class)
|
|
@ -0,0 +1,30 @@
|
|||
#
|
||||
# signals.py -- Extension-related signals.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import django.dispatch
|
||||
|
||||
|
||||
extension_initialized = django.dispatch.Signal(providing_args=["ext_class"])
|
||||
extension_uninitialized = django.dispatch.Signal(providing_args=["ext_class"])
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "siteconfig/settings.html" %}
|
||||
{% load djblets_utils i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% blocktrans with extension.info.name as name %}Configure {{name}}{% endblocktrans %}
|
||||
{{block.super}}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_title %}
|
||||
{% blocktrans with extension.info.name as name %}Configure {{name}}{% endblocktrans %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,26 @@
|
|||
<script type="text/javascript">
|
||||
|
||||
var SITE_ROOT = "{{SITE_ROOT}}";
|
||||
|
||||
$(document).ready(function() {
|
||||
/*
|
||||
* Extension IDs have periods in them. In order to select them
|
||||
* with jQuery, the periods must be escaped with double slashes.
|
||||
*/
|
||||
|
||||
{% for extension in extensions %}
|
||||
$('#' + ('{{extension.id}}'.replace(/\./g, '\\.')) + '-enable')
|
||||
.click(function() {
|
||||
send_extension_webapi_request('{{extension.id}}', {enabled: "True"});
|
||||
return false;
|
||||
}
|
||||
);
|
||||
$('#' + ('{{extension.id}}'.replace(/\./g, '\\.')) + '-disable')
|
||||
.click(function() {
|
||||
send_extension_webapi_request('{{extension.id}}', {enabled: "False"});
|
||||
return false;
|
||||
}
|
||||
);
|
||||
{% endfor %}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,78 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load adminmedia admin_list djblets_extensions i18n staticfiles %}
|
||||
|
||||
{% block title %}{% trans "Manage Extensions" %} {{block.super}}{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
|
||||
<link rel="stylesheet" type="text/css" href="{% static "css/admin.css" %}" />
|
||||
<link rel="stylesheet" type="text/css" href="{% static "css/extensions.css" %}" />
|
||||
{% include "js/jquery.html" %}
|
||||
{% include "js/jquery-ui.html" %}
|
||||
<script type="text/javascript" src="{% static "js/jquery.gravy.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "js/extensions.js" %}"></script>
|
||||
|
||||
{{block.super}}
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{block.super}} extensions-list-page{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include "extensions/extension_dlgs.html" %}
|
||||
|
||||
<h1 class="title">{% trans "Manage Extensions" %}</h1>
|
||||
|
||||
<div id="content-main">
|
||||
{% if extensions %}
|
||||
<ul class="extensions">
|
||||
{% for extension in extensions %}
|
||||
<li class="extension {% cycle row1,row2 %}">
|
||||
<div class="extension-header">
|
||||
<h1>{{extension.info.name}} <span class="version">{{extension.info.version}}</span></h1>
|
||||
<p class="author">
|
||||
{% if extension.info.author_url %}
|
||||
<a href="{{extension.info.author_url}}">
|
||||
{% endif %}
|
||||
{{extension.info.author}}
|
||||
{% if extension.info.author_url %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="description">
|
||||
{{extension.info.summary}}
|
||||
</div>
|
||||
<ul class="object-tools">
|
||||
{% if extension.info.enabled %}
|
||||
<li><a id="{{extension.id}}-disable" href="#" class="disablelink">Disable</a></li>
|
||||
{% if extension.info.is_configurable %}
|
||||
<li><a href="{{extension.id}}/config/" class="changelink">Configure</a></li>
|
||||
{% endif %}
|
||||
{% if extension.info.has_admin_site %}
|
||||
<li><a href="{{extension.id}}/db/" class="changelink">Database</a></li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li><a id="{{extension.id}}-enable" href="#" class="enablelink">Enable</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if not extension.info.enabled %}
|
||||
{% if extension.has_disabled_requirements %}
|
||||
<h4>{% trans "Enabling this will also enable the following extension(s):" %}</h4>
|
||||
<ul>
|
||||
{% for requirement in extension.info.requirements %}
|
||||
{% if not requirement.info.enabled %}
|
||||
<li>{{requirement.info.name}}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{% trans "There are no extensions installed." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,22 @@
|
|||
from django import template
|
||||
|
||||
from djblets.extensions.hooks import TemplateHook
|
||||
from djblets.util.decorators import basictag
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.tag
|
||||
@basictag(takes_context=True)
|
||||
def template_hook_point(context, name):
|
||||
"""
|
||||
Registers a template hook point that TemplateHook instances can
|
||||
attach to.
|
||||
"""
|
||||
s = ""
|
||||
for hook in TemplateHook.by_name(name):
|
||||
if hook.applies_to(context):
|
||||
s += hook.render_to_string(context.get('request', None), context)
|
||||
|
||||
return s
|
|
@ -0,0 +1,6 @@
|
|||
from django.conf.urls.defaults import patterns
|
||||
|
||||
|
||||
urlpatterns = patterns('djblets.extensions.views',
|
||||
(r'^$', 'test_url')
|
||||
)
|
|
@ -0,0 +1,572 @@
|
|||
#
|
||||
# tests.py -- Unit tests for extensions.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.defaults import include, patterns
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from mock import Mock
|
||||
|
||||
from djblets.extensions.base import _extension_managers, Extension, \
|
||||
ExtensionHook, ExtensionHookPoint, \
|
||||
ExtensionInfo, ExtensionManager, \
|
||||
Settings
|
||||
from djblets.extensions.hooks import TemplateHook, URLHook
|
||||
from djblets.testing.testcases import TestCase
|
||||
|
||||
|
||||
class SettingsTest(TestCase):
|
||||
def setUp(self):
|
||||
# Build up a mocked extension
|
||||
self.extension = Mock()
|
||||
self.extension.registration = Mock()
|
||||
self.test_dict = {
|
||||
'test_key1': 'test_value1',
|
||||
'test_key2': 'test_value2',
|
||||
}
|
||||
self.extension.registration.settings = self.test_dict
|
||||
self.settings = Settings(self.extension)
|
||||
|
||||
def test_constructor(self):
|
||||
"""Testing the Extension's Settings constructor"""
|
||||
# Build the Settings objects
|
||||
self.assertEqual(self.extension, self.settings.extension)
|
||||
|
||||
# Ensure that the registration settings dict gets
|
||||
# added to this Settings
|
||||
self.assertEqual(self.test_dict['test_key1'],
|
||||
self.settings['test_key1'])
|
||||
|
||||
def test_load_updates_dict(self):
|
||||
"""Testing that Settings.load correctly updates core dict"""
|
||||
new_dict = {
|
||||
'test_new_key': 'test_new_value',
|
||||
'test_key1': 'new_value',
|
||||
}
|
||||
self.extension.registration.settings = new_dict
|
||||
self.settings.load()
|
||||
|
||||
# Should have added test_new_key, and modified test_key1
|
||||
self.assertEqual(new_dict['test_new_key'],
|
||||
self.settings['test_new_key'])
|
||||
self.assertEqual(new_dict['test_key1'], self.settings['test_key1'])
|
||||
|
||||
# Should have left test_key2 alone
|
||||
self.assertEqual(self.test_dict['test_key2'],
|
||||
self.settings['test_key2'])
|
||||
|
||||
def test_load_silently_discards(self):
|
||||
"""Testing that Settings.load silently ignores invalid settings"""
|
||||
some_string = 'This is a string'
|
||||
self.extension.registration.settings = some_string
|
||||
|
||||
try:
|
||||
self.settings.load()
|
||||
except Exception:
|
||||
self.fail("Shouldn't have raised an exception")
|
||||
|
||||
def test_save_updates_database(self):
|
||||
"""Testing that Settings.save will correctly update registration"""
|
||||
registration = self.extension.registration
|
||||
self.settings['test_new_key'] = 'Test new value'
|
||||
generated_dict = dict(self.settings)
|
||||
self.settings.save()
|
||||
|
||||
self.assertTrue(registration.save.called)
|
||||
self.assertEqual(generated_dict, registration.settings)
|
||||
|
||||
|
||||
class TestExtensionWithRegistration(Extension):
|
||||
"""Dummy extension for testing."""
|
||||
registration = Mock()
|
||||
registration.settings = dict()
|
||||
|
||||
|
||||
class ExtensionTest(TestCase):
|
||||
def setUp(self):
|
||||
manager = ExtensionManager('')
|
||||
self.extension = \
|
||||
TestExtensionWithRegistration(extension_manager=manager)
|
||||
|
||||
for index in range(0, 5):
|
||||
self.extension.hooks.add(Mock())
|
||||
|
||||
def test_extension_constructor(self):
|
||||
"""Testing Extension construction"""
|
||||
self.assertEqual(type(self.extension.settings), Settings)
|
||||
self.assertEqual(self.extension, self.extension.settings.extension)
|
||||
|
||||
def test_shutdown(self):
|
||||
"""Testing Extension.shutdown"""
|
||||
self.extension.shutdown()
|
||||
|
||||
for hook in self.extension.hooks:
|
||||
self.assertTrue(hook.shutdown.called)
|
||||
|
||||
def test_get_admin_urlconf(self):
|
||||
"""Testing Extension with admin URLConfs"""
|
||||
did_fail = False
|
||||
old_module = self.extension.__class__.__module__
|
||||
self.extension.__class__.__module__ = 'djblets.extensions.test.test'
|
||||
|
||||
try:
|
||||
self.extension._get_admin_urlconf()
|
||||
except ImproperlyConfigured:
|
||||
did_fail = True
|
||||
finally:
|
||||
self.extension.__class__.__module__ = old_module
|
||||
|
||||
if did_fail:
|
||||
self.fail("Should have loaded admin_urls.py")
|
||||
|
||||
|
||||
class ExtensionInfoTest(TestCase):
|
||||
def test_metadata_from_package(self):
|
||||
"""Testing ExtensionInfo metadata from package"""
|
||||
entrypoint = Mock()
|
||||
entrypoint.dist = Mock()
|
||||
|
||||
test_author = 'Test author lorem ipsum'
|
||||
test_description = 'Test description lorem ipsum'
|
||||
test_email = 'Test author@email.com'
|
||||
test_home_page = 'http://www.example.com'
|
||||
test_license = 'Test License MIT GPL Apache Drivers'
|
||||
test_module_name = 'testextension.dummy.dummy'
|
||||
test_module_to_app = 'testextension.dummy'
|
||||
test_project_name = 'TestProjectName'
|
||||
test_summary = 'Test summary lorem ipsum'
|
||||
test_version = '1.0'
|
||||
|
||||
test_htdocs_path = os.path.join(settings.EXTENSIONS_STATIC_ROOT,
|
||||
test_project_name)
|
||||
|
||||
test_metadata = {
|
||||
'Name': test_project_name,
|
||||
'Version': test_version,
|
||||
'Summary': test_summary,
|
||||
'Description': test_description,
|
||||
'Author': test_author,
|
||||
'Author-email': test_email,
|
||||
'License': test_license,
|
||||
'Home-page': test_home_page,
|
||||
}
|
||||
|
||||
entrypoint.dist.get_metadata_lines = Mock(
|
||||
return_value=[
|
||||
"%s: %s" % (key, value)
|
||||
for key, value in test_metadata.iteritems()
|
||||
])
|
||||
|
||||
entrypoint.dist.project_name = test_project_name
|
||||
entrypoint.dist.version = test_version
|
||||
|
||||
ext_class = Mock()
|
||||
ext_class.__module__ = test_module_name
|
||||
ext_class.metadata = None
|
||||
extension_info = ExtensionInfo(entrypoint, ext_class)
|
||||
|
||||
self.assertEqual(extension_info.app_name, test_module_to_app)
|
||||
self.assertEqual(extension_info.author, test_author)
|
||||
self.assertEqual(extension_info.author_email, test_email)
|
||||
self.assertEqual(extension_info.description, test_description)
|
||||
self.assertFalse(extension_info.enabled)
|
||||
self.assertEqual(extension_info.htdocs_path, test_htdocs_path)
|
||||
self.assertFalse(extension_info.installed)
|
||||
self.assertEqual(extension_info.license, test_license)
|
||||
self.assertEqual(extension_info.metadata, test_metadata)
|
||||
self.assertEqual(extension_info.name, test_project_name)
|
||||
self.assertEqual(extension_info.summary, test_summary)
|
||||
self.assertEqual(extension_info.url, test_home_page)
|
||||
self.assertEqual(extension_info.version, test_version)
|
||||
|
||||
def test_custom_metadata(self):
|
||||
"""Testing ExtensionInfo metadata from Extension.metadata"""
|
||||
entrypoint = Mock()
|
||||
entrypoint.dist = Mock()
|
||||
|
||||
test_author = 'Test author lorem ipsum'
|
||||
test_description = 'Test description lorem ipsum'
|
||||
test_email = 'Test author@email.com'
|
||||
test_home_page = 'http://www.example.com'
|
||||
test_license = 'Test License MIT GPL Apache Drivers'
|
||||
test_module_name = 'testextension.dummy.dummy'
|
||||
test_module_to_app = 'testextension.dummy'
|
||||
test_project_name = 'TestProjectName'
|
||||
test_summary = 'Test summary lorem ipsum'
|
||||
test_version = '1.0'
|
||||
|
||||
test_htdocs_path = os.path.join(settings.EXTENSIONS_STATIC_ROOT,
|
||||
test_project_name)
|
||||
|
||||
test_metadata = {
|
||||
'Name': test_project_name,
|
||||
'Version': test_version,
|
||||
'Summary': test_summary,
|
||||
'Description': test_description,
|
||||
'Author': test_author,
|
||||
'Author-email': test_email,
|
||||
'License': test_license,
|
||||
'Home-page': test_home_page,
|
||||
}
|
||||
|
||||
entrypoint.dist.get_metadata_lines = Mock(
|
||||
return_value=[
|
||||
"%s: %s" % (key, 'Dummy')
|
||||
for key, value in test_metadata.iteritems()
|
||||
])
|
||||
|
||||
entrypoint.dist.project_name = 'Dummy'
|
||||
entrypoint.dist.version = 'Dummy'
|
||||
|
||||
ext_class = Mock()
|
||||
ext_class.__module__ = test_module_name
|
||||
ext_class.metadata = test_metadata
|
||||
|
||||
extension_info = ExtensionInfo(entrypoint, ext_class)
|
||||
|
||||
self.assertEqual(extension_info.app_name, test_module_to_app)
|
||||
self.assertEqual(extension_info.author, test_author)
|
||||
self.assertEqual(extension_info.author_email, test_email)
|
||||
self.assertEqual(extension_info.description, test_description)
|
||||
self.assertFalse(extension_info.enabled)
|
||||
self.assertEqual(extension_info.htdocs_path, test_htdocs_path)
|
||||
self.assertFalse(extension_info.installed)
|
||||
self.assertEqual(extension_info.license, test_license)
|
||||
self.assertEqual(extension_info.metadata, test_metadata)
|
||||
self.assertEqual(extension_info.name, test_project_name)
|
||||
self.assertEqual(extension_info.summary, test_summary)
|
||||
self.assertEqual(extension_info.url, test_home_page)
|
||||
self.assertEqual(extension_info.version, test_version)
|
||||
|
||||
|
||||
class TestExtensionHook(ExtensionHook):
|
||||
"""A dummy ExtensionHook to test with"""
|
||||
__metaclass__ = ExtensionHookPoint
|
||||
|
||||
|
||||
class ExtensionHookTest(TestCase):
|
||||
def setUp(self):
|
||||
manager = ExtensionManager('')
|
||||
self.extension = \
|
||||
TestExtensionWithRegistration(extension_manager=manager)
|
||||
self.extension_hook = TestExtensionHook(self.extension)
|
||||
|
||||
def test_registration(self):
|
||||
"""Testing ExtensionHook registration"""
|
||||
self.assertEqual(self.extension, self.extension_hook.extension)
|
||||
self.assertTrue(self.extension_hook in self.extension.hooks)
|
||||
self.assertTrue(self.extension_hook in
|
||||
self.extension_hook.__class__.hooks)
|
||||
|
||||
def test_shutdown(self):
|
||||
"""Testing ExtensionHook.shutdown"""
|
||||
self.extension_hook.shutdown()
|
||||
self.assertTrue(self.extension_hook not in
|
||||
self.extension_hook.__class__.hooks)
|
||||
|
||||
|
||||
class ExtensionHookPointTest(TestCase):
|
||||
def setUp(self):
|
||||
manager = ExtensionManager('')
|
||||
self.extension = \
|
||||
TestExtensionWithRegistration(extension_manager=manager)
|
||||
self.extension_hook_class = TestExtensionHook
|
||||
self.dummy_hook = Mock()
|
||||
self.extension_hook_class.add_hook(self.dummy_hook)
|
||||
|
||||
def test_extension_hook_class_gets_hooks(self):
|
||||
"""Testing ExtensionHookPoint.hooks"""
|
||||
self.assertTrue(hasattr(self.extension_hook_class, "hooks"))
|
||||
|
||||
def test_add_hook(self):
|
||||
"""Testing ExtensionHookPoint.add_hook"""
|
||||
self.assertTrue(self.dummy_hook in self.extension_hook_class.hooks)
|
||||
|
||||
def test_remove_hook(self):
|
||||
"""Testing ExtensionHookPoint.remove_hook"""
|
||||
self.extension_hook_class.remove_hook(self.dummy_hook)
|
||||
self.assertTrue(self.dummy_hook not in self.extension_hook_class.hooks)
|
||||
|
||||
|
||||
class ExtensionManagerTest(TestCase):
|
||||
def setUp(self):
|
||||
class TestExtension(Extension):
|
||||
"""An empty, dummy extension for testing"""
|
||||
pass
|
||||
|
||||
self.key = 'test_key'
|
||||
self.extension_class = TestExtension
|
||||
self.manager = ExtensionManager(self.key)
|
||||
self.fake_entrypoint = Mock()
|
||||
self.fake_entrypoint.load = Mock(return_value=self.extension_class)
|
||||
self.fake_entrypoint.dist = Mock()
|
||||
|
||||
self.test_author = 'Test author lorem ipsum'
|
||||
self.test_description = 'Test description lorem ipsum'
|
||||
self.test_email = 'Test author@email.com'
|
||||
self.test_home_page = 'http://www.example.com'
|
||||
self.test_license = 'Test License MIT GPL Apache Drivers'
|
||||
self.test_module_name = 'testextension.dummy.dummy'
|
||||
self.test_module_to_app = 'testextension.dummy'
|
||||
self.test_project_name = 'TestProjectName'
|
||||
self.test_summary = 'Test summary lorem ipsum'
|
||||
self.test_version = '1.0'
|
||||
|
||||
self.test_htdocs_path = os.path.join(settings.EXTENSIONS_STATIC_ROOT,
|
||||
self.test_project_name)
|
||||
|
||||
self.test_metadata = {
|
||||
'Name': self.test_project_name,
|
||||
'Version': self.test_version,
|
||||
'Summary': self.test_summary,
|
||||
'Description': self.test_description,
|
||||
'Author': self.test_author,
|
||||
'Author-email': self.test_email,
|
||||
'License': self.test_license,
|
||||
'Home-page': self.test_home_page,
|
||||
}
|
||||
|
||||
self.fake_entrypoint.dist.get_metadata_lines = Mock(
|
||||
return_value=[
|
||||
"%s: %s" % (key, value)
|
||||
for key, value in self.test_metadata.iteritems()
|
||||
])
|
||||
|
||||
self.fake_entrypoint.dist.project_name = self.test_project_name
|
||||
self.fake_entrypoint.dist.version = self.test_version
|
||||
|
||||
self.manager._entrypoint_iterator = Mock(
|
||||
return_value=[self.fake_entrypoint]
|
||||
)
|
||||
self.manager.load()
|
||||
|
||||
def tearDown(self):
|
||||
self.manager.clear_sync_cache()
|
||||
|
||||
def test_added_to_extension_managers(self):
|
||||
"""Testing ExtensionManager registration"""
|
||||
self.assertTrue(self.manager in _extension_managers)
|
||||
|
||||
def test_get_enabled_extensions_returns_empty(self):
|
||||
"""Testing ExtensionManager.get_enabled_extensions with no extensions"""
|
||||
self.assertEqual(len(self.manager.get_enabled_extensions()), 0)
|
||||
|
||||
def test_load(self):
|
||||
"""Testing ExtensionManager.get_installed_extensions with loaded extensions"""
|
||||
self.assertEqual(len(self.manager.get_installed_extensions()), 1)
|
||||
self.assertTrue(self.extension_class in
|
||||
self.manager.get_installed_extensions())
|
||||
self.assertTrue(hasattr(self.extension_class, 'info'))
|
||||
self.assertEqual(self.extension_class.info.name,
|
||||
self.test_project_name)
|
||||
self.assertTrue(hasattr(self.extension_class, 'registration'))
|
||||
self.assertEqual(self.extension_class.registration.name,
|
||||
self.test_project_name)
|
||||
|
||||
def test_load_full_reload_hooks(self):
|
||||
"""Testing ExtensionManager.load with full_reload=True"""
|
||||
self.assertEqual(len(self.manager.get_installed_extensions()), 1)
|
||||
|
||||
extension = self.extension_class(extension_manager=self.manager)
|
||||
extension = self.manager.enable_extension(self.extension_class.id)
|
||||
|
||||
URLHook(extension, ())
|
||||
self.assertEqual(len(URLHook.hooks), 1)
|
||||
self.assertEqual(URLHook.hooks[0].extension, extension)
|
||||
|
||||
self.manager.load(full_reload=True)
|
||||
|
||||
self.assertEqual(len(URLHook.hooks), 0)
|
||||
|
||||
def test_extension_list_sync(self):
|
||||
"""Testing ExtensionManager extension list synchronization cross-process."""
|
||||
key = 'extension-list-sync'
|
||||
|
||||
manager1 = ExtensionManager(key)
|
||||
manager2 = ExtensionManager(key)
|
||||
|
||||
for manager in (manager1, manager2):
|
||||
manager._entrypoint_iterator = Mock(
|
||||
return_value=[self.fake_entrypoint]
|
||||
)
|
||||
|
||||
manager1.load()
|
||||
manager2.load()
|
||||
|
||||
self.assertEqual(len(manager1.get_installed_extensions()), 1)
|
||||
self.assertEqual(len(manager2.get_installed_extensions()), 1)
|
||||
self.assertEqual(len(manager1.get_enabled_extensions()), 0)
|
||||
self.assertEqual(len(manager2.get_enabled_extensions()), 0)
|
||||
|
||||
manager1.enable_extension(self.extension_class.id)
|
||||
self.assertEqual(len(manager1.get_enabled_extensions()), 1)
|
||||
self.assertEqual(len(manager2.get_enabled_extensions()), 0)
|
||||
|
||||
self.assertFalse(manager1.is_expired())
|
||||
self.assertTrue(manager2.is_expired())
|
||||
|
||||
manager2.load(full_reload=True)
|
||||
self.assertEqual(len(manager1.get_enabled_extensions()), 1)
|
||||
self.assertEqual(len(manager2.get_enabled_extensions()), 1)
|
||||
self.assertFalse(manager1.is_expired())
|
||||
self.assertFalse(manager2.is_expired())
|
||||
|
||||
def test_extension_settings_sync(self):
|
||||
"""Testing ExtensionManager extension settings synchronization cross-process."""
|
||||
key = 'extension-settings-sync'
|
||||
setting_key = 'foo'
|
||||
setting_val = 'abc123'
|
||||
|
||||
manager1 = ExtensionManager(key)
|
||||
manager2 = ExtensionManager(key)
|
||||
|
||||
for manager in (manager1, manager2):
|
||||
manager._entrypoint_iterator = Mock(
|
||||
return_value=[self.fake_entrypoint]
|
||||
)
|
||||
|
||||
manager1.load()
|
||||
|
||||
extension1 = manager1.enable_extension(self.extension_class.id)
|
||||
|
||||
manager2.load()
|
||||
|
||||
self.assertFalse(manager1.is_expired())
|
||||
self.assertFalse(manager2.is_expired())
|
||||
|
||||
extension2 = manager2.get_enabled_extension(self.extension_class.id)
|
||||
self.assertNotEqual(extension2, None)
|
||||
|
||||
self.assertFalse(setting_key in extension1.settings)
|
||||
self.assertFalse(setting_key in extension2.settings)
|
||||
extension1.settings[setting_key] = setting_val
|
||||
extension1.settings.save()
|
||||
|
||||
self.assertFalse(setting_key in extension2.settings)
|
||||
|
||||
self.assertFalse(manager1.is_expired())
|
||||
self.assertTrue(manager2.is_expired())
|
||||
|
||||
manager2.load(full_reload=True)
|
||||
extension2 = manager2.get_enabled_extension(self.extension_class.id)
|
||||
|
||||
self.assertFalse(manager1.is_expired())
|
||||
self.assertFalse(manager2.is_expired())
|
||||
self.assertTrue(setting_key in extension1.settings)
|
||||
self.assertTrue(setting_key in extension2.settings)
|
||||
self.assertEqual(extension1.settings[setting_key], setting_val)
|
||||
self.assertEqual(extension2.settings[setting_key], setting_val)
|
||||
|
||||
|
||||
class URLHookTest(TestCase):
|
||||
def setUp(self):
|
||||
manager = ExtensionManager('')
|
||||
self.test_extension = \
|
||||
TestExtensionWithRegistration(extension_manager=manager)
|
||||
self.patterns = patterns('',
|
||||
(r'^url_hook_test/', include('djblets.extensions.test.urls')))
|
||||
self.url_hook = URLHook(self.test_extension, self.patterns)
|
||||
|
||||
def test_url_registration(self):
|
||||
"""Testing URLHook URL registration"""
|
||||
self.assertTrue(set(self.patterns)
|
||||
.issubset(set(self.url_hook.dynamic_urls.url_patterns)))
|
||||
# And the URLHook should be added to the extension's list of hooks
|
||||
self.assertTrue(self.url_hook in self.test_extension.hooks)
|
||||
|
||||
def test_shutdown_removes_urls(self):
|
||||
"""Testing URLHook.shutdown"""
|
||||
# On shutdown, a URLHook's patterns should no longer be in its
|
||||
# parent URL resolver's pattern collection.
|
||||
self.url_hook.shutdown()
|
||||
self.assertFalse(
|
||||
set(self.patterns).issubset(
|
||||
set(self.url_hook.dynamic_urls.url_patterns)))
|
||||
|
||||
# But the URLHook should still be in the extension's list of hooks
|
||||
self.assertTrue(self.url_hook in self.test_extension.hooks)
|
||||
|
||||
|
||||
class TemplateHookTest(TestCase):
|
||||
def setUp(self):
|
||||
manager = ExtensionManager('')
|
||||
self.extension = \
|
||||
TestExtensionWithRegistration(extension_manager=manager)
|
||||
self.hook_with_applies_name = "template-hook-with-applies-name"
|
||||
self.hook_no_applies_name = "template-hook-no-applies-name"
|
||||
self.template_hook_no_applies = TemplateHook(self.extension,
|
||||
self.hook_no_applies_name, "test_module/some_template.html", [])
|
||||
self.template_hook_with_applies = TemplateHook(self.extension,
|
||||
self.hook_with_applies_name, "test_module/some_template.html", [
|
||||
'test-url-name',
|
||||
'url_2',
|
||||
'url_3',
|
||||
]
|
||||
)
|
||||
|
||||
self.fake_request = Mock()
|
||||
self.fake_request._djblets_extensions_kwargs = {}
|
||||
self.fake_request.path_info = '/'
|
||||
self.context = {
|
||||
'request': self.fake_request,
|
||||
}
|
||||
|
||||
def test_hook_added_to_class_by_name(self):
|
||||
"""Testing TemplateHook registration"""
|
||||
self.assertTrue(self.template_hook_with_applies in
|
||||
self.template_hook_with_applies.__class__
|
||||
._by_name[self.hook_with_applies_name])
|
||||
|
||||
# The TemplateHook should also be added to the Extension's collection
|
||||
# of hooks.
|
||||
self.assertTrue(self.template_hook_with_applies in
|
||||
self.extension.hooks)
|
||||
|
||||
def test_hook_shutdown(self):
|
||||
"""Testing TemplateHook shutdown"""
|
||||
self.template_hook_with_applies.shutdown()
|
||||
self.assertTrue(self.template_hook_with_applies not in
|
||||
self.template_hook_with_applies.__class__
|
||||
._by_name[self.hook_with_applies_name])
|
||||
|
||||
# The TemplateHook should still be in the Extension's collection
|
||||
# of hooks.
|
||||
self.assertTrue(self.template_hook_with_applies in
|
||||
self.extension.hooks)
|
||||
|
||||
def test_applies_to_default(self):
|
||||
"""Testing TemplateHook.applies_to defaults to everything"""
|
||||
self.assertTrue(self.template_hook_no_applies.applies_to(self.context))
|
||||
self.assertTrue(self.template_hook_no_applies.applies_to(None))
|
||||
|
||||
def test_applies_to(self):
|
||||
"""Testing TemplateHook.applies_to customization"""
|
||||
self.fake_request.path_info = '/some_other/url'
|
||||
self.assertFalse(
|
||||
self.template_hook_with_applies.applies_to(self.context))
|
||||
|
||||
# A dummy function that acts as a View method
|
||||
test_view_method = Mock()
|
|
@ -0,0 +1,31 @@
|
|||
#
|
||||
# urls.py -- URLs for the Admin UI.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django.conf.urls.defaults import patterns
|
||||
|
||||
|
||||
urlpatterns = patterns('djblets.extensions.views',
|
||||
(r'^$', 'extension_list')
|
||||
)
|
|
@ -0,0 +1,82 @@
|
|||
#
|
||||
# views.py -- Views for the Admin UI.
|
||||
#
|
||||
# Copyright (c) 2010-2011 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext
|
||||
|
||||
|
||||
def _has_disabled_requirements(extension):
|
||||
"""Returns whether an extension has one or more disabled requirements."""
|
||||
for requirement in extension.info.requirements:
|
||||
if not requirement.info.enabled:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def extension_list(request, extension_manager,
|
||||
template_name='extensions/extension_list.html'):
|
||||
# Refresh the extension list.
|
||||
extension_manager.load()
|
||||
|
||||
return render_to_response(template_name, RequestContext(request, {
|
||||
'extensions': [
|
||||
{
|
||||
'id': extension.id,
|
||||
'info': extension.info,
|
||||
'has_disabled_requirements':
|
||||
_has_disabled_requirements(extension),
|
||||
}
|
||||
for extension in extension_manager.get_installed_extensions()
|
||||
]
|
||||
}))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def configure_extension(request, ext_class, form_class, extension_manager,
|
||||
template_name='extensions/configure_extension.html'):
|
||||
extension = extension_manager.get_enabled_extension(ext_class.id)
|
||||
|
||||
if not extension or not extension.is_configurable:
|
||||
raise Http404
|
||||
|
||||
if request.method == 'POST':
|
||||
form = form_class(extension, request.POST, request.FILES)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
|
||||
return HttpResponseRedirect(request.path + '?saved=1')
|
||||
else:
|
||||
form = form_class(extension)
|
||||
|
||||
return render_to_response(template_name, RequestContext(request, {
|
||||
'extension': extension,
|
||||
'form': form,
|
||||
'saved': request.GET.get('saved', 0),
|
||||
}))
|
|
@ -0,0 +1,14 @@
|
|||
{% load feedtags %}
|
||||
{% load i18n %}
|
||||
{% if error %}
|
||||
{% blocktrans with error.reason as error %}<p class="feed-error">Unable to load feed: {{error}}</p>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% for entry in parser.entries|slice:":3" %}
|
||||
<div class="entry">
|
||||
<h3 class="entry-title"><a href="{{entry.link}}">{{entry.title}}</a> - {{entry.updated_parsed|feeddate|date:"M d, Y"}}</h3>
|
||||
<div class="entry-content">
|
||||
{{entry.summary|safe}}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "feedview/feed-inline.html" %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,16 @@
|
|||
import calendar
|
||||
import datetime
|
||||
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def feeddate(datetuple):
|
||||
"""
|
||||
A filter that converts the date tuple provided from feedparser into
|
||||
a datetime object.
|
||||
"""
|
||||
return datetime.datetime.utcfromtimestamp(calendar.timegm(datetuple))
|
|
@ -0,0 +1,18 @@
|
|||
import os.path
|
||||
from django.conf.urls.defaults import patterns, handler500
|
||||
|
||||
|
||||
FEED_URL = "file://%s/testdata/sample.rss" % os.path.dirname(__file__)
|
||||
|
||||
|
||||
urlpatterns = patterns('djblets.feedview.views',
|
||||
('^feed/$', 'view_feed',
|
||||
{'template_name': 'feedview/feed-page.html',
|
||||
'url': FEED_URL}),
|
||||
('^feed-inline/$', 'view_feed',
|
||||
{'template_name': 'feedview/feed-inline.html',
|
||||
'url': FEED_URL}),
|
||||
('^feed-error/$', 'view_feed',
|
||||
{'template_name': 'feedview/feed-inline.html',
|
||||
'url': "http://example.fake/dummy.rss"}),
|
||||
)
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet href="http://feeds.feedburner.com/~d/styles/rss2full.xsl" type="text/xsl" media="screen"?><?xml-stylesheet href="http://feeds.feedburner.com/~d/styles/itemcontent.css" type="text/css" media="screen"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">
|
||||
|
||||
<channel>
|
||||
<title>Review Board News</title>
|
||||
|
||||
<link>http://www.review-board.org/news</link>
|
||||
<description>News on Review Board releases and important updates</description>
|
||||
<pubDate>Wed, 23 Jul 2008 10:27:05 +0000</pubDate>
|
||||
<generator>http://wordpress.org/?v=2.5.1</generator>
|
||||
<language>en</language>
|
||||
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" href="http://feeds.feedburner.com/ReviewBoardNews" type="application/rss+xml" /><item>
|
||||
<title>Django 1.0 alpha released</title>
|
||||
<link>http://feeds.feedburner.com/~r/ReviewBoardNews/~3/343422891/</link>
|
||||
<comments>http://www.review-board.org/news/2008/07/23/django-10-alpha-released/#comments</comments>
|
||||
<pubDate>Wed, 23 Jul 2008 10:27:05 +0000</pubDate>
|
||||
<dc:creator>chipx86</dc:creator>
|
||||
|
||||
<category><![CDATA[Project Updates]]></category>
|
||||
|
||||
<guid isPermaLink="false">http://www.review-board.org/news/?p=7</guid>
|
||||
<description><![CDATA[Django, the web framework Review Board is built upon, is approaching their 1.0 release and have released Django 1.0 alpha for download. In order to keep up with Django development, Review Board has required the Django Subversion development tree to run, requiring that users develop a basic understanding of Subversion and keeping their copy up-to-date.
|
||||
With [...]]]></description>
|
||||
<content:encoded><![CDATA[<p><a href="http://www.djangoproject.com/">Django</a>, the web framework Review Board is built upon, is approaching their 1.0 release and have <a href="http://www.djangoproject.com/download/">released</a> Django 1.0 alpha for download. In order to keep up with Django development, Review Board has required the Django Subversion development tree to run, requiring that users develop a basic understanding of Subversion and keeping their copy up-to-date.</p>
|
||||
<p>With the new Django alpha release, we’re now able to remove this extra burden. As of tonight, Review Board now requires Django 1.0 alpha or higher. Simply download the new release and install it. Of course, if you’re currently using a Subversion checkout of Django, you can continue to use it.</p>
|
||||
<p>The Django 1.0 release will bring us a step closer to our own 1.0. There’s a few major features still planned, and lots of bug fixing. We’ll have a roadmap up in the coming weeks detailing our plans. Stay tuned!</p>
|
||||
<div class="feedflare">
|
||||
<a href="http://feeds.feedburner.com/~f/ReviewBoardNews?a=Ur95Vj"><img src="http://feeds.feedburner.com/~f/ReviewBoardNews?i=Ur95Vj" border="0"></img></a> <a href="http://feeds.feedburner.com/~f/ReviewBoardNews?a=T7dICj"><img src="http://feeds.feedburner.com/~f/ReviewBoardNews?i=T7dICj" border="0"></img></a>
|
||||
</div><img src="http://feeds.feedburner.com/~r/ReviewBoardNews/~4/343422891" height="1" width="1"/>]]></content:encoded>
|
||||
<wfw:commentRss>http://www.review-board.org/news/2008/07/23/django-10-alpha-released/feed/</wfw:commentRss>
|
||||
<feedburner:origLink>http://www.review-board.org/news/2008/07/23/django-10-alpha-released/</feedburner:origLink></item>
|
||||
<item>
|
||||
<title>Introducing Review Board News</title>
|
||||
<link>http://feeds.feedburner.com/~r/ReviewBoardNews/~3/307288497/</link>
|
||||
<comments>http://www.review-board.org/news/2008/05/30/introducing-review-board-news/#comments</comments>
|
||||
<pubDate>Fri, 30 May 2008 23:50:12 +0000</pubDate>
|
||||
<dc:creator>chipx86</dc:creator>
|
||||
|
||||
<category><![CDATA[Site Updates]]></category>
|
||||
|
||||
<guid isPermaLink="false">http://www.review-board.org/news/?p=3</guid>
|
||||
<description><![CDATA[In the past, we’ve always brought information on new Review Board development to our blogs and the mailing list. However, as the software continues to mature, we need a better way of informing people about major releases and important updates, without making them sift through development discussions and technical updates.
|
||||
In the future, we’ll be posting [...]]]></description>
|
||||
<content:encoded><![CDATA[<p>In the past, we’ve always brought information on new Review Board development to our <a href="/blogs/">blogs</a> and the <a href="/http://groups.google.com/group/reviewboard">mailing list</a>. However, as the software continues to mature, we need a better way of informing people about major releases and important updates, without making them sift through development discussions and technical updates.</p>
|
||||
<p>In the future, we’ll be posting critical updates (such as required database migrations) here, as well as software releases (once we hit 1.0). News posts will soon begin to appear in your Review Board administration dashboard, making it easy to keep up. And, of course, you can subscribe to our RSS feed in your favorite news reader.</p>
|
||||
<div class="feedflare">
|
||||
<a href="http://feeds.feedburner.com/~f/ReviewBoardNews?a=XTHVGi"><img src="http://feeds.feedburner.com/~f/ReviewBoardNews?i=XTHVGi" border="0"></img></a> <a href="http://feeds.feedburner.com/~f/ReviewBoardNews?a=HMHeYi"><img src="http://feeds.feedburner.com/~f/ReviewBoardNews?i=HMHeYi" border="0"></img></a>
|
||||
</div><img src="http://feeds.feedburner.com/~r/ReviewBoardNews/~4/307288497" height="1" width="1"/>]]></content:encoded>
|
||||
<wfw:commentRss>http://www.review-board.org/news/2008/05/30/introducing-review-board-news/feed/</wfw:commentRss>
|
||||
<feedburner:origLink>http://www.review-board.org/news/2008/05/30/introducing-review-board-news/</feedburner:origLink></item>
|
||||
</channel>
|
||||
</rss>
|
|
@ -0,0 +1,48 @@
|
|||
#
|
||||
# tests.py -- Unit tests for classes in djblets.feedview
|
||||
#
|
||||
# Copyright (c) 2008 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
from djblets.util.testing import TestCase
|
||||
|
||||
|
||||
class FeedViewTests(TestCase):
|
||||
urls = "djblets.feedview.test_urls"
|
||||
|
||||
def testViewFeedPage(self):
|
||||
"""Testing view_feed with the feed-page.html template"""
|
||||
response = self.client.get('/feed/')
|
||||
self.assertContains(response, "Django 1.0 alpha released", 1)
|
||||
self.assertContains(response, "Introducing Review Board News", 1)
|
||||
|
||||
def testViewFeedInline(self):
|
||||
"""Testing view_feed with the feed-inline.html template"""
|
||||
response = self.client.get('/feed-inline/')
|
||||
self.assertContains(response, "Django 1.0 alpha released", 1)
|
||||
self.assertContains(response, "Introducing Review Board News", 1)
|
||||
|
||||
def testViewFeedError(self):
|
||||
"""Testing view_feed with a URL error"""
|
||||
response = self.client.get('/feed-error/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assert_('error' in response.context)
|
|
@ -0,0 +1,47 @@
|
|||
import httplib
|
||||
import urllib2
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from djblets.util.misc import cache_memoize
|
||||
|
||||
|
||||
DEFAULT_EXPIRATION = 2 * 24 * 60 * 60 # 2 days
|
||||
|
||||
|
||||
def view_feed(request, url, template_name="feedview/feed-page.html",
|
||||
cache_expiration=DEFAULT_EXPIRATION, extra_context={}):
|
||||
"""
|
||||
Renders an RSS or Atom feed using the given template. This will use
|
||||
a cached copy if available in order to reduce hits to the server.
|
||||
"""
|
||||
def fetch_feed():
|
||||
import feedparser
|
||||
|
||||
data = urllib2.urlopen(url).read()
|
||||
|
||||
parser = feedparser.parse(data)
|
||||
|
||||
context = {
|
||||
'parser': parser,
|
||||
}
|
||||
context.update(extra_context)
|
||||
|
||||
return render_to_string(template_name,
|
||||
RequestContext(request, context))
|
||||
|
||||
try:
|
||||
return HttpResponse(cache_memoize("feed-%s" % url, fetch_feed,
|
||||
cache_expiration,
|
||||
force_overwrite=request.GET.has_key("reload")))
|
||||
except (urllib2.URLError, httplib.HTTPException), e:
|
||||
context = {
|
||||
'error': e,
|
||||
}
|
||||
context.update(extra_context)
|
||||
|
||||
return render_to_response(template_name,
|
||||
RequestContext(request, context))
|
|
@ -0,0 +1,70 @@
|
|||
#
|
||||
# __init__.py -- Gravatar functionality
|
||||
#
|
||||
# Copyright (c) 2012 Beanbag, Inc.
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
try:
|
||||
from hashlib import md5
|
||||
except ImportError:
|
||||
from md5 import md5
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_gravatar_url_for_email(request, email, size=None):
|
||||
email_hash = md5(email).hexdigest()
|
||||
|
||||
if request.is_secure():
|
||||
url_base = 'https://secure.gravatar.com'
|
||||
else:
|
||||
url_base = 'http://www.gravatar.com'
|
||||
|
||||
url = "%s/avatar/%s" % (url_base, email_hash)
|
||||
params = []
|
||||
|
||||
if not size and hasattr(settings, "GRAVATAR_SIZE"):
|
||||
size = settings.GRAVATAR_SIZE
|
||||
|
||||
if size:
|
||||
params.append("s=%s" % size)
|
||||
|
||||
if hasattr(settings, "GRAVATAR_RATING"):
|
||||
params.append("r=%s" % settings.GRAVATAR_RATING)
|
||||
|
||||
if hasattr(settings, "GRAVATAR_DEFAULT"):
|
||||
params.append("d=%s" % settings.GRAVATAR_DEFAULT)
|
||||
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def get_gravatar_url(request, user, size=None):
|
||||
from django.conf import settings
|
||||
|
||||
if user.is_anonymous() or not user.email:
|
||||
return ""
|
||||
|
||||
email = user.email.strip().lower()
|
||||
return get_gravatar_url_for_email(request, email, size)
|
|
@ -0,0 +1,80 @@
|
|||
#
|
||||
# gravatars.py -- Decorational template tags
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from django import template
|
||||
|
||||
from djblets.gravatars import get_gravatar_url, \
|
||||
get_gravatar_url_for_email
|
||||
from djblets.util.decorators import basictag
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.tag
|
||||
@basictag(takes_context=True)
|
||||
def gravatar(context, user, size=None):
|
||||
"""
|
||||
Outputs the HTML for displaying a user's gravatar.
|
||||
|
||||
This can take an optional size of the image (defaults to 80 if not
|
||||
specified).
|
||||
|
||||
This is also influenced by the following settings:
|
||||
|
||||
GRAVATAR_SIZE - Default size for gravatars
|
||||
GRAVATAR_RATING - Maximum allowed rating (g, pg, r, x)
|
||||
GRAVATAR_DEFAULT - Default image set to show if the user hasn't
|
||||
specified a gravatar (identicon, monsterid, wavatar)
|
||||
|
||||
See http://www.gravatar.com/ for more information.
|
||||
"""
|
||||
url = get_gravatar_url(context['request'], user, size)
|
||||
|
||||
if url:
|
||||
return '<img src="%s" width="%s" height="%s" alt="%s" class="gravatar"/>' % \
|
||||
(url, size, size, user.get_full_name() or user.username)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
@register.tag
|
||||
@basictag(takes_context=True)
|
||||
def gravatar_url(context, email, size=None):
|
||||
"""
|
||||
Outputs the URL for a gravatar for the given email address.
|
||||
|
||||
This can take an optional size of the image (defaults to 80 if not
|
||||
specified).
|
||||
|
||||
This is also influenced by the following settings:
|
||||
|
||||
GRAVATAR_SIZE - Default size for gravatars
|
||||
GRAVATAR_RATING - Maximum allowed rating (g, pg, r, x)
|
||||
GRAVATAR_DEFAULT - Default image set to show if the user hasn't
|
||||
specified a gravatar (identicon, monsterid, wavatar)
|
||||
|
||||
See http://www.gravatar.com/ for more information.
|
||||
"""
|
||||
return get_gravatar_url_for_email(context['request'], email, size)
|
|
@ -0,0 +1,258 @@
|
|||
#
|
||||
# __init__.py -- Basic utilities for the log app
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
# Copyright (c) 2008-2009 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from logging import WatchedFileHandler
|
||||
except ImportError:
|
||||
from djblets.log.handlers import WatchedFileHandler
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
_logging_setup = False
|
||||
_console_log = None
|
||||
_profile_log = None
|
||||
|
||||
DEFAULT_LOG_LEVEL = "DEBUG"
|
||||
DEFAULT_LINE_FORMAT = \
|
||||
"%(asctime)s - %(levelname)s - %(request_info)s - %(message)s"
|
||||
DEFAULT_REQUEST_FORMAT = '%(user)s - %(path)s'
|
||||
|
||||
|
||||
class TimedLogInfo(object):
|
||||
"""
|
||||
A utility class created by ``log_timed`` that handles the timed logging
|
||||
functionality and provides a way to end the timed logging operation.
|
||||
"""
|
||||
def __init__(self, message, warning_at, critical_at, default_level,
|
||||
log_beginning, request):
|
||||
self.message = message
|
||||
self.warning_at = warning_at
|
||||
self.critical_at = critical_at
|
||||
self.default_level = default_level
|
||||
self.start_time = datetime.utcnow()
|
||||
self.request = request
|
||||
|
||||
if log_beginning:
|
||||
logging.log(self.default_level, "Begin: %s" % self.message,
|
||||
request=self.request)
|
||||
|
||||
def done(self):
|
||||
"""
|
||||
Stops the timed logging operation. The resulting time of the
|
||||
operation will be written to the log file. The log level depends
|
||||
on how long the operation takes.
|
||||
"""
|
||||
delta = datetime.utcnow() - self.start_time
|
||||
level = self.default_level
|
||||
|
||||
if delta.seconds >= self.critical_at:
|
||||
level = logging.CRITICAL
|
||||
elif delta.seconds >= self.warning_at:
|
||||
level = logging.WARNING
|
||||
|
||||
logging.log(self.default_level, "End: %s" % self.message,
|
||||
request=self.request)
|
||||
logging.log(level, "%s took %s.%s seconds" % (self.message,
|
||||
delta.seconds,
|
||||
delta.microseconds),
|
||||
request=self.request)
|
||||
|
||||
|
||||
class RequestLogFormatter(logging.Formatter):
|
||||
def __init__(self, request_fmt, *args, **kwargs):
|
||||
logging.Formatter.__init__(self, *args, **kwargs)
|
||||
self.request_fmt = request_fmt
|
||||
|
||||
def format(self, record):
|
||||
record.request_info = self.format_request(
|
||||
getattr(record, 'request', None))
|
||||
|
||||
return logging.Formatter.format(self, record)
|
||||
|
||||
def format_request(self, request):
|
||||
if request:
|
||||
return self.request_fmt % request.__dict__
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def _wrap_logger(logger):
|
||||
"""Wraps a logger, providing an extra 'request' argument."""
|
||||
def _log_with_request(self, *args, **kwargs):
|
||||
extra = kwargs.pop('extra', {})
|
||||
request = kwargs.pop('request', None)
|
||||
|
||||
if request:
|
||||
extra['request'] = request
|
||||
|
||||
kwargs['extra'] = extra
|
||||
|
||||
old_log(self, *args, **kwargs)
|
||||
|
||||
if not hasattr(logger, '_djblets_wrapped'):
|
||||
# This should be a good assumption on all supported versions of Python.
|
||||
assert hasattr(logger, '_log')
|
||||
old_log = logger._log
|
||||
logger._log = _log_with_request
|
||||
logger._djblets_wrapped = True
|
||||
|
||||
|
||||
# Regardless of whether we have logging enabled, we'll want this to be
|
||||
# set so that logging calls don't fail when passing request.
|
||||
root = logging.getLogger('')
|
||||
_wrap_logger(root)
|
||||
|
||||
|
||||
def init_logging():
|
||||
"""
|
||||
Sets up the main loggers, if they haven't already been set up.
|
||||
"""
|
||||
global _logging_setup
|
||||
|
||||
if _logging_setup:
|
||||
return
|
||||
|
||||
enabled = getattr(settings, 'LOGGING_ENABLED', False)
|
||||
log_directory = getattr(settings, 'LOGGING_DIRECTORY', None)
|
||||
log_name = getattr(settings, 'LOGGING_NAME', None)
|
||||
|
||||
if not enabled or not log_directory or not log_name:
|
||||
return
|
||||
|
||||
log_level_name = getattr(settings, 'LOGGING_LEVEL',
|
||||
DEFAULT_LOG_LEVEL)
|
||||
log_level = logging.getLevelName(log_level_name)
|
||||
request_format_str = getattr(settings, 'LOGGING_REQUEST_FORMAT',
|
||||
DEFAULT_REQUEST_FORMAT)
|
||||
format_str = getattr(settings, 'LOGGING_LINE_FORMAT',
|
||||
DEFAULT_LINE_FORMAT)
|
||||
|
||||
log_path = os.path.join(log_directory, log_name + ".log")
|
||||
|
||||
formatter = RequestLogFormatter(request_format_str, format_str)
|
||||
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
handler = logging.FileHandler(log_path)
|
||||
else:
|
||||
handler = WatchedFileHandler(log_path)
|
||||
|
||||
handler.setLevel(log_level)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root.addHandler(handler)
|
||||
root.setLevel(log_level)
|
||||
except IOError:
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format=format_str,
|
||||
)
|
||||
logging.warning("Could not open logfile %s. Logging to stderr",
|
||||
log_path)
|
||||
|
||||
if settings.DEBUG:
|
||||
# In DEBUG mode, log to the console as well.
|
||||
console_log = logging.StreamHandler()
|
||||
console_log.setLevel(log_level)
|
||||
console_log.setFormatter(formatter)
|
||||
root.addHandler(console_log)
|
||||
|
||||
logging.debug("Logging to %s with a minimum level of %s",
|
||||
log_path, log_level_name)
|
||||
|
||||
_logging_setup = True
|
||||
|
||||
|
||||
def init_profile_logger():
|
||||
"""
|
||||
Sets up the profiling logger, if it hasn't already been set up.
|
||||
"""
|
||||
global _profile_log
|
||||
|
||||
enabled = getattr(settings, 'LOGGING_ENABLED', False)
|
||||
log_directory = getattr(settings, 'LOGGING_DIRECTORY', None)
|
||||
log_name = getattr(settings, 'LOGGING_NAME', None)
|
||||
|
||||
if (enabled and log_directory and log_name and not _profile_log and
|
||||
getattr(settings, "LOGGING_ALLOW_PROFILING", False)):
|
||||
|
||||
filename = os.path.join(log_directory, log_name + ".prof")
|
||||
|
||||
if sys.platform == 'win32':
|
||||
handler = logging.FileHandler(filename)
|
||||
else:
|
||||
handler = WatchedFileHandler(filename)
|
||||
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
||||
|
||||
_profile_log = logging.getLogger("profile")
|
||||
_profile_log.addHandler(handler)
|
||||
|
||||
|
||||
def restart_logging():
|
||||
"""
|
||||
Restarts the logging. The next page view will set up the loggers
|
||||
based on any new settings.
|
||||
"""
|
||||
global _logging_setup
|
||||
global _console_log
|
||||
global _profile_log
|
||||
|
||||
logging.log(logging.INFO, "Reloading logging settings")
|
||||
|
||||
if _profile_log:
|
||||
logging.getLogger("profile").removeHandler(_profile_log)
|
||||
|
||||
if _console_log:
|
||||
_console_log.flush()
|
||||
logging.getLogger("").removeHandler(_console_log)
|
||||
|
||||
_logging_setup = False
|
||||
_console_log = None
|
||||
_profile_log = None
|
||||
|
||||
init_logging()
|
||||
|
||||
|
||||
def log_timed(message, warning_at=5, critical_at=15,
|
||||
log_beginning=True, default_level=logging.DEBUG,
|
||||
request=None):
|
||||
"""
|
||||
Times an operation, displaying a log message before and after the
|
||||
operation. The log level for the final log message containing the
|
||||
operation runtime will be based on the runtime, the ``warning_at`` and
|
||||
the ``critical_at`` parameters.
|
||||
"""
|
||||
return TimedLogInfo(message, warning_at, critical_at, default_level,
|
||||
log_beginning, request)
|
|
@ -0,0 +1,86 @@
|
|||
#
|
||||
# handlers.py -- Custom logging handlers
|
||||
#
|
||||
# Copyright (c) 2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
import logging
|
||||
import os
|
||||
from stat import ST_DEV, ST_INO
|
||||
|
||||
|
||||
# This is a port of Python 2.6's WatchedFileHandler. It should only be
|
||||
# used if logging.handlers.WatchedFileHandler can't be imported.
|
||||
#
|
||||
# This class is copyright by the Python Software Foundation.
|
||||
# See http://www.python.org/psf/license/ for details.
|
||||
class WatchedFileHandler(logging.FileHandler):
|
||||
"""
|
||||
A handler for logging to a file, which watches the file
|
||||
to see if it has changed while in use. This can happen because of
|
||||
usage of programs such as newsyslog and logrotate which perform
|
||||
log file rotation. This handler, intended for use under Unix,
|
||||
watches the file to see if it has changed since the last emit.
|
||||
(A file has changed if its device or inode have changed.)
|
||||
If it has changed, the old file stream is closed, and the file
|
||||
opened to get a new stream.
|
||||
|
||||
This handler is not appropriate for use under Windows, because
|
||||
under Windows open files cannot be moved or renamed - logging
|
||||
opens the files with exclusive locks - and so there is no need
|
||||
for such a handler. Furthermore, ST_INO is not supported under
|
||||
Windows; stat always returns zero for this value.
|
||||
|
||||
This handler is based on a suggestion and patch by Chad J.
|
||||
Schroeder.
|
||||
"""
|
||||
def __init__(self, filename, mode='a', encoding=None, delay=0):
|
||||
logging.FileHandler.__init__(self, filename, mode, encoding)
|
||||
|
||||
if not os.path.exists(self.baseFilename):
|
||||
self.dev, self.ino = -1, -1
|
||||
else:
|
||||
stat = os.stat(self.baseFilename)
|
||||
self.dev, self.ino = stat[ST_DEV], stat[ST_INO]
|
||||
|
||||
def emit(self, record):
|
||||
"""
|
||||
Emit a record.
|
||||
|
||||
First check if the underlying file has changed, and if it
|
||||
has, close the old stream and reopen the file to get the
|
||||
current stream.
|
||||
"""
|
||||
if not os.path.exists(self.baseFilename):
|
||||
stat = None
|
||||
changed = 1
|
||||
else:
|
||||
stat = os.stat(self.baseFilename)
|
||||
changed = (stat[ST_DEV] != self.dev) or (stat[ST_INO] != self.ino)
|
||||
if changed and self.stream is not None:
|
||||
self.stream.flush()
|
||||
self.stream.close()
|
||||
self.stream = self._open()
|
||||
if stat is None:
|
||||
stat = os.stat(self.baseFilename)
|
||||
self.dev, self.ino = stat[ST_DEV], stat[ST_INO]
|
||||
logging.FileHandler.emit(self, record)
|
|
@ -0,0 +1,243 @@
|
|||
#
|
||||
# middleware.py -- Middleware implementation for logging
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
# Copyright (c) 2008-2009 David Trowbridge
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.backends import util
|
||||
|
||||
from djblets.log import init_logging, init_profile_logger, log_timed
|
||||
|
||||
|
||||
class CursorDebugWrapper(util.CursorDebugWrapper):
|
||||
"""
|
||||
Replacement for CursorDebugWrapper which stores a traceback in
|
||||
`connection.queries`. This will dramatically increase the overhead of having
|
||||
DEBUG=True, so use with caution.
|
||||
"""
|
||||
def execute(self, sql, params=()):
|
||||
start = time.time()
|
||||
try:
|
||||
return self.cursor.execute(sql, params)
|
||||
finally:
|
||||
stop = time.time()
|
||||
sql = self.db.ops.last_executed_query(self.cursor, sql, params)
|
||||
self.db.queries.append({
|
||||
'sql': sql,
|
||||
'time': stop - start,
|
||||
'stack': traceback.format_stack(),
|
||||
})
|
||||
util.CursorDebugWrapper = CursorDebugWrapper
|
||||
|
||||
|
||||
def reformat_sql(sql):
|
||||
sql = sql.replace('`,`', '`, `')
|
||||
sql = sql.replace('SELECT ', 'SELECT\t')
|
||||
sql = sql.replace('` FROM ', '`\nFROM\t')
|
||||
sql = sql.replace(' WHERE ', '\nWHERE\t')
|
||||
sql = sql.replace(' INNER JOIN ', '\nINNER JOIN\t')
|
||||
sql = sql.replace(' LEFT OUTER JOIN ', '\nLEFT OUTER JOIN\t')
|
||||
sql = sql.replace(' OUTER JOIN ', '\nOUTER JOIN\t')
|
||||
sql = sql.replace(' ON ', '\n ON ')
|
||||
sql = sql.replace(' ORDER BY ', '\nORDER BY\t')
|
||||
return sql
|
||||
|
||||
|
||||
class LoggingMiddleware(object):
|
||||
"""
|
||||
A piece of middleware that sets up logging.
|
||||
|
||||
This a few settings to configure.
|
||||
|
||||
LOGGING_ENABLED
|
||||
---------------
|
||||
|
||||
Default: False
|
||||
|
||||
Sets whether or not logging is enabled.
|
||||
|
||||
|
||||
LOGGING_DIRECTORY
|
||||
-----------------
|
||||
|
||||
Default: None
|
||||
|
||||
Specifies the directory that log files should be stored in.
|
||||
This directory must be writable by the process running Django.
|
||||
|
||||
|
||||
LOGGING_NAME
|
||||
------------
|
||||
|
||||
Default: None
|
||||
|
||||
The name of the log files, excluding the extension and path. This will
|
||||
usually be the name of the website or web application. The file extension
|
||||
will be automatically appended when the file is written.
|
||||
|
||||
|
||||
LOGGING_ALLOW_PROFILING
|
||||
-----------------------
|
||||
|
||||
Default: False
|
||||
|
||||
Specifies whether or not code profiling is allowed. If True, visiting
|
||||
any page with a ``?profiling=1`` parameter in the URL will cause the
|
||||
request to be profiled and stored in a ``.prof`` file using the defined
|
||||
``LOGGING_DIRECTORY`` and ``LOGGING_NAME``.
|
||||
|
||||
|
||||
LOGGING_LINE_FORMAT
|
||||
-------------------
|
||||
|
||||
Default: "%(asctime)s - %(levelname)s - %(message)s"
|
||||
|
||||
The format for lines in the log file. See Python's logging documentation
|
||||
for possible values in the format string.
|
||||
|
||||
|
||||
LOGGING_PAGE_TIMES
|
||||
------------------
|
||||
|
||||
Default: False
|
||||
|
||||
If enabled, page access times will be logged. Specifically, it will log
|
||||
the initial request, the finished render and response, and the total
|
||||
time it look.
|
||||
|
||||
The username and page URL will be included in the logs.
|
||||
|
||||
|
||||
LOGGING_LEVEL
|
||||
-------------
|
||||
|
||||
Default: "DEBUG"
|
||||
|
||||
The minimum level to log. Possible values are ``DEBUG``, ``INFO``,
|
||||
``WARNING``, ``ERROR`` and ``CRITICAL``.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Processes an incoming request. This will set up logging.
|
||||
"""
|
||||
if getattr(settings, 'LOGGING_PAGE_TIMES', False):
|
||||
request._page_timedloginfo = \
|
||||
log_timed('Page request: HTTP %s %s (by %s)' %
|
||||
(request.method, request.path, request.user))
|
||||
|
||||
if ('profiling' in request.GET and
|
||||
getattr(settings, "LOGGING_ALLOW_PROFILING", False)):
|
||||
settings.DEBUG = True
|
||||
|
||||
def process_view(self, request, callback, callback_args, callback_kwargs):
|
||||
"""
|
||||
Handler for processing a view. This will run the profiler on the view
|
||||
if profiling is allowed in the settings and the user specified the
|
||||
profiling parameter on the URL.
|
||||
"""
|
||||
init_logging()
|
||||
|
||||
if ('profiling' in request.GET and
|
||||
getattr(settings, "LOGGING_ALLOW_PROFILING", False)):
|
||||
import cProfile
|
||||
self.profiler = cProfile.Profile()
|
||||
args = (request,) + callback_args
|
||||
settings.DEBUG = True
|
||||
return self.profiler.runcall(callback, *args, **callback_kwargs)
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""
|
||||
Handler for processing a response. Dumps the profiling information
|
||||
to the profile log file.
|
||||
"""
|
||||
timedloginfo = getattr(request, '_page_timedloginfo', None)
|
||||
|
||||
if timedloginfo:
|
||||
timedloginfo.done()
|
||||
|
||||
if ('profiling' in request.GET and
|
||||
getattr(settings, "LOGGING_ALLOW_PROFILING", False)):
|
||||
|
||||
init_profile_logger()
|
||||
|
||||
from cStringIO import StringIO
|
||||
self.profiler.create_stats()
|
||||
|
||||
# Capture the stats
|
||||
out = StringIO()
|
||||
old_stdout, sys.stdout = sys.stdout, out
|
||||
self.profiler.print_stats(1)
|
||||
sys.stdout = old_stdout
|
||||
|
||||
profile_log = logging.getLogger("profile")
|
||||
profile_log.log(logging.INFO,
|
||||
"Profiling results for %s (HTTP %s):",
|
||||
request.path, request.method)
|
||||
profile_log.log(logging.INFO, out.getvalue().strip())
|
||||
|
||||
profile_log.log(logging.INFO,
|
||||
'%d database queries made\n',
|
||||
len(connection.queries))
|
||||
|
||||
queries = {}
|
||||
for query in connection.queries:
|
||||
sql = reformat_sql(query['sql'])
|
||||
stack = ''.join(query['stack'][:-1])
|
||||
time = query['time']
|
||||
if sql in queries:
|
||||
queries[sql].append((time, stack))
|
||||
else:
|
||||
queries[sql] = [(time, stack)]
|
||||
|
||||
times = {}
|
||||
for sql, entries in queries.iteritems():
|
||||
time = sum((float(entry[0]) for entry in entries))
|
||||
tracebacks = '\n\n'.join((entry[1] for entry in entries))
|
||||
times[time] = \
|
||||
'SQL Query profile (%d times, %.3fs average)\n%s\n\n%s\n\n' % \
|
||||
(len(entries), time / len(entries), sql, tracebacks)
|
||||
|
||||
sorted_times = sorted(times.keys(), reverse=1)
|
||||
for time in sorted_times:
|
||||
profile_log.log(logging.INFO, times[time])
|
||||
|
||||
return response
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
"""Handle for exceptions on a page.
|
||||
|
||||
Logs the exception, along with the username and path where the
|
||||
exception occurred.
|
||||
"""
|
||||
logging.error("Exception thrown for user %s at %s\n\n%s",
|
||||
request.user, request.build_absolute_uri(),
|
||||
exception, exc_info=1)
|
|
@ -0,0 +1,40 @@
|
|||
#
|
||||
# siteconfig.py -- Siteconfig definitions for the log app
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from djblets.log import DEFAULT_LOG_LEVEL
|
||||
|
||||
settings_map = {
|
||||
'logging_enabled': 'LOGGING_ENABLED',
|
||||
'logging_directory': 'LOGGING_DIRECTORY',
|
||||
'logging_allow_profiling': 'LOGGING_ALLOW_PROFILING',
|
||||
'logging_level': 'LOGGING_LEVEL',
|
||||
}
|
||||
|
||||
defaults = {
|
||||
'logging_enabled': False,
|
||||
'logging_directory': None,
|
||||
'logging_allow_profiling': False,
|
||||
'logging_level': DEFAULT_LOG_LEVEL,
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load adminmedia %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Server Log" %}{% endblock %}
|
||||
|
||||
{% block bodyclass %}change-list{% endblock %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{block.super}}
|
||||
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% trans "Server Log" %}</h1>
|
||||
|
||||
<div id="changelist" class="module filtered">
|
||||
<div id="changelist-filter">
|
||||
<h2>{% trans "Filter" %}</h2>
|
||||
{% for filterset_name, filters in filtersets %}
|
||||
<h3>{{filterset_name}}</h3>
|
||||
<ul>
|
||||
{% for filter in filters %}
|
||||
<li{% if filter.selected %} class="selected"{% endif %}><a href="{{filter.url}}">{{filter.name}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<table id="log-entries">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"{% if sort_type %} class="sorted sortable {% ifequal sort_type 'asc' %}ascending{% else %}descending{% endifequal %}"{% endif %}>
|
||||
<div class="text">
|
||||
<a href="{{sort_url}}">{% trans "Timestamp" %}</a>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col"><div class="text">{% trans "Level" %}</div></th>
|
||||
<th scope="col"><div class="text">{% trans "Message" %}</div></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for timestamp, level, message in log_lines %}
|
||||
{% ifchanged timestamp.day %}
|
||||
<tr>
|
||||
<th colspan="3">{{timestamp|date}}</th>
|
||||
</tr>
|
||||
{% endifchanged %}
|
||||
<tr class="level-{{level|lower}} {% cycle row1,row2 %}">
|
||||
<th>{{timestamp|time:"H:i:s"}}</td>
|
||||
<th>{{level}}</td>
|
||||
<td><pre>{{message}}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,32 @@
|
|||
#
|
||||
# tests.py -- Unit tests for classes in djblets.log
|
||||
#
|
||||
# Copyright (c) 2010 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from djblets.log.handlers import WatchedFileHandler
|
||||
from djblets.testing.testcases import TestCase
|
||||
|
||||
|
||||
class LogTests(TestCase):
|
||||
def testCreateWatchedFileHandler(self):
|
||||
"""Testing creation of WatchedFileHandler."""
|
||||
WatchedFileHandler("test.log")
|
|
@ -0,0 +1,6 @@
|
|||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
|
||||
urlpatterns = patterns('djblets.log.views',
|
||||
url(r'^server/$', 'server_log', name='server-log')
|
||||
)
|
|
@ -0,0 +1,278 @@
|
|||
#
|
||||
# views.py -- Views for the log app
|
||||
#
|
||||
# Copyright (c) 2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from urllib import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
||||
LEVELS = (
|
||||
(logging.DEBUG, 'debug', _('Debug')),
|
||||
(logging.INFO, 'info', _('Info')),
|
||||
(logging.WARNING, 'warning', _('Warning')),
|
||||
(logging.ERROR, 'error', _('Error')),
|
||||
(logging.CRITICAL, 'critical', _('Critical')),
|
||||
)
|
||||
|
||||
|
||||
# Matches the default timestamp format in the logging module.
|
||||
TIMESTAMP_FMT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
LOG_LINE_RE = re.compile(
|
||||
r'(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) - '
|
||||
r'(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL) - '
|
||||
r'(?P<message>.*)')
|
||||
|
||||
|
||||
def parse_timestamp(format, timestamp_str):
|
||||
"""Utility function to parse a timestamp into a datetime.datetime.
|
||||
|
||||
Python 2.5 and up have datetime.strptime, but Python 2.4 does not,
|
||||
so we roll our own as per the documentation.
|
||||
|
||||
If passed a timestamp_str of None, we will return None as a convenience.
|
||||
"""
|
||||
if not timestamp_str:
|
||||
return None
|
||||
|
||||
return datetime.datetime(*time.strptime(timestamp_str, format)[0:6])
|
||||
|
||||
|
||||
def build_query_string(request, params):
|
||||
"""Builds a query string that includes the specified parameters along
|
||||
with those that were passed to the page.
|
||||
|
||||
params is a dictionary.
|
||||
"""
|
||||
query_parts = []
|
||||
|
||||
for key, value in request.GET.iteritems():
|
||||
if key not in params:
|
||||
query_parts.append(urlencode({
|
||||
key: value
|
||||
}))
|
||||
|
||||
for key, value in params.iteritems():
|
||||
if value is not None:
|
||||
query_parts.append(urlencode({
|
||||
key: value
|
||||
}))
|
||||
|
||||
return '?' + '&'.join(query_parts)
|
||||
|
||||
|
||||
def iter_log_lines(from_timestamp, to_timestamp, requested_levels):
|
||||
"""Generator that iterates over lines in a log file, yielding the
|
||||
yielding information about the lines."""
|
||||
log_filename = os.path.join(settings.LOGGING_DIRECTORY,
|
||||
settings.LOGGING_NAME + '.log')
|
||||
|
||||
line_info = None
|
||||
|
||||
try:
|
||||
fp = open(log_filename, 'r')
|
||||
except IOError:
|
||||
# We'd log this, but it'd do very little good in practice.
|
||||
# It would only appear on the console when using the development
|
||||
# server, but production users would never see anything. So,
|
||||
# just return gracefully. We'll show an empty log, which is
|
||||
# about accurate.
|
||||
return
|
||||
|
||||
for line in fp.xreadlines():
|
||||
line = line.rstrip()
|
||||
|
||||
m = LOG_LINE_RE.match(line)
|
||||
|
||||
if m:
|
||||
if line_info:
|
||||
# We have a fully-formed log line and this new line isn't
|
||||
# part of it, so yield it now.
|
||||
yield line_info
|
||||
line_info = None
|
||||
|
||||
timestamp_str = m.group('timestamp')
|
||||
level = m.group('level')
|
||||
message = m.group('message')
|
||||
|
||||
if not requested_levels or level.lower() in requested_levels:
|
||||
timestamp = parse_timestamp(TIMESTAMP_FMT,
|
||||
timestamp_str.split(',')[0])
|
||||
|
||||
timestamp_date = timestamp.date()
|
||||
|
||||
if ((from_timestamp and from_timestamp > timestamp_date) or
|
||||
(to_timestamp and to_timestamp < timestamp_date)):
|
||||
continue
|
||||
|
||||
line_info = (timestamp, level, message)
|
||||
elif line_info:
|
||||
line_info = (line_info[0],
|
||||
line_info[1],
|
||||
line_info[2] + "\n" + line)
|
||||
|
||||
if line_info:
|
||||
yield line_info
|
||||
|
||||
fp.close()
|
||||
|
||||
|
||||
def get_log_filtersets(request, requested_levels,
|
||||
from_timestamp, to_timestamp):
|
||||
"""Returns the filtersets that will be used in the log view."""
|
||||
logger = logging.getLogger('')
|
||||
level_filters = [
|
||||
{
|
||||
'name': _('All'),
|
||||
'url': build_query_string(request, {'levels': None}),
|
||||
'selected': len(requested_levels) == 0,
|
||||
}
|
||||
] + [
|
||||
{
|
||||
'name': label_name,
|
||||
'url': build_query_string(request, {'levels': level_name}),
|
||||
'selected': level_name in requested_levels,
|
||||
}
|
||||
for level_id, level_name, label_name in LEVELS
|
||||
if logger.isEnabledFor(level_id)
|
||||
]
|
||||
|
||||
from_timestamp_str = request.GET.get('from', None)
|
||||
to_timestamp_str = request.GET.get('to', None)
|
||||
today = datetime.date.today()
|
||||
today_str = today.strftime('%Y-%m-%d')
|
||||
one_week_ago = today - datetime.timedelta(days=7)
|
||||
one_week_ago_str = one_week_ago.strftime('%Y-%m-%d')
|
||||
month_range = calendar.monthrange(today.year, today.month)
|
||||
this_month_begin_str = today.strftime('%Y-%m-01')
|
||||
this_month_end_str = today.strftime('%Y-%m-') + str(month_range[1])
|
||||
|
||||
date_filters = [
|
||||
{
|
||||
'name': _('Any date'),
|
||||
'url': build_query_string(request, {
|
||||
'from': None,
|
||||
'to': None,
|
||||
}),
|
||||
'selected': from_timestamp_str is None and
|
||||
to_timestamp_str is None,
|
||||
},
|
||||
{
|
||||
'name': _('Today'),
|
||||
'url': build_query_string(request, {
|
||||
'from': today_str,
|
||||
'to': today_str,
|
||||
}),
|
||||
'selected': from_timestamp_str == today_str and
|
||||
to_timestamp_str == today_str,
|
||||
},
|
||||
{
|
||||
'name': _('Past 7 days'),
|
||||
'url': build_query_string(request, {
|
||||
'from': one_week_ago_str,
|
||||
'to': today_str,
|
||||
}),
|
||||
'selected': from_timestamp_str == one_week_ago_str and
|
||||
to_timestamp_str == today_str,
|
||||
},
|
||||
{
|
||||
'name': _('This month'),
|
||||
'url': build_query_string(request, {
|
||||
'from': this_month_begin_str,
|
||||
'to': this_month_end_str,
|
||||
}),
|
||||
'selected': from_timestamp_str == this_month_begin_str and
|
||||
to_timestamp_str == this_month_end_str,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
(_("By date"), date_filters),
|
||||
(_("By level"), level_filters),
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def server_log(request, template_name='log/log.html'):
|
||||
"""Displays the server log."""
|
||||
|
||||
# First check if logging is even configured. If it's not, just return
|
||||
# a 404.
|
||||
if (not getattr(settings, "LOGGING_ENABLED", False) or
|
||||
not getattr(settings, "LOGGING_DIRECTORY", None)):
|
||||
raise Http404()
|
||||
|
||||
requested_levels = []
|
||||
|
||||
# Get the list of levels to show.
|
||||
if 'levels' in request.GET:
|
||||
requested_levels = request.GET.get('levels').split(',')
|
||||
|
||||
# Get the timestamp ranges.
|
||||
from_timestamp = parse_timestamp('%Y-%m-%d', request.GET.get('from'))
|
||||
to_timestamp = parse_timestamp('%Y-%m-%d', request.GET.get('to'))
|
||||
|
||||
if from_timestamp:
|
||||
from_timestamp = from_timestamp.date()
|
||||
|
||||
if to_timestamp:
|
||||
to_timestamp = to_timestamp.date()
|
||||
|
||||
# Get the filters to show.
|
||||
filtersets = get_log_filtersets(request, requested_levels,
|
||||
from_timestamp, to_timestamp)
|
||||
|
||||
# Grab the lines from the log file.
|
||||
log_lines = iter_log_lines(from_timestamp, to_timestamp, requested_levels)
|
||||
|
||||
# Figure out the sorting
|
||||
sort_type = request.GET.get('sort', 'asc')
|
||||
|
||||
if sort_type == 'asc':
|
||||
reverse_sort_type = 'desc'
|
||||
else:
|
||||
reverse_sort_type = 'asc'
|
||||
log_lines = reversed(list(log_lines))
|
||||
|
||||
response = render_to_response(template_name, RequestContext(request, {
|
||||
'log_lines': log_lines,
|
||||
'filtersets': filtersets,
|
||||
'sort_url': build_query_string(request, {'sort': reverse_sort_type}),
|
||||
'sort_type': sort_type,
|
||||
}))
|
||||
|
||||
return response
|
|
@ -0,0 +1,35 @@
|
|||
fieldset.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
fieldset .description {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
fieldset .description p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.disabled-reason {
|
||||
color: #D00000;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.disabled-reason a {
|
||||
color: #900000;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
form .wide p.disabled-reason {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
form .aligned p.disabled-reason {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.checkbox-row p.disabled-reason {
|
||||
margin-left: 0pt;
|
||||
padding-left: 0pt !important;
|
||||
}
|
|
@ -0,0 +1,284 @@
|
|||
.datagrid-main {
|
||||
}
|
||||
|
||||
.datagrid {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datagrid td {
|
||||
line-height: 1.4em;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.datagrid td a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.datagrid th.day {
|
||||
background: #E9E9E9;
|
||||
border-bottom: 1px #999999 solid;
|
||||
}
|
||||
|
||||
.datagrid tr {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.datagrid tr.even {
|
||||
background: #F2F2F2;
|
||||
}
|
||||
|
||||
.datagrid tr:hover {
|
||||
background-color: #d4e0f3;
|
||||
}
|
||||
|
||||
.datagrid-header {
|
||||
background: url("../images/datagrid/header_bg.png") repeat-x bottom left;
|
||||
border-top: 1px #999999 solid;
|
||||
border-bottom: 1px #999999 solid;
|
||||
border-right: 1px #CCCCCC solid;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding: 4px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid-header-drag {
|
||||
border: 1px #999999 solid;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.datagrid-header a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.datagrid-header a.unsort {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
.datagrid-header a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.datagrid tr:hover.headers {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.datagrid-header:hover {
|
||||
background: url("../images/datagrid/header_bg_primary.png") repeat-x bottom left;
|
||||
}
|
||||
|
||||
.datagrid-header:hover a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.datagrid-header img,
|
||||
.datagrid-header div {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.edit-columns {
|
||||
width: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
/****************************************************************************
|
||||
* Titles
|
||||
****************************************************************************/
|
||||
.datagrid-title,
|
||||
.datagrid-titlebox {
|
||||
background-color: #a2bedc;
|
||||
border-bottom: 1px #728eac solid;
|
||||
margin: 0;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.datagrid-titlebox h1 {
|
||||
display: inline;
|
||||
font-size: 120%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.datagrid-titlebox ul {
|
||||
list-style: none;
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.datagrid-titlebox ul li {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.datagrid-titlebox ul li a {
|
||||
color: #0000CC;
|
||||
}
|
||||
|
||||
/****************************************************************************
|
||||
* Paginator
|
||||
****************************************************************************/
|
||||
|
||||
.datagrid-wrapper .paginator {
|
||||
padding: 8px 4px 4px 4px;
|
||||
}
|
||||
|
||||
.datagrid-wrapper .paginator .current-page {
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.datagrid-wrapper .paginator .page-count {
|
||||
color: #444444;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.datagrid-wrapper .paginator a {
|
||||
border: 1px solid #CCCCCC;
|
||||
color: black;
|
||||
padding: 2px 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.datagrid-wrapper .paginator a:hover {
|
||||
background: #9BC0F2;
|
||||
border-color: #003366;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.datagrid-wrapper .paginator a:visited {
|
||||
color: black;
|
||||
}
|
||||
|
||||
|
||||
/****************************************************************************
|
||||
* Column-specific classes
|
||||
****************************************************************************/
|
||||
.datagrid .age1 {
|
||||
background: #beedbc;
|
||||
border-left: 1px #8bbd5c solid;
|
||||
border-right: 1px #8bbd5c solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid tr.even .age1 {
|
||||
background: #b4e3b2;
|
||||
}
|
||||
|
||||
.datagrid .age2 {
|
||||
background: #ddfa8e;
|
||||
border-left: 1px #a3e266 solid;
|
||||
border-right: 1px #a3e266 solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid tr.even .age2 {
|
||||
background: #d3f084;
|
||||
}
|
||||
|
||||
.datagrid .age3 {
|
||||
background: #fdf18c;
|
||||
border-left: 1px #d8c158 solid;
|
||||
border-right: 1px #d8c158 solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid tr.even .age3 {
|
||||
background: #f3e782;
|
||||
}
|
||||
|
||||
.datagrid .age4 {
|
||||
background: #fed3a9;
|
||||
border-left: 1px #d49659 solid;
|
||||
border-right: 1px #d49659 solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid tr.even .age4 {
|
||||
background: #f4c99f;
|
||||
}
|
||||
|
||||
.datagrid .age5 {
|
||||
background: #fab6b6;
|
||||
border-left: 1px #f56363 solid;
|
||||
border-right: 1px #f56363 solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.datagrid tr.even .age5 {
|
||||
background: #f0acac;
|
||||
}
|
||||
|
||||
.datagrid tr:hover .age1 {
|
||||
background: #a1cb9f;
|
||||
}
|
||||
|
||||
.datagrid tr:hover .age2 {
|
||||
background: #bcd675;
|
||||
}
|
||||
|
||||
.datagrid tr:hover .age3 {
|
||||
background: #d9ce74;
|
||||
}
|
||||
|
||||
.datagrid tr:hover .age4 {
|
||||
background: #dab38e;
|
||||
}
|
||||
|
||||
.datagrid tr:hover .age5 {
|
||||
background: #d69999;
|
||||
}
|
||||
|
||||
.datagrid tr.month {
|
||||
background: #E9E9E9;
|
||||
}
|
||||
|
||||
.datagrid td.summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.datagrid-menu {
|
||||
background: #F0F0F0;
|
||||
border-left: 1px #303030 solid;
|
||||
border-bottom: 1px #303030 solid;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.datagrid-menu td {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.datagrid-menu td a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.datagrid-menu td img,
|
||||
.datagrid-menu td div {
|
||||
vertical-align: sub;
|
||||
}
|
||||
|
||||
.datagrid-menu-checkbox {
|
||||
border: 1px #C0C0C0 solid;
|
||||
height: 1.4em;
|
||||
width: 1.4em;
|
||||
}
|
||||
|
||||
.datagrid-menu-checkbox img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.datagrid-menu tr:hover .datagrid-menu-checkbox {
|
||||
background: #E0E0E0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.datagrid-menu tr:hover .datagrid-menu-label {
|
||||
text-decoration: underline;
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
#activity-indicator {
|
||||
background-color: #fce94f;
|
||||
background-image: url("../images/extensions/spinner.gif");
|
||||
background-position: 0.4em 0.4em;
|
||||
background-repeat: no-repeat;
|
||||
border: 1px #c4a000 solid;
|
||||
border-top: 0;
|
||||
font-weight: bold;
|
||||
left: 50%;
|
||||
margin-left: -3em;
|
||||
padding: 0.5em 0.6em 0.5em 2.2em;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
top: 0;
|
||||
width: 6em;
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
/****************************************************************************
|
||||
* Modal Boxes
|
||||
****************************************************************************/
|
||||
|
||||
|
||||
.modalbox {
|
||||
background-color: white;
|
||||
background-image: url('../images/extensions/box_top_bg.png');
|
||||
background-position: top left;
|
||||
background-repeat: repeat-x;
|
||||
border: 1px #888A85 solid;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.modalbox-title {
|
||||
background: #a2bedc url('../images/extensions/title_box_top_bg.png') repeat-x top left;
|
||||
border-bottom: 1px #728eac solid;
|
||||
font-size: 120%;
|
||||
margin: 0;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.modalbox-inner {
|
||||
background-image: url('../images/extensions/box_bottom_bg.png');
|
||||
background-position: bottom left;
|
||||
background-repeat: repeat-x;
|
||||
padding-bottom: 1px; /* IE wants this and it does no harm. */
|
||||
}
|
||||
|
||||
.modalbox-contents {
|
||||
margin: 10px;
|
||||
position: relative; /* Makes this the offsetParent for calculations. */
|
||||
}
|
||||
|
||||
.modalbox-buttons {
|
||||
bottom: 0;
|
||||
margin: 10px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modalbox-buttons input {
|
||||
margin-left: 10px;
|
||||
}
|
After Width: | Height: | Size: 241 B |
After Width: | Height: | Size: 560 B |
After Width: | Height: | Size: 153 B |
After Width: | Height: | Size: 159 B |
After Width: | Height: | Size: 234 B |
After Width: | Height: | Size: 235 B |
After Width: | Height: | Size: 233 B |
After Width: | Height: | Size: 245 B |
After Width: | Height: | Size: 258 B |
After Width: | Height: | Size: 120 B |
After Width: | Height: | Size: 145 B |
After Width: | Height: | Size: 73 B |
After Width: | Height: | Size: 105 B |
After Width: | Height: | Size: 116 B |
After Width: | Height: | Size: 673 B |
After Width: | Height: | Size: 144 B |
|
@ -0,0 +1,395 @@
|
|||
/*
|
||||
* Copyright 2008-2010 Christian Hammond.
|
||||
* Copyright 2010-2012 Beanbag, Inc.
|
||||
*
|
||||
* Licensed under the MIT license.
|
||||
*/
|
||||
(function($) {
|
||||
|
||||
|
||||
/*
|
||||
* Creates a datagrid. This will cause drag and drop and column
|
||||
* customization to be enabled.
|
||||
*/
|
||||
$.fn.datagrid = function() {
|
||||
var $grid = this,
|
||||
gridId = this.attr("id"),
|
||||
$editButton = $("#" + gridId + "-edit"),
|
||||
$menu = $("#" + gridId + "-menu"),
|
||||
$summaryCells = $grid.find("td.summary");
|
||||
|
||||
/* State */
|
||||
activeColumns = [],
|
||||
$activeMenu = null,
|
||||
columnMidpoints = [],
|
||||
dragColumn = null,
|
||||
dragColumnsChanged = false,
|
||||
dragColumnWidth = 0,
|
||||
dragIndex = 0,
|
||||
dragLastX = 0;
|
||||
|
||||
$grid.data('datagrid', this);
|
||||
|
||||
/* Add all the non-special columns to the list. */
|
||||
$grid.find("col").not(".datagrid-customize").each(function(i, col) {
|
||||
activeColumns.push(col.className);
|
||||
});
|
||||
|
||||
$grid.find("th")
|
||||
/* Make the columns unselectable. */
|
||||
.disableSelection()
|
||||
|
||||
/* Make the columns draggable. */
|
||||
.not(".edit-columns").draggable({
|
||||
appendTo: "body",
|
||||
axis: "x",
|
||||
containment: $grid.find("thead:first"),
|
||||
cursor: "move",
|
||||
helper: function() {
|
||||
var $el = $(this);
|
||||
|
||||
return $("<div/>")
|
||||
.addClass("datagrid-header-drag datagrid-header")
|
||||
.width($el.width())
|
||||
.height($el.height())
|
||||
.css("top", $el.offset().top)
|
||||
.html($el.html());
|
||||
},
|
||||
start: startColumnDrag,
|
||||
stop: endColumnDrag,
|
||||
drag: onColumnDrag
|
||||
});
|
||||
|
||||
/* Register callbacks for the columns. */
|
||||
$menu.find("tr").each(function(i, row) {
|
||||
var className = row.className;
|
||||
|
||||
$(row).find(".datagrid-menu-checkbox, .datagrid-menu-label a")
|
||||
.click(function() {
|
||||
toggleColumn(className);
|
||||
});
|
||||
});
|
||||
|
||||
$editButton.click(function(evt) {
|
||||
evt.stopPropagation();
|
||||
toggleColumnsMenu();
|
||||
});
|
||||
|
||||
/*
|
||||
* Attaches click event listener to all summary td elements,
|
||||
* following href of child anchors if present. This is being
|
||||
* done to complement the "cursor:pointer" style that is
|
||||
* already applied to the same elements. (Bug #1022)
|
||||
*/
|
||||
$summaryCells.click(function(evt) {
|
||||
var cellHref = $(evt.target).find("a").attr("href");
|
||||
|
||||
evt.stopPropagation();
|
||||
|
||||
if (cellHref){
|
||||
window.location.href = cellHref;
|
||||
}
|
||||
});
|
||||
|
||||
$(document.body).click(hideColumnsMenu);
|
||||
|
||||
|
||||
/********************************************************************
|
||||
* Public methods
|
||||
********************************************************************/
|
||||
this.reload = function() {
|
||||
loadFromServer(null, true);
|
||||
};
|
||||
|
||||
|
||||
/********************************************************************
|
||||
* Server communication
|
||||
********************************************************************/
|
||||
|
||||
function loadFromServer(params, reloadGrid) {
|
||||
var search = window.location.search || '?',
|
||||
url = window.location.pathname + search +
|
||||
'&gridonly=1&datagrid-id=' + gridId;
|
||||
|
||||
if (params) {
|
||||
url += '&' + params;
|
||||
}
|
||||
|
||||
$.get(url, function(html) {
|
||||
if (reloadGrid) {
|
||||
$grid.replaceWith(html);
|
||||
$("#" + gridId).datagrid();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/********************************************************************
|
||||
* Column customization
|
||||
********************************************************************/
|
||||
|
||||
/*
|
||||
* Hides the currently open columns menu.
|
||||
*/
|
||||
function hideColumnsMenu() {
|
||||
if ($activeMenu !== null) {
|
||||
$activeMenu.hide();
|
||||
$activeMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Toggles the visibility of the specified columns menu.
|
||||
*/
|
||||
function toggleColumnsMenu() {
|
||||
var offset;
|
||||
|
||||
if ($menu.is(":visible")) {
|
||||
hideColumnsMenu();
|
||||
} else {
|
||||
offset = $editButton.offset()
|
||||
|
||||
$menu
|
||||
.css({
|
||||
left: offset.left - $menu.outerWidth() +
|
||||
$editButton.outerWidth(),
|
||||
top: offset.top + $editButton.outerHeight()
|
||||
})
|
||||
.show();
|
||||
|
||||
$activeMenu = $menu;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Saves the new columns list on the server.
|
||||
*
|
||||
* @param {string} columnsStr The columns to display.
|
||||
* @param {boolean} reloadGrid Reload from the server.
|
||||
*/
|
||||
function saveColumns(columnsStr, reloadGrid) {
|
||||
loadFromServer('columns=' + columnsStr, reloadGrid);
|
||||
}
|
||||
|
||||
/*
|
||||
* Toggles the visibility of a column. This will build the resulting
|
||||
* columns string and request a save of the columns, followed by a
|
||||
* reload of the page.
|
||||
*
|
||||
* @param {string} columnId The ID of the column to toggle.
|
||||
*/
|
||||
function toggleColumn(columnId) {
|
||||
saveColumns(serializeColumns(columnId), true);
|
||||
}
|
||||
|
||||
/*
|
||||
* Serializes the active column list, optionally adding one new entry
|
||||
* to the end of the list.
|
||||
*
|
||||
* @return The serialized column list.
|
||||
*/
|
||||
function serializeColumns(addedColumn) {
|
||||
var columnsStr = "";
|
||||
|
||||
$(activeColumns).each(function(i) {
|
||||
var curColumn = activeColumns[i];
|
||||
|
||||
if (curColumn === addedColumn) {
|
||||
/* We're removing this column. */
|
||||
addedColumn = null;
|
||||
} else {
|
||||
columnsStr += curColumn;
|
||||
|
||||
if (i < activeColumns.length - 1) {
|
||||
columnsStr += ",";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (addedColumn) {
|
||||
columnsStr += "," + addedColumn;
|
||||
}
|
||||
|
||||
return columnsStr;
|
||||
}
|
||||
|
||||
|
||||
/********************************************************************
|
||||
* Column reordering support
|
||||
********************************************************************/
|
||||
|
||||
/*
|
||||
* Handles the beginning of the drag.
|
||||
*
|
||||
* Builds the column information needed for determining when we should
|
||||
* switch columns.
|
||||
*
|
||||
* @param {event} evt The event.
|
||||
* @param {object} ui The jQuery drag and drop information.
|
||||
*/
|
||||
function startColumnDrag(evt, ui) {
|
||||
dragColumn = this;
|
||||
dragColumnsChanged = false;
|
||||
dragColumnWidth = ui.helper.width();
|
||||
dragIndex = 0;
|
||||
dragLastX = 0;
|
||||
buildColumnInfo();
|
||||
|
||||
/* Hide the column but keep its area reserved. */
|
||||
$(dragColumn).css("visibility", "hidden");
|
||||
}
|
||||
|
||||
/*
|
||||
* Handles the end of a drag.
|
||||
*
|
||||
* This shows the original header (now in its new place) and saves
|
||||
* the new columns configuration.
|
||||
*/
|
||||
function endColumnDrag() {
|
||||
var $column = $(this);
|
||||
|
||||
/* Re-show the column header. */
|
||||
$column.css("visibility", "visible");
|
||||
|
||||
columnMidpoints = [];
|
||||
|
||||
if (dragColumnsChanged) {
|
||||
/* Build the new columns list */
|
||||
saveColumns(serializeColumns());
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Handles movement while in drag mode.
|
||||
*
|
||||
* This will check if we've crossed the midpoint of a column. If so, we
|
||||
* switch the columns.
|
||||
*
|
||||
* @param {event} e The event.
|
||||
* @param {object} ui The jQuery drag and drop information.
|
||||
*/
|
||||
function onColumnDrag(e, ui) {
|
||||
/*
|
||||
* Check the direction we're moving and see if we're ready to switch
|
||||
* with another column.
|
||||
*/
|
||||
var x = e.originalEvent.pageX,
|
||||
hitX = -1,
|
||||
index = -1;
|
||||
|
||||
if (x === dragLastX) {
|
||||
/* No change that we care about. Bail out. */
|
||||
return;
|
||||
}
|
||||
|
||||
if (x < dragLastX) {
|
||||
index = dragIndex - 1;
|
||||
hitX = ui.offset.left;
|
||||
} else {
|
||||
index = dragIndex + 1;
|
||||
hitX = ui.offset.left + ui.helper.width();
|
||||
}
|
||||
|
||||
if (index >= 0 && index < columnMidpoints.length) {
|
||||
/* Check that we're dragging past the midpoint. If so, swap. */
|
||||
if (x < dragLastX && hitX <= columnMidpoints[index]) {
|
||||
swapColumnBefore(dragIndex, index);
|
||||
} else if (x > dragLastX && hitX >= columnMidpoints[index]) {
|
||||
swapColumnBefore(index, dragIndex);
|
||||
}
|
||||
}
|
||||
|
||||
dragLastX = x;
|
||||
}
|
||||
|
||||
/*
|
||||
* Builds the necessary information on the columns.
|
||||
*
|
||||
* This will construct an array of midpoints that are used to determine
|
||||
* when we should swap columns during a drag. It also sets the index
|
||||
* of the currently dragged column.
|
||||
*/
|
||||
function buildColumnInfo() {
|
||||
/* Clear and rebuild the list of mid points. */
|
||||
columnMidpoints = [];
|
||||
|
||||
$grid.find("th").not(".edit-columns").each(function(i, column) {
|
||||
var $column = $(column),
|
||||
offset = $column.offset();
|
||||
|
||||
if (column === dragColumn) {
|
||||
dragIndex = i;
|
||||
|
||||
/*
|
||||
* Getting the width of an invisible element is very bad
|
||||
* when the element is a <th>. Use our pre-calculated width.
|
||||
*/
|
||||
width = dragColumnWidth;
|
||||
} else {
|
||||
width = $column.width();
|
||||
}
|
||||
|
||||
columnMidpoints.push(Math.round(offset.left + width / 2));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Swaps two columns, placing the first before the second.
|
||||
*
|
||||
* It is assumed that the two columns are siblings. Horrible disfiguring
|
||||
* things might happen if this isn't the case, or it might not. Who
|
||||
* can tell. Our code behaves, though.
|
||||
*
|
||||
* @param {int} index The index of the column to move.
|
||||
* @param {int} beforeIndex The index of the column to place the first
|
||||
* before.
|
||||
*/
|
||||
function swapColumnBefore(index, beforeIndex) {
|
||||
/* Swap the column info. */
|
||||
var colTags = $grid.find("col"),
|
||||
tempName,
|
||||
table,
|
||||
rowsLen,
|
||||
i,
|
||||
row,
|
||||
cell,
|
||||
beforeCell,
|
||||
tempColSpan;
|
||||
|
||||
$(colTags[index]).insertBefore($(colTags[beforeIndex]));
|
||||
|
||||
/* Swap the list of active columns */
|
||||
tempName = activeColumns[index];
|
||||
activeColumns[index] = activeColumns[beforeIndex];
|
||||
activeColumns[beforeIndex] = tempName;
|
||||
|
||||
/* Swap the cells. This will include the headers. */
|
||||
table = $grid.find("table:first")[0];
|
||||
|
||||
for (i = 0, rowsLen = table.rows.length; i < rowsLen; i++) {
|
||||
row = table.rows[i];
|
||||
cell = row.cells[index];
|
||||
beforeCell = row.cells[beforeIndex];
|
||||
|
||||
row.insertBefore(cell, beforeCell);
|
||||
|
||||
/* Switch the colspans. */
|
||||
tempColSpan = cell.colSpan;
|
||||
cell.colSpan = beforeCell.colSpan;
|
||||
beforeCell.colSpan = tempColSpan;
|
||||
}
|
||||
|
||||
dragColumnsChanged = true;
|
||||
|
||||
/* Everything has changed, so rebuild our view of things. */
|
||||
buildColumnInfo();
|
||||
}
|
||||
|
||||
return $grid;
|
||||
};
|
||||
|
||||
$(document).ready(function() {
|
||||
$("div.datagrid-wrapper").datagrid();
|
||||
});
|
||||
|
||||
})(jQuery);
|
|
@ -0,0 +1,37 @@
|
|||
function send_extension_webapi_request(extension_id, data) {
|
||||
$('#activity-indicator').show();
|
||||
$.ajax({
|
||||
type: "PUT",
|
||||
url: SITE_ROOT + "api/extensions/" + extension_id + "/",
|
||||
data: data,
|
||||
success: function(xhr) {
|
||||
window.location = window.location;
|
||||
},
|
||||
complete: function(xhr) {
|
||||
$('#activity-indicator').hide();
|
||||
},
|
||||
error: function(xhr) {
|
||||
/*
|
||||
* If something goes wrong, try to dump out the error
|
||||
* to a modal dialog.
|
||||
*/
|
||||
var jsonData = eval("(" + xhr.responseText + ")");
|
||||
var dlg = $("<p/>")
|
||||
.text(jsonData['err']['msg'])
|
||||
.modalBox({
|
||||
title: "Error",
|
||||
buttons: [
|
||||
$('<input type="button" value="OK"/>'),
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$(document).ready(function() {
|
||||
$('<div id="activity-indicator" />')
|
||||
.text("Loading...")
|
||||
.hide()
|
||||
.appendTo("body");
|
||||
});
|
|
@ -0,0 +1,92 @@
|
|||
import mimetools
|
||||
import os
|
||||
import re
|
||||
import urllib2
|
||||
|
||||
from pipeline.compilers import CompilerBase, CompilerError
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
BLESS_URL = getattr(settings, 'BLESS_URL',
|
||||
'http://blesscss.cloudfoundry.com/min')
|
||||
BLESS_IMPORT_PATHS = getattr(settings, 'BLESS_IMPORT_PATHS', [])
|
||||
|
||||
|
||||
class BlessCompiler(CompilerBase):
|
||||
output_extension = 'css'
|
||||
IMPORT_RE = re.compile(r'^@import "([^"]+)";')
|
||||
|
||||
def match_file(self, filename):
|
||||
return filename.endswith('.less')
|
||||
|
||||
def compile_file(self, infile, outfile, *args, **kwargs):
|
||||
if self.verbose:
|
||||
print 'Converting lesscss using %s' % BLESS_URL
|
||||
|
||||
boundary = mimetools.choose_boundary()
|
||||
|
||||
blob = '--%s\r\n' % boundary
|
||||
blob += 'Content-Disposition: form-data; name="style.less"\r\n'
|
||||
blob += '\r\n'
|
||||
|
||||
try:
|
||||
fp = open(infile, 'r')
|
||||
except IOError, e:
|
||||
raise CompilerError('Unable to read file %s' % infile)
|
||||
|
||||
content = fp.read()
|
||||
|
||||
for line in content.splitlines(True):
|
||||
m = self.IMPORT_RE.match(line)
|
||||
|
||||
if m:
|
||||
filename = m.group(1)
|
||||
|
||||
if (not filename.endswith(".css") and
|
||||
not filename.endswith(".less")):
|
||||
filename += '.less'
|
||||
|
||||
line = self._load_import(filename)
|
||||
|
||||
blob += line
|
||||
|
||||
fp.close()
|
||||
|
||||
blob += '\r\n'
|
||||
blob += '--%s--\r\n' % boundary
|
||||
blob += '\r\n'
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
|
||||
'Content-Length': str(len(blob)),
|
||||
}
|
||||
|
||||
r = urllib2.Request(BLESS_URL, blob, headers)
|
||||
|
||||
try:
|
||||
content = urllib2.urlopen(r).read()
|
||||
except urllib2.HTTPError, e:
|
||||
if e.code == 400:
|
||||
raise CompilerError("Error processing lessCSS files: %s" %
|
||||
e.read())
|
||||
|
||||
raise
|
||||
|
||||
fp = open(outfile, 'w')
|
||||
fp.write(content)
|
||||
fp.close()
|
||||
|
||||
return content
|
||||
|
||||
def _load_import(self, filename):
|
||||
for import_path in BLESS_IMPORT_PATHS:
|
||||
path = os.path.join(settings.STATIC_ROOT, import_path, filename)
|
||||
|
||||
if os.path.exists(path):
|
||||
fp = open(path, 'r')
|
||||
content = fp.read()
|
||||
fp.close()
|
||||
|
||||
return content
|
||||
|
||||
raise CompilerError('Unable to find import file "%s"' % filename)
|
|
@ -0,0 +1,2 @@
|
|||
{% load djblets_utils %}
|
||||
<link href="{{url}}" rel="stylesheet{% if url|endswith:"less" %}/less{% endif %}" type="text/css"{% if media %} media="{{media}}"{% endif %}{% if title %} title="{{title|default:"all"}}"{% endif %}{% if charset %} charset="{{charset}}"{% endif %} />
|
|
@ -0,0 +1,41 @@
|
|||
#
|
||||
# Settings for djblets.
|
||||
#
|
||||
# This is meant for internal use only. We use it primarily for building
|
||||
# static media to bundle with djblets.
|
||||
#
|
||||
# This should generally not be used in a project.
|
||||
import os
|
||||
|
||||
|
||||
SECRET_KEY = '47157c7ae957f904ab809d8c5b77e0209221d4c0'
|
||||
|
||||
|
||||
DJBLETS_ROOT = os.path.abspath(os.path.dirname(__file__))
|
||||
STATIC_ROOT = os.path.join(DJBLETS_ROOT, 'static')
|
||||
STATIC_URL = '/'
|
||||
|
||||
STATICFILES_DIRS = (
|
||||
os.path.join(DJBLETS_ROOT, 'media'),
|
||||
)
|
||||
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
)
|
||||
|
||||
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
'djblets.auth',
|
||||
'djblets.datagrid',
|
||||
'djblets.extensions',
|
||||
'djblets.feedview',
|
||||
'djblets.gravatars',
|
||||
'djblets.log',
|
||||
'djblets.pipeline',
|
||||
'djblets.siteconfig',
|
||||
'djblets.testing',
|
||||
'djblets.util',
|
||||
'djblets.webapi',
|
||||
]
|
|
@ -0,0 +1,36 @@
|
|||
#
|
||||
# admin.py -- Admin site definitions for siteconfig
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from djblets.siteconfig.models import SiteConfiguration
|
||||
|
||||
|
||||
class SiteConfigurationAdmin(admin.ModelAdmin):
|
||||
list_display = ('site', 'version')
|
||||
readonly_fields = ('settings',)
|
||||
|
||||
|
||||
admin.site.register(SiteConfiguration, SiteConfigurationAdmin)
|
|
@ -0,0 +1,36 @@
|
|||
#
|
||||
# context_processors.py -- Context processors for the siteconfig app.
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from djblets.siteconfig.models import SiteConfiguration
|
||||
|
||||
|
||||
def siteconfig(request):
|
||||
"""
|
||||
Exposes the site configuration as a siteconfig variable in templates.
|
||||
"""
|
||||
try:
|
||||
return {'siteconfig': SiteConfiguration.objects.get_current()}
|
||||
except:
|
||||
return {'siteconfig': None}
|
|
@ -0,0 +1,209 @@
|
|||
#
|
||||
# djblets/siteconfig/django_settings.py
|
||||
#
|
||||
# Copyright (c) 2008 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core.cache import DEFAULT_CACHE_ALIAS
|
||||
from django.utils import timezone
|
||||
|
||||
from djblets.util.cache import normalize_cache_backend
|
||||
|
||||
|
||||
def _set_cache_backend(settings, key, value):
|
||||
settings.CACHES[DEFAULT_CACHE_ALIAS] = normalize_cache_backend(value)
|
||||
|
||||
|
||||
def _set_static_url(settings, key, value):
|
||||
settings.STATIC_URL = value
|
||||
staticfiles_storage.base_url = value
|
||||
|
||||
|
||||
def _set_timezone(settings, key, value):
|
||||
settings.TIME_ZONE = value
|
||||
|
||||
# Internally, Django will also set os.environ['TZ'] to this value
|
||||
# and call time.tzset() when initially loading settings. We don't do
|
||||
# that, because it can have consequences.
|
||||
#
|
||||
# You can think of the timezone being set initially by Django as being
|
||||
# the core timezone that will be used for anything outside of a request.
|
||||
# What we set here is the timezone that Django will use in its own
|
||||
# timezone-related functions (for DateTimeFields and the like).
|
||||
#
|
||||
# That does mean that time.localtime and other functions will not
|
||||
# produce reliable dates. However, we need to ensure that any date/time
|
||||
# code is timezone-aware anyway, and works with our setting.
|
||||
#
|
||||
# To see how using os.environ['TZ'] would cause us problems, read
|
||||
# http://blog.chipx86.com/2013/01/26/weird-bugs-django-timezones-and-importing-from-eggs/
|
||||
timezone.activate(settings.TIME_ZONE)
|
||||
|
||||
|
||||
locale_settings_map = {
|
||||
'locale_timezone': { 'key': 'TIME_ZONE',
|
||||
'deserialize_func': str,
|
||||
'setter': _set_timezone },
|
||||
'locale_language_code': 'LANGUAGE_CODE',
|
||||
'locale_date_format': 'DATE_FORMAT',
|
||||
'locale_datetime_format': 'DATETIME_FORMAT',
|
||||
'locale_default_charset': { 'key': 'DEFAULT_CHARSET',
|
||||
'deserialize_func': str },
|
||||
'locale_language_code': 'LANGUAGE_CODE',
|
||||
'locale_month_day_format': 'MONTH_DAY_FORMAT',
|
||||
'locale_time_format': 'TIME_FORMAT',
|
||||
'locale_year_month_format': 'YEAR_MONTH_FORMAT',
|
||||
}
|
||||
|
||||
mail_settings_map = {
|
||||
'mail_server_address': 'SERVER_EMAIL',
|
||||
'mail_default_from': 'DEFAULT_FROM_EMAIL',
|
||||
'mail_host': 'EMAIL_HOST',
|
||||
'mail_port': 'EMAIL_PORT',
|
||||
'mail_host_user': { 'key': 'EMAIL_HOST_USER',
|
||||
'deserialize_func': str },
|
||||
'mail_host_password': { 'key': 'EMAIL_HOST_PASSWORD',
|
||||
'deserialize_func': str },
|
||||
'mail_use_tls': 'EMAIL_USE_TLS',
|
||||
}
|
||||
|
||||
site_settings_map = {
|
||||
'site_media_root': 'MEDIA_ROOT',
|
||||
'site_media_url': 'MEDIA_URL',
|
||||
'site_static_root': 'STATIC_ROOT',
|
||||
'site_static_url': { 'key': 'STATIC_URL',
|
||||
'setter': _set_static_url },
|
||||
'site_prepend_www': 'PREPEND_WWW',
|
||||
'site_upload_temp_dir': 'FILE_UPLOAD_TEMP_DIR',
|
||||
'site_upload_max_memory_size': 'FILE_UPLOAD_MAX_MEMORY_SIZE',
|
||||
}
|
||||
|
||||
cache_settings_map = {
|
||||
'cache_backend': { 'key': 'CACHES',
|
||||
'setter': _set_cache_backend },
|
||||
'cache_expiration_time': 'CACHE_EXPIRATION_TIME',
|
||||
}
|
||||
|
||||
|
||||
# Don't build unless we need it.
|
||||
_django_settings_map = {}
|
||||
|
||||
|
||||
def get_django_settings_map():
|
||||
"""
|
||||
Returns the settings map for all Django settings that users may need
|
||||
to customize.
|
||||
"""
|
||||
if not _django_settings_map:
|
||||
_django_settings_map.update(locale_settings_map)
|
||||
_django_settings_map.update(mail_settings_map)
|
||||
_django_settings_map.update(site_settings_map)
|
||||
_django_settings_map.update(cache_settings_map)
|
||||
|
||||
return _django_settings_map
|
||||
|
||||
|
||||
def generate_defaults(settings_map):
|
||||
"""
|
||||
Utility function to generate a defaults mapping.
|
||||
"""
|
||||
defaults = {}
|
||||
|
||||
for siteconfig_key, setting_data in settings_map.iteritems():
|
||||
if isinstance(setting_data, dict):
|
||||
setting_key = setting_data['key']
|
||||
else:
|
||||
setting_key = setting_data
|
||||
|
||||
if hasattr(settings, setting_key):
|
||||
defaults[siteconfig_key] = getattr(settings, setting_key)
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def get_locale_defaults():
|
||||
"""
|
||||
Returns the locale-related Django defaults that projects may want to
|
||||
let users customize.
|
||||
"""
|
||||
return generate_defaults(locale_settings_map)
|
||||
|
||||
|
||||
def get_mail_defaults():
|
||||
"""
|
||||
Returns the mail-related Django defaults that projects may want to
|
||||
let users customize.
|
||||
"""
|
||||
return generate_defaults(mail_settings_map)
|
||||
|
||||
|
||||
def get_site_defaults():
|
||||
"""
|
||||
Returns the site-related Django defaults that projects may want to
|
||||
let users customize.
|
||||
"""
|
||||
return generate_defaults(site_settings_map)
|
||||
|
||||
|
||||
def get_cache_defaults():
|
||||
"""
|
||||
Returns the cache-related Django defaults that projects may want to
|
||||
let users customize.
|
||||
"""
|
||||
return generate_defaults(cache_settings_map)
|
||||
|
||||
|
||||
def get_django_defaults():
|
||||
"""
|
||||
Returns all Django defaults that projects may want to let users customize.
|
||||
"""
|
||||
return generate_defaults(get_django_settings_map())
|
||||
|
||||
|
||||
def apply_django_settings(siteconfig, settings_map=None):
|
||||
"""
|
||||
Applies all settings from the site configuration to the Django settings
|
||||
object.
|
||||
"""
|
||||
if settings_map is None:
|
||||
settings_map = get_django_settings_map()
|
||||
|
||||
for key, setting_data in settings_map.iteritems():
|
||||
if key in siteconfig.settings:
|
||||
value = siteconfig.get(key)
|
||||
setter = setattr
|
||||
|
||||
if isinstance(setting_data, dict):
|
||||
setting_key = setting_data['key']
|
||||
|
||||
if 'setter' in setting_data:
|
||||
setter = setting_data['setter']
|
||||
|
||||
if ('deserialize_func' in setting_data and
|
||||
callable(setting_data['deserialize_func'])):
|
||||
value = setting_data['deserialize_func'](value)
|
||||
else:
|
||||
setting_key = setting_data
|
||||
|
||||
setter(settings, setting_key, value)
|
|
@ -0,0 +1,73 @@
|
|||
#
|
||||
# forms.py -- Forms for the siteconfig app.
|
||||
#
|
||||
# Copyright (c) 2008-2009 Christian Hammond
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
|
||||
from django import forms
|
||||
|
||||
|
||||
class SiteSettingsForm(forms.Form):
|
||||
"""
|
||||
A base form for loading/saving settings for a SiteConfiguration. This is
|
||||
meant to be subclassed for different settings pages. Any fields defined
|
||||
by the form will be loaded/saved automatically.
|
||||
"""
|
||||
def __init__(self, siteconfig, *args, **kwargs):
|
||||
forms.Form.__init__(self, *args, **kwargs)
|
||||
self.siteconfig = siteconfig
|
||||
self.disabled_fields = {}
|
||||
self.disabled_reasons = {}
|
||||
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""
|
||||
Loads settings from the ```SiteConfiguration''' into this form.
|
||||
The default values in the form will be the values in the settings.
|
||||
|
||||
This also handles setting disabled fields based on the
|
||||
```disabled_fields''' and ```disabled_reasons''' variables set on
|
||||
this form.
|
||||
"""
|
||||
for field in self.fields:
|
||||
value = self.siteconfig.get(field)
|
||||
self.fields[field].initial = value
|
||||
|
||||
if field in self.disabled_fields:
|
||||
self.fields[field].widget.attrs['disabled'] = 'disabled'
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Saves settings from the form back into the ```SiteConfiguration'''.
|
||||
"""
|
||||
if not self.errors:
|
||||
if hasattr(self, "Meta"):
|
||||
save_blacklist = getattr(self.Meta, "save_blacklist", [])
|
||||
else:
|
||||
save_blacklist = []
|
||||
|
||||
for key, value in self.cleaned_data.iteritems():
|
||||
if key not in save_blacklist:
|
||||
self.siteconfig.set(key, value)
|
||||
|
||||
self.siteconfig.save()
|