Move djblets sources to murano-dashboard repository.

Change-Id: Idb44f2b4111629139ae1f3afd5f490d5270e72e1
Fixes: bug/MRN-1045
This commit is contained in:
Timur Sufiev 2013-09-23 17:01:13 +04:00
parent 098d84a4da
commit 3dc1f5fd9d
163 changed files with 18759 additions and 36 deletions

12
libs/djblets/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
build
dist
djblets/static/*
Djblets.egg-info
.coverage
*.rej
*.orig
.*.sw*
*.pyc
.DS_Store

View File

@ -0,0 +1,3 @@
REVIEWBOARD_URL = "http://reviews.reviewboard.org"
REPOSITORY = "Djblets"
BRANCH = "master"

34
libs/djblets/AUTHORS Normal file
View File

@ -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

5
libs/djblets/MANIFEST.in Normal file
View File

@ -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

1543
libs/djblets/NEWS Normal file

File diff suppressed because it is too large Load Diff

76
libs/djblets/README Normal file
View File

@ -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.

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

View File

@ -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()

View File

@ -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."])

View File

@ -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

View File

@ -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()

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -0,0 +1,14 @@
<div class="paginator">
{% if show_first %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page=1" title="First Page">&laquo;</a>{% endif %}
{% if has_previous %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{previous}}" title="Previous Page">&lt;</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">&gt;</a></span>{% endif %}
{% if show_last %}<a href="?{%if extra_query%}{{extra_query}}&{%endif%}page={{pages}}" title="Last Page">&raquo;</a></span>{% endif %}
<span class="page-count">{{pages}} pages</span>
</div>

View File

@ -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),
}

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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, [])

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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"])

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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

View File

@ -0,0 +1,6 @@
from django.conf.urls.defaults import patterns
urlpatterns = patterns('djblets.extensions.views',
(r'^$', 'test_url')
)

View File

@ -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()

View File

@ -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')
)

View File

@ -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),
}))

View File

@ -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 %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
{% include "feedview/feed-inline.html" %}
{% endblock %}

View File

@ -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))

View File

@ -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"}),
)

View File

@ -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&#8217;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&#8217;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&#8217;s a few major features still planned, and lots of bug fixing. We&#8217;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&#8217;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&#8217;ll be posting [...]]]></description>
<content:encoded><![CDATA[<p>In the past, we&#8217;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&#8217;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>

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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,
}

View File

@ -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 %}

View File

@ -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")

View File

@ -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')
)

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

View File

@ -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);

View File

@ -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");
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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 %} />

View File

@ -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',
]

View File

@ -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)

View File

@ -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}

View File

@ -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)

View File

@ -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()

Some files were not shown because too many files have changed in this diff Show More