diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000..16329f39cf --- /dev/null +++ b/AUTHORS @@ -0,0 +1,8 @@ +Project Owner... + +Tom Christie - tom@tomchristie.com + +Thanks to... + +Jesper Noehr & the django-piston contributors for providing the starting point for this project. +Paul Bagwell - Suggestions & bugfixes. diff --git a/CREDITS.txt b/CREDITS.txt deleted file mode 100644 index ed410ee2f4..0000000000 --- a/CREDITS.txt +++ /dev/null @@ -1,4 +0,0 @@ -Thanks to... - -Jesper Noehr & the django-piston contributors for providing the starting point for this project. -Paul Bagwell - Suggestions & bugfixes. diff --git a/README.txt b/README similarity index 63% rename from README.txt rename to README index b79d76ae56..3e5e275575 100644 --- a/README.txt +++ b/README @@ -4,18 +4,23 @@ hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework cd django-rest-framework/ virtualenv --no-site-packages --distribute --python=python2.6 env source ./env/bin/activate -pip install -r requirements.txt +pip install -r requirements.txt # django, pip -# To build the documentation... +# To run the tests... -pip install -r docs/requirements.txt -sphinx-build -c docs -b html -d docs/build docs html +cd testproject +export PYTHONPATH=.. +python manage.py test djangorestframework # To run the examples... -pip install -r examples/requirements.txt +pip install -r examples/requirements.txt # pygments, httplib2, markdown cd examples export PYTHONPATH=.. python manage.py syncdb python manage.py runserver +# To build the documentation... + +pip install -r docs/requirements.txt # sphinx +sphinx-build -c docs -b html -d docs/build docs html diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index 24addb2295..85ba9f114e 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -1,17 +1,41 @@ from django.contrib.auth import authenticate +from django.middleware.csrf import CsrfViewMiddleware +from djangorestframework.utils import as_tuple import base64 + +class AuthenticatorMixin(object): + authenticators = None + + def authenticate(self, request): + """Attempt to authenticate the request, returning an authentication context or None. + An authentication context may be any object, although in many cases it will be a User instance.""" + + # Attempt authentication against each authenticator in turn, + # and return None if no authenticators succeed in authenticating the request. + for authenticator in as_tuple(self.authenticators): + auth_context = authenticator(self).authenticate(request) + if auth_context: + return auth_context + + return None + + class BaseAuthenticator(object): """All authenticators should extend BaseAuthenticator.""" - def __init__(self, resource): - """Initialise the authenticator with the Resource instance as state, - in case the authenticator needs to access any metadata on the Resource object.""" - self.resource = resource + def __init__(self, mixin): + """Initialise the authenticator with the mixin instance as state, + in case the authenticator needs to access any metadata on the mixin object.""" + self.mixin = mixin def authenticate(self, request): """Authenticate the request and return the authentication context or None. + An authentication context might be something as simple as a User object, or it might + be some more complicated token, for example authentication tokens which are signed + against a particular set of permissions for a given user, over a given timeframe. + The default permission checking on Resource will use the allowed_methods attribute for permissions if the authentication context is not None, and use anon_allowed_methods otherwise. @@ -38,7 +62,9 @@ def authenticate(self, request): class UserLoggedInAuthenticator(BaseAuthenticator): """Use Djagno's built-in request session for authentication.""" def authenticate(self, request): - if getattr(request, 'user', None) and request.user.is_active: - return request.user + if getattr(request, 'user', None) and request.user.is_active: + resp = CsrfViewMiddleware().process_view(request, None, (), {}) + if resp is None: # csrf passed + return request.user return None diff --git a/djangorestframework/breadcrumbs.py b/djangorestframework/breadcrumbs.py new file mode 100644 index 0000000000..ba779dd015 --- /dev/null +++ b/djangorestframework/breadcrumbs.py @@ -0,0 +1,31 @@ +from django.core.urlresolvers import resolve +from djangorestframework.description import get_name + +def get_breadcrumbs(url): + """Given a url returns a list of breadcrumbs, which are each a tuple of (name, url).""" + + def breadcrumbs_recursive(url, breadcrumbs_list): + """Add tuples of (name, url) to the breadcrumbs list, progressively chomping off parts of the url.""" + + # This is just like compsci 101 all over again... + try: + (view, unused_args, unused_kwargs) = resolve(url) + except: + pass + else: + if callable(view): + breadcrumbs_list.insert(0, (get_name(view), url)) + + if url == '': + # All done + return breadcrumbs_list + + elif url.endswith('/'): + # Drop trailing slash off the end and continue to try to resolve more breadcrumbs + return breadcrumbs_recursive(url.rstrip('/'), breadcrumbs_list) + + # Drop trailing non-slash off the end and continue to try to resolve more breadcrumbs + return breadcrumbs_recursive(url[:url.rfind('/') + 1], breadcrumbs_list) + + return breadcrumbs_recursive(url, []) + diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py new file mode 100644 index 0000000000..3e82bd98a9 --- /dev/null +++ b/djangorestframework/compat.py @@ -0,0 +1,128 @@ +"""Compatability module to provide support for backwards compatability with older versions of django/python""" + +# django.test.client.RequestFactory (Django >= 1.3) +try: + from django.test.client import RequestFactory + +except ImportError: + from django.test import Client + from django.core.handlers.wsgi import WSGIRequest + + # From: http://djangosnippets.org/snippets/963/ + # Lovely stuff + class RequestFactory(Client): + """ + Class that lets you create mock Request objects for use in testing. + + Usage: + + rf = RequestFactory() + get_request = rf.get('/hello/') + post_request = rf.post('/submit/', {'foo': 'bar'}) + + This class re-uses the django.test.client.Client interface, docs here: + http://www.djangoproject.com/documentation/testing/#the-test-client + + Once you have a request object you can pass it to any view function, + just as if that view had been hooked up using a URLconf. + + """ + def request(self, **request): + """ + Similar to parent class, but returns the request object as soon as it + has created it. + """ + environ = { + 'HTTP_COOKIE': self.cookies, + 'PATH_INFO': '/', + 'QUERY_STRING': '', + 'REQUEST_METHOD': 'GET', + 'SCRIPT_NAME': '', + 'SERVER_NAME': 'testserver', + 'SERVER_PORT': 80, + 'SERVER_PROTOCOL': 'HTTP/1.1', + } + environ.update(self.defaults) + environ.update(request) + return WSGIRequest(environ) + +# django.views.generic.View (Django >= 1.3) +try: + from django.views.generic import View +except: + from django import http + from django.utils.functional import update_wrapper + # from django.utils.log import getLogger + # from django.utils.decorators import classonlymethod + + # logger = getLogger('django.request') - We'll just drop support for logger if running Django <= 1.2 + # Might be nice to fix this up sometime to allow djangorestframework.compat.View to match 1.3's View more closely + + class View(object): + """ + Intentionally simple parent class for all views. Only implements + dispatch-by-method and simple sanity checking. + """ + + http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace'] + + def __init__(self, **kwargs): + """ + Constructor. Called in the URLconf; can contain helpful extra + keyword arguments, and other things. + """ + # Go through keyword arguments, and either save their values to our + # instance, or raise an error. + for key, value in kwargs.iteritems(): + setattr(self, key, value) + + # @classonlymethod - We'll just us classmethod instead if running Django <= 1.2 + @classmethod + def as_view(cls, **initkwargs): + """ + Main entry point for a request-response process. + """ + # sanitize keyword arguments + for key in initkwargs: + if key in cls.http_method_names: + raise TypeError(u"You tried to pass in the %s method name as a " + u"keyword argument to %s(). Don't do that." + % (key, cls.__name__)) + if not hasattr(cls, key): + raise TypeError(u"%s() received an invalid keyword %r" % ( + cls.__name__, key)) + + def view(request, *args, **kwargs): + self = cls(**initkwargs) + return self.dispatch(request, *args, **kwargs) + + # take name and docstring from class + update_wrapper(view, cls, updated=()) + + # and possible attributes set by decorators + # like csrf_exempt from dispatch + update_wrapper(view, cls.dispatch, assigned=()) + return view + + def dispatch(self, request, *args, **kwargs): + # Try to dispatch to the right method; if a method doesn't exist, + # defer to the error handler. Also defer to the error handler if the + # request method isn't on the approved list. + if request.method.lower() in self.http_method_names: + handler = getattr(self, request.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + self.request = request + self.args = args + self.kwargs = kwargs + return handler(request, *args, **kwargs) + + def http_method_not_allowed(self, request, *args, **kwargs): + allowed_methods = [m for m in self.http_method_names if hasattr(self, m)] + #logger.warning('Method Not Allowed (%s): %s' % (request.method, request.path), + # extra={ + # 'status_code': 405, + # 'request': self.request + # } + #) + return http.HttpResponseNotAllowed(allowed_methods) \ No newline at end of file diff --git a/djangorestframework/description.py b/djangorestframework/description.py new file mode 100644 index 0000000000..f7145c0f89 --- /dev/null +++ b/djangorestframework/description.py @@ -0,0 +1,37 @@ +"""Get a descriptive name and description for a view, +based on class name and docstring, and override-able by 'name' and 'description' attributes""" +import re + +def get_name(view): + """Return a name for the view. + + If view has a name attribute, use that, otherwise use the view's class name, with 'CamelCaseNames' converted to 'Camel Case Names'.""" + if getattr(view, 'name', None) is not None: + return view.name + + if getattr(view, '__name__', None) is not None: + name = view.__name__ + elif getattr(view, '__class__', None) is not None: # TODO: should be able to get rid of this case once refactoring to 1.3 class views is complete + name = view.__class__.__name__ + else: + return '' + + return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', name).strip() + +def get_description(view): + """Provide a description for the view. + + By default this is the view's docstring with nice unindention applied.""" + if getattr(view, 'description', None) is not None: + return getattr(view, 'description') + + if getattr(view, '__doc__', None) is not None: + whitespace_counts = [len(line) - len(line.lstrip(' ')) for line in view.__doc__.splitlines()[1:] if line.lstrip()] + + if whitespace_counts: + whitespace_pattern = '^' + (' ' * min(whitespace_counts)) + return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', view.__doc__) + + return view.__doc__ + + return '' \ No newline at end of file diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index d6bb33c18f..e6129b4f76 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -4,21 +4,145 @@ and providing forms and links depending on the allowed methods, emitters and parsers on the Resource. """ from django.conf import settings +from django.http import HttpResponse from django.template import RequestContext, loader from django import forms -from djangorestframework.response import NoContent +from djangorestframework.response import NoContent, ResponseException, status from djangorestframework.validators import FormValidatorMixin from djangorestframework.utils import dict2xml, url_resolves +from djangorestframework.markdownwrapper import apply_markdown +from djangorestframework.breadcrumbs import get_breadcrumbs +from djangorestframework.content import OverloadedContentMixin +from djangorestframework.description import get_name, get_description from urllib import quote_plus import string +import re +from decimal import Decimal + try: import json except ImportError: import simplejson as json +_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') + + +class EmitterMixin(object): + ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params + REWRITE_IE_ACCEPT_HEADER = True + + request = None + response = None + emitters = () + + def emit(self, response): + self.response = response + + try: + emitter = self._determine_emitter(self.request) + except ResponseException, exc: + emitter = self.default_emitter + response = exc.response + + # Serialize the response content + if response.has_content_body: + content = emitter(self).emit(output=response.cleaned_content) + else: + content = emitter(self).emit() + + # Munge DELETE Response code to allow us to return content + # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output) + if response.status == 204: + response.status = 200 + + # Build the HTTP Response + # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set + resp = HttpResponse(content, mimetype=emitter.media_type, status=response.status) + for (key, val) in response.headers.items(): + resp[key] = val + + return resp + + + def _determine_emitter(self, request): + """Return the appropriate emitter for the output, given the client's 'Accept' header, + and the content types that this Resource knows how to serve. + + See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" + + if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None): + # Use _accept parameter override + accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)] + elif self.REWRITE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']): + accept_list = ['text/html', '*/*'] + elif request.META.has_key('HTTP_ACCEPT'): + # Use standard HTTP Accept negotiation + accept_list = request.META["HTTP_ACCEPT"].split(',') + else: + # No accept header specified + return self.default_emitter + + # Parse the accept header into a dict of {qvalue: set of media types} + # We ignore mietype parameters + accept_dict = {} + for token in accept_list: + components = token.split(';') + mimetype = components[0].strip() + qvalue = Decimal('1.0') + + if len(components) > 1: + # Parse items that have a qvalue eg text/html;q=0.9 + try: + (q, num) = components[-1].split('=') + if q == 'q': + qvalue = Decimal(num) + except: + # Skip malformed entries + continue + + if accept_dict.has_key(qvalue): + accept_dict[qvalue].add(mimetype) + else: + accept_dict[qvalue] = set((mimetype,)) + + # Convert to a list of sets ordered by qvalue (highest first) + accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)] + + for accept_set in accept_sets: + # Return any exact match + for emitter in self.emitters: + if emitter.media_type in accept_set: + return emitter + + # Return any subtype match + for emitter in self.emitters: + if emitter.media_type.split('/')[0] + '/*' in accept_set: + return emitter + + # Return default + if '*/*' in accept_set: + return self.default_emitter + + + raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE, + {'detail': 'Could not statisfy the client\'s Accept header', + 'available_types': self.emitted_media_types}) + + @property + def emitted_media_types(self): + """Return an list of all the media types that this resource can emit.""" + return [emitter.media_type for emitter in self.emitters] + + @property + def default_emitter(self): + """Return the resource's most prefered emitter. + (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)""" + return self.emitters[0] + + # TODO: Rename verbose to something more appropriate # TODO: NoContent could be handled more cleanly. It'd be nice if it was handled by default, @@ -51,7 +175,7 @@ def emit(self, output=NoContent, verbose=False): if output is NoContent: return '' - context = RequestContext(self.resource.request, output) + context = RequestContext(self.request, output) return self.template.render(context) @@ -60,7 +184,7 @@ class DocumentingTemplateEmitter(BaseEmitter): Implementing classes should extend this class and set the template attribute.""" template = None - def _get_content(self, resource, output): + def _get_content(self, resource, request, output): """Get the content as if it had been emitted by a non-documenting emitter. (Typically this will be the content as it would have been if the Resource had been @@ -88,21 +212,24 @@ def _get_form_instance(self, resource): form_instance = None - if isinstance(self, FormValidatorMixin): - # Otherwise if this isn't an error response - # then attempt to get a form bound to the response object + if isinstance(resource, FormValidatorMixin): + # If we already have a bound form instance (IE provided by the input parser, then use that) + if resource.bound_form_instance is not None: + form_instance = resource.bound_form_instance + + # Otherwise if we have a response that is valid against the form then use that if not form_instance and resource.response.has_content_body: try: form_instance = resource.get_bound_form(resource.response.raw_content) - if form_instance: - form_instance.is_valid() + if form_instance and not form_instance.is_valid(): + form_instance = None except: form_instance = None # If we still don't have a form instance then try to get an unbound form if not form_instance: try: - form_instance = self.resource.get_bound_form() + form_instance = resource.get_bound_form() except: pass @@ -117,6 +244,11 @@ def _get_generic_content_form(self, resource): """Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms (Which are typically application/x-www-form-urlencoded)""" + # If we're not using content overloading there's no point in supplying a generic form, + # as the resource won't treat the form's value as the content of the request. + if not isinstance(resource, OverloadedContentMixin): + return None + # NB. http://jacobian.org/writing/dynamic-form-generation/ class GenericContentForm(forms.Form): def __init__(self, resource): @@ -143,7 +275,7 @@ def __init__(self, resource): def emit(self, output=NoContent): - content = self._get_content(self.resource, output) + content = self._get_content(self.resource, self.resource.request, output) form_instance = self._get_form_instance(self.resource) if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL): @@ -153,24 +285,36 @@ def emit(self, output=NoContent): login_url = None logout_url = None + name = get_name(self.resource) + description = get_description(self.resource) + + markeddown = None + if apply_markdown: + try: + markeddown = apply_markdown(description) + except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class + markeddown = None + + breadcrumb_list = get_breadcrumbs(self.resource.request.path) + template = loader.get_template(self.template) context = RequestContext(self.resource.request, { 'content': content, 'resource': self.resource, 'request': self.resource.request, 'response': self.resource.response, + 'description': description, + 'name': name, + 'markeddown': markeddown, + 'breadcrumblist': breadcrumb_list, 'form': form_instance, 'login_url': login_url, 'logout_url': logout_url, + 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX }) ret = template.render(context) - # Munge DELETE Response code to allow us to return content - # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output) - if self.resource.response.status == 204: - self.resource.response.status = 200 - return ret @@ -217,5 +361,11 @@ class DocumentingPlainTextEmitter(DocumentingTemplateEmitter): Useful for browsing an API with command line tools.""" media_type = 'text/plain' template = 'emitter.txt' + +DEFAULT_EMITTERS = ( JSONEmitter, + DocumentingHTMLEmitter, + DocumentingXHTMLEmitter, + DocumentingPlainTextEmitter, + XMLEmitter ) diff --git a/djangorestframework/markdownwrapper.py b/djangorestframework/markdownwrapper.py new file mode 100644 index 0000000000..70512440bb --- /dev/null +++ b/djangorestframework/markdownwrapper.py @@ -0,0 +1,51 @@ +"""If python-markdown is installed expose an apply_markdown(text) function, +to convert markeddown text into html. Otherwise just set apply_markdown to None. + +See: http://www.freewisdom.org/projects/python-markdown/ +""" + +__all__ = ['apply_markdown'] + +try: + import markdown + import re + + class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor): + """Override markdown's SetextHeaderProcessor, so that ==== headers are

and ---- headers are

. + + We use

for the resource name.""" + + # Detect Setext-style header. Must be first 2 lines of block. + RE = re.compile(r'^.*?\n[=-]{3,}', re.MULTILINE) + + def test(self, parent, block): + return bool(self.RE.match(block)) + + def run(self, parent, blocks): + lines = blocks.pop(0).split('\n') + # Determine level. ``=`` is 1 and ``-`` is 2. + if lines[1].startswith('='): + level = 2 + else: + level = 3 + h = markdown.etree.SubElement(parent, 'h%d' % level) + h.text = lines[0].strip() + if len(lines) > 2: + # Block contains additional lines. Add to master blocks for later. + blocks.insert(0, '\n'.join(lines[2:])) + + def apply_markdown(text): + """Simple wrapper around markdown.markdown to apply our CustomSetextHeaderProcessor, + and also set the base level of '#' style headers to

.""" + extensions = ['headerid(level=2)'] + safe_mode = False, + output_format = markdown.DEFAULT_OUTPUT_FORMAT + + md = markdown.Markdown(extensions=markdown.load_extensions(extensions), + safe_mode=safe_mode, + output_format=output_format) + md.parser.blockprocessors['setextheader'] = CustomSetextHeaderProcessor(md.parser) + return md.convert(text) + +except: + apply_markdown = None \ No newline at end of file diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 2b0e719c77..19930e0752 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -4,13 +4,14 @@ from djangorestframework.response import status, Response, ResponseException from djangorestframework.resource import Resource +from djangorestframework.validators import ModelFormValidatorMixin import decimal import inspect import re -class ModelResource(Resource): +class ModelResource(Resource, ModelFormValidatorMixin): """A specialized type of Resource, for resources that map directly to a Django Model. Useful things this provides: @@ -40,50 +41,50 @@ class ModelResource(Resource): # By default the set of input fields will be the same as the set of output fields # If you wish to override this behaviour you should explicitly set the # form_fields attribute on your class. - form_fields = None + #form_fields = None - def get_form(self, content=None): - """Return a form that may be used in validation and/or rendering an html emitter""" - if self.form: - return super(self.__class__, self).get_form(content) - - elif self.model: - - class NewModelForm(ModelForm): - class Meta: - model = self.model - fields = self.form_fields if self.form_fields else None - - if content and isinstance(content, Model): - return NewModelForm(instance=content) - elif content: - return NewModelForm(content) - - return NewModelForm() - - return None - - - def cleanup_request(self, data, form_instance): - """Override cleanup_request to drop read-only fields from the input prior to validation. - This ensures that we don't error out with 'non-existent field' when these fields are supplied, - and allows for a pragmatic approach to resources which include read-only elements. - - I would actually like to be strict and verify the value of correctness of the values in these fields, - although that gets tricky as it involves validating at the point that we get the model instance. - - See here for another example of this approach: - http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide - https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041""" - read_only_fields = set(self.fields) - set(self.form_instance.fields) - input_fields = set(data.keys()) + #def get_form(self, content=None): + # """Return a form that may be used in validation and/or rendering an html emitter""" + # if self.form: + # return super(self.__class__, self).get_form(content) + # + # elif self.model: + # + # class NewModelForm(ModelForm): + # class Meta: + # model = self.model + # fields = self.form_fields if self.form_fields else None + # + # if content and isinstance(content, Model): + # return NewModelForm(instance=content) + # elif content: + # return NewModelForm(content) + # + # return NewModelForm() + # + # return None - clean_data = {} - for key in input_fields - read_only_fields: - clean_data[key] = data[key] - return super(ModelResource, self).cleanup_request(clean_data, form_instance) + #def cleanup_request(self, data, form_instance): + # """Override cleanup_request to drop read-only fields from the input prior to validation. + # This ensures that we don't error out with 'non-existent field' when these fields are supplied, + # and allows for a pragmatic approach to resources which include read-only elements. + # + # I would actually like to be strict and verify the value of correctness of the values in these fields, + # although that gets tricky as it involves validating at the point that we get the model instance. + # + # See here for another example of this approach: + # http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide + # https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041""" + # read_only_fields = set(self.fields) - set(self.form_instance.fields) + # input_fields = set(data.keys()) + # + # clean_data = {} + # for key in input_fields - read_only_fields: + # clean_data[key] = data[key] + # + # return super(ModelResource, self).cleanup_request(clean_data, form_instance) def cleanup_response(self, data): @@ -121,7 +122,7 @@ def _any(thing, fields=()): if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: ret = _any(f()) else: - ret = unicode(thing) # TRC TODO: Change this back! + ret = str(thing) # TRC TODO: Change this back! return ret @@ -308,9 +309,9 @@ def _model(data, fields=()): try: ret['absolute_url'] = data.get_absolute_url() except: pass - for key, val in ret.items(): - if key.endswith('_url') or key.endswith('_uri'): - ret[key] = self.add_domain(val) + #for key, val in ret.items(): + # if key.endswith('_url') or key.endswith('_uri'): + # ret[key] = self.add_domain(val) return ret @@ -346,7 +347,7 @@ def post(self, request, auth, content, *args, **kwargs): instance.save() headers = {} if hasattr(instance, 'get_absolute_url'): - headers['Location'] = self.add_domain(instance.get_absolute_url()) + headers['Location'] = instance.get_absolute_url() return Response(status.HTTP_201_CREATED, instance, headers) def get(self, request, auth, *args, **kwargs): diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index f48f2f59da..db771ffb74 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -117,13 +117,7 @@ def parse(self, input): return data - -# TODO: Allow parsers to specify multiple media types +# TODO: Allow parsers to specify multiple media_types class MultipartParser(FormParser): - """The default parser for multipart form data. - Return a dict containing a single value for each non-reserved parameter. - """ - media_type = 'multipart/form-data' - diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index b1f48f0698..68ca6bf3e7 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -1,15 +1,16 @@ -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.http import HttpResponse +from django.core.urlresolvers import set_script_prefix +from django.views.decorators.csrf import csrf_exempt +from djangorestframework.compat import View +from djangorestframework.emitters import EmitterMixin from djangorestframework.parsers import ParserMixin +from djangorestframework.authenticators import AuthenticatorMixin from djangorestframework.validators import FormValidatorMixin from djangorestframework.content import OverloadedContentMixin from djangorestframework.methods import OverloadedPOSTMethodMixin from djangorestframework import emitters, parsers, authenticators from djangorestframework.response import status, Response, ResponseException -from decimal import Decimal import re # TODO: Figure how out references and named urls need to work nicely @@ -21,10 +22,10 @@ __all__ = ['Resource'] -_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') -class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, OverloadedPOSTMethodMixin): +class Resource(EmitterMixin, ParserMixin, AuthenticatorMixin, FormValidatorMixin, + OverloadedContentMixin, OverloadedPOSTMethodMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" @@ -52,67 +53,21 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload # Optional form for input validation and presentation of HTML formatted responses. form = None + # Allow name and description for the Resource to be set explicitly, + # overiding the default classname/docstring behaviour. + # These are used for documentation in the standard html and text emitters. + name = None + description = None + # Map standard HTTP methods to function calls callmap = { 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' } + # Some reserved parameters to allow us to use standard HTML forms with our resource # Override any/all of these with None to disable them, or override them with another value to rename them. - ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params CONTENTTYPE_PARAM = '_contenttype' # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms) CSRF_PARAM = 'csrfmiddlewaretoken' # Django's CSRF token used in form params - _MUNGE_IE_ACCEPT_HEADER = True - - def __new__(cls, *args, **kwargs): - """Make the class callable so it can be used as a Django view.""" - self = object.__new__(cls) - if args: - request = args[0] - self.__init__(request) - return self._handle_request(request, *args[1:], **kwargs) - else: - self.__init__() - return self - - - def __init__(self, request=None): - """""" - # Setup the resource context - self.request = request - self.response = None - self.form_instance = None - - # These sets are determined now so that overridding classes can modify the various parameter names, - # or set them to None to disable them. - self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM)) - self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM)) - self.RESERVED_FORM_PARAMS.discard(None) - self.RESERVED_QUERY_PARAMS.discard(None) - - - @property - def name(self): - """Provide a name for the resource. - By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'.""" - class_name = self.__class__.__name__ - return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip() - - @property - def description(self): - """Provide a description for the resource. - By default this is the class's docstring with leading line spaces stripped.""" - return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__) - - @property - def emitted_media_types(self): - """Return an list of all the media types that this resource can emit.""" - return [emitter.media_type for emitter in self.emitters] - - @property - def default_emitter(self): - """Return the resource's most prefered emitter. - (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)""" - return self.emitters[0] def get(self, request, auth, *args, **kwargs): """Must be subclassed to be implemented.""" @@ -134,12 +89,6 @@ def delete(self, request, auth, *args, **kwargs): self.not_implemented('DELETE') - def reverse(self, view, *args, **kwargs): - """Return a fully qualified URI for a given view or resource. - Add the domain using the Sites framework if possible, otherwise fallback to using the current request.""" - return self.add_domain(reverse(view, args=args, kwargs=kwargs)) - - def not_implemented(self, operation): """Return an HTTP 500 server error if an operation is called which has been allowed by allowed_methods, but which has not been implemented.""" @@ -147,36 +96,6 @@ def not_implemented(self, operation): {'detail': '%s operation on this resource has not been implemented' % (operation, )}) - def add_domain(self, path): - """Given a path, return an fully qualified URI. - Use the Sites framework if possible, otherwise fallback to using the domain from the current request.""" - - # Note that out-of-the-box the Sites framework uses the reserved domain 'example.com' - # See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html - try: - site = Site.objects.get_current() - if site.domain and site.domain != 'example.com': - return 'http://%s%s' % (site.domain, path) - except: - pass - - return self.request.build_absolute_uri(path) - - - def authenticate(self, request): - """Attempt to authenticate the request, returning an authentication context or None. - An authentication context may be any object, although in many cases it will be a User instance.""" - - # Attempt authentication against each authenticator in turn, - # and return None if no authenticators succeed in authenticating the request. - for authenticator in self.authenticators: - auth_context = authenticator(self).authenticate(request) - if auth_context: - return auth_context - - return None - - def check_method_allowed(self, method, auth): """Ensure the request method is permitted for this resource, raising a ResourceException if it is not.""" @@ -198,76 +117,16 @@ def cleanup_response(self, data): """Perform any resource-specific data filtering prior to the standard HTTP content-type serialization. - Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can.""" + Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can. + + TODO: This is going to be removed. I think that the 'fields' behaviour is going to move into + the EmitterMixin and Emitter classes.""" return data + # Session based authentication is explicitly CSRF validated, all other authentication is CSRF exempt. - def determine_emitter(self, request): - """Return the appropriate emitter for the output, given the client's 'Accept' header, - and the content types that this Resource knows how to serve. - - See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" - - if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None): - # Use _accept parameter override - accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)] - elif self._MUNGE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']): - accept_list = ['text/html', '*/*'] - elif request.META.has_key('HTTP_ACCEPT'): - # Use standard HTTP Accept negotiation - accept_list = request.META["HTTP_ACCEPT"].split(',') - else: - # No accept header specified - return self.default_emitter - - # Parse the accept header into a dict of {qvalue: set of media types} - # We ignore mietype parameters - accept_dict = {} - for token in accept_list: - components = token.split(';') - mimetype = components[0].strip() - qvalue = Decimal('1.0') - - if len(components) > 1: - # Parse items that have a qvalue eg text/html;q=0.9 - try: - (q, num) = components[-1].split('=') - if q == 'q': - qvalue = Decimal(num) - except: - # Skip malformed entries - continue - - if accept_dict.has_key(qvalue): - accept_dict[qvalue].add(mimetype) - else: - accept_dict[qvalue] = set((mimetype,)) - - # Convert to a list of sets ordered by qvalue (highest first) - accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)] - - for accept_set in accept_sets: - # Return any exact match - for emitter in self.emitters: - if emitter.media_type in accept_set: - return emitter - - # Return any subtype match - for emitter in self.emitters: - if emitter.media_type.split('/')[0] + '/*' in accept_set: - return emitter - - # Return default - if '*/*' in accept_set: - return self.default_emitter - - - raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE, - {'detail': 'Could not statisfy the client\'s Accept header', - 'available_types': self.emitted_media_types}) - - - def _handle_request(self, request, *args, **kwargs): + @csrf_exempt + def dispatch(self, request, *args, **kwargs): """This method is the core of Resource, through which all requests are passed. Broadly this consists of the following procedure: @@ -279,12 +138,23 @@ def _handle_request(self, request, *args, **kwargs): 4. cleanup the response data 5. serialize response data into response content, using standard HTTP content negotiation """ - emitter = None + + self.request = request + + # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. + prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) + set_script_prefix(prefix) + + # These sets are determined now so that overridding classes can modify the various parameter names, + # or set them to None to disable them. + self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM)) + self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM)) + self.RESERVED_FORM_PARAMS.discard(None) + self.RESERVED_QUERY_PARAMS.discard(None) + method = self.determine_method(request) try: - # Before we attempt anything else determine what format to emit our response data with. - emitter = self.determine_emitter(request) # Authenticate the request, and store any context so that the resource operations can # do more fine grained authentication if required. @@ -301,51 +171,34 @@ def _handle_request(self, request, *args, **kwargs): func = getattr(self, self.callmap.get(method, None)) # Either generate the response data, deserializing and validating any request data - # TODO: Add support for message bodys on other HTTP methods, as it is valid. + # TODO: Add support for message bodys on other HTTP methods, as it is valid (although non-conventional). if method in ('PUT', 'POST'): (content_type, content) = self.determine_content(request) parser_content = self.parse(content_type, content) cleaned_content = self.validate(parser_content) - response = func(request, auth_context, cleaned_content, *args, **kwargs) + response_obj = func(request, auth_context, cleaned_content, *args, **kwargs) else: - response = func(request, auth_context, *args, **kwargs) + response_obj = func(request, auth_context, *args, **kwargs) # Allow return value to be either Response, or an object, or None - if isinstance(response, Response): - self.response = response - elif response is not None: - self.response = Response(status.HTTP_200_OK, response) + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) else: - self.response = Response(status.HTTP_204_NO_CONTENT) + response = Response(status.HTTP_204_NO_CONTENT) # Pre-serialize filtering (eg filter complex objects into natively serializable types) - self.response.cleaned_content = self.cleanup_response(self.response.raw_content) + response.cleaned_content = self.cleanup_response(response.raw_content) except ResponseException, exc: - self.response = exc.response - - # Fall back to the default emitter if we failed to perform content negotiation - if emitter is None: - emitter = self.default_emitter - + response = exc.response # Always add these headers - self.response.headers['Allow'] = ', '.join(self.allowed_methods) - self.response.headers['Vary'] = 'Authenticate, Allow' - - # Serialize the response content - if self.response.has_content_body: - content = emitter(self).emit(output=self.response.cleaned_content) - else: - content = emitter(self).emit() - - # Build the HTTP Response - # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set - resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status) - for (key, val) in self.response.headers.items(): - resp[key] = val - - return resp + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Allow' + + return self.emit(response) diff --git a/djangorestframework/static/favicon.ico b/djangorestframework/static/favicon.ico new file mode 100644 index 0000000000..1708010646 Binary files /dev/null and b/djangorestframework/static/favicon.ico differ diff --git a/djangorestframework/static/robots.txt b/djangorestframework/static/robots.txt new file mode 100644 index 0000000000..1f53798bb4 --- /dev/null +++ b/djangorestframework/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/djangorestframework/templates/api_login.html b/djangorestframework/templates/api_login.html new file mode 100644 index 0000000000..ef383a0b5f --- /dev/null +++ b/djangorestframework/templates/api_login.html @@ -0,0 +1,48 @@ + + + + + + + + +
+ + + +
+ +
+
+{% csrf_token %} +
+ {{ form.username }} +
+
+ {{ form.password }} + +
+
+ +
+
+ + +
+ + +
+
+ + + +
+ + \ No newline at end of file diff --git a/djangorestframework/templates/emitter.html b/djangorestframework/templates/emitter.html index d21350cd1e..798c5fb97d 100644 --- a/djangorestframework/templates/emitter.html +++ b/djangorestframework/templates/emitter.html @@ -3,49 +3,59 @@ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> - - API - {{ resource.name }} + + + + Django REST framework - {{ name }} -
- Django REST framework - {% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} Log out{% endif %}{% else %}Not logged in {% if login_url %}Log in{% endif %}{% endif %} +
+ + -
-

{{ resource.name }}

-

{{ resource.description|linebreaksbr }}

+ + + +
+ +
+

{{ name }}

+

{% if markeddown %}{% autoescape off %}{{ markeddown }}{% endautoescape %}{% else %}{{ description|linebreaksbr }}{% endif %}

+
{{ response.status }} {{ response.status_text }}{% autoescape off %}
 {% for key, val in response.headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
 {% endfor %}
-{{ content|urlize_quoted_links }}
{% endautoescape %} - +{{ content|urlize_quoted_links }}{% endautoescape %}
+ {% if 'GET' in resource.allowed_methods %} -
- GET -
    - {% for media_type in resource.emitted_media_types %} - {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} -
  • [{{ media_type }}]
  • - {% endwith %} - {% endfor %} -
-
-
+
+
+

GET {{ name }}

+
+ GET + {% for media_type in resource.emitted_media_types %} + {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} + [{{ media_type }}] + {% endwith %} + {% endfor %} +
+
+
{% endif %} {% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method *** @@ -55,54 +65,63 @@

{{ resource.name }}

{% if resource.METHOD_PARAM and form %} {% if 'POST' in resource.allowed_methods %} -
+
+

POST {{ name }}

{% csrf_token %} {{ form.non_field_errors }} {% for field in form %} -
- {{ field.label_tag }}: +
+ {{ field.label_tag }} {{ field }} - {{ field.help_text }} + {{ field.help_text }} {{ field.errors }}
{% endfor %} -
- +
+ +
+
-
{% endif %} {% if 'PUT' in resource.allowed_methods %} -
+
+

PUT {{ name }}

{% csrf_token %} {{ form.non_field_errors }} {% for field in form %} -
- {{ field.label_tag }}: +
+ {{ field.label_tag }} {{ field }} - {{ field.help_text }} + {{ field.help_text }} {{ field.errors }}
{% endfor %} -
- +
+ +
+
-
{% endif %} {% if 'DELETE' in resource.allowed_methods %} -
+
+

DELETE {{ name }}

{% csrf_token %} - +
+ +
+
-
{% endif %} {% endif %}
+
+
\ No newline at end of file diff --git a/djangorestframework/templates/emitter.txt b/djangorestframework/templates/emitter.txt index 1cc7d1d763..5be8c11752 100644 --- a/djangorestframework/templates/emitter.txt +++ b/djangorestframework/templates/emitter.txt @@ -1,6 +1,6 @@ -{{ resource.name }} +{{ name }} -{{ resource.description }} +{{ description }} {% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }} {% for key, val in response.headers.items %}{{ key }}: {{ val }} diff --git a/djangorestframework/tests.py b/djangorestframework/tests.py deleted file mode 100644 index aec45c7e25..0000000000 --- a/djangorestframework/tests.py +++ /dev/null @@ -1,98 +0,0 @@ -from django.test import Client, TestCase -from django.core.handlers.wsgi import WSGIRequest -from djangorestframework.resource import Resource - -# From: http://djangosnippets.org/snippets/963/ -class RequestFactory(Client): - """ - Class that lets you create mock Request objects for use in testing. - - Usage: - - rf = RequestFactory() - get_request = rf.get('/hello/') - post_request = rf.post('/submit/', {'foo': 'bar'}) - - This class re-uses the django.test.client.Client interface, docs here: - http://www.djangoproject.com/documentation/testing/#the-test-client - - Once you have a request object you can pass it to any view function, - just as if that view had been hooked up using a URLconf. - - """ - def request(self, **request): - """ - Similar to parent class, but returns the request object as soon as it - has created it. - """ - environ = { - 'HTTP_COOKIE': self.cookies, - 'PATH_INFO': '/', - 'QUERY_STRING': '', - 'REQUEST_METHOD': 'GET', - 'SCRIPT_NAME': '', - 'SERVER_NAME': 'testserver', - 'SERVER_PORT': 80, - 'SERVER_PROTOCOL': 'HTTP/1.1', - } - environ.update(self.defaults) - environ.update(request) - return WSGIRequest(environ) - -# See: http://www.useragentstring.com/ -MSIE_9_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 9.0; WIndows NT 9.0; en-US))' -MSIE_8_USER_AGENT = 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)' -MSIE_7_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 7.0; Windows NT 6.0; en-US)' -FIREFOX_4_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; ru; rv:1.9.2.3) Gecko/20100401 Firefox/4.0 (.NET CLR 3.5.30729)' -CHROME_11_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/11.0.655.0 Safari/534.17' -SAFARI_5_0_USER_AGENT = 'Mozilla/5.0 (X11; U; Linux x86_64; en-ca) AppleWebKit/531.2+ (KHTML, like Gecko) Version/5.0 Safari/531.2+' -OPERA_11_0_MSIE_USER_AGENT = 'Mozilla/4.0 (compatible; MSIE 8.0; X11; Linux x86_64; pl) Opera 11.00' -OPERA_11_0_OPERA_USER_AGENT = 'Opera/9.80 (X11; Linux x86_64; U; pl) Presto/2.7.62 Version/11.00' - -class UserAgentMungingTest(TestCase): - """We need to fake up the accept headers when we deal with MSIE. Blergh. - http://www.gethifi.com/blog/browser-rest-http-accept-headers""" - - def setUp(self): - class MockResource(Resource): - anon_allowed_methods = allowed_methods = ('GET',) - def get(self, request, auth): - return {'a':1, 'b':2, 'c':3} - self.rf = RequestFactory() - self.MockResource = MockResource - - def test_munge_msie_accept_header(self): - """Send MSIE user agent strings and ensure that we get an HTML response, - even if we set a */* accept header.""" - for user_agent in (MSIE_9_USER_AGENT, - MSIE_8_USER_AGENT, - MSIE_7_USER_AGENT): - req = self.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) - self.assertEqual(resp['Content-Type'], 'text/html') - - def test_dont_munge_msie_accept_header(self): - """Turn off _MUNGE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure - that we get a JSON response if we set a */* accept header.""" - self.MockResource._MUNGE_IE_ACCEPT_HEADER = False - - for user_agent in (MSIE_9_USER_AGENT, - MSIE_8_USER_AGENT, - MSIE_7_USER_AGENT): - req = self.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) - self.assertEqual(resp['Content-Type'], 'application/json') - - def test_dont_munge_nice_browsers_accept_header(self): - """Send Non-MSIE user agent strings and ensure that we get a JSON response, - if we set a */* Accept header. (Other browsers will correctly set the Accept header)""" - for user_agent in (FIREFOX_4_0_USER_AGENT, - CHROME_11_0_USER_AGENT, - SAFARI_5_0_USER_AGENT, - OPERA_11_0_MSIE_USER_AGENT, - OPERA_11_0_OPERA_USER_AGENT): - req = self.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) - self.assertEqual(resp['Content-Type'], 'application/json') - - diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index c4964e8ab3..f2a21277d7 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -1,5 +1,5 @@ from django.test import TestCase -from djangorestframework.tests.utils import RequestFactory +from djangorestframework.compat import RequestFactory from djangorestframework.resource import Resource @@ -24,6 +24,7 @@ def get(self, request, auth): return {'a':1, 'b':2, 'c':3} self.req = RequestFactory() self.MockResource = MockResource + self.view = MockResource.as_view() def test_munge_msie_accept_header(self): """Send MSIE user agent strings and ensure that we get an HTML response, @@ -32,19 +33,19 @@ def test_munge_msie_accept_header(self): MSIE_8_USER_AGENT, MSIE_7_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) + resp = self.view(req) self.assertEqual(resp['Content-Type'], 'text/html') - def test_dont_munge_msie_accept_header(self): - """Turn off _MUNGE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure + def test_dont_rewrite_msie_accept_header(self): + """Turn off REWRITE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure that we get a JSON response if we set a */* accept header.""" - self.MockResource._MUNGE_IE_ACCEPT_HEADER = False + view = self.MockResource.as_view(REWRITE_IE_ACCEPT_HEADER=False) for user_agent in (MSIE_9_USER_AGENT, MSIE_8_USER_AGENT, MSIE_7_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) + resp = view(req) self.assertEqual(resp['Content-Type'], 'application/json') def test_dont_munge_nice_browsers_accept_header(self): @@ -56,7 +57,7 @@ def test_dont_munge_nice_browsers_accept_header(self): OPERA_11_0_MSIE_USER_AGENT, OPERA_11_0_OPERA_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) - resp = self.MockResource(req) + resp = self.view(req) self.assertEqual(resp['Content-Type'], 'application/json') diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py new file mode 100644 index 0000000000..af9c34ca9d --- /dev/null +++ b/djangorestframework/tests/authentication.py @@ -0,0 +1,90 @@ +from django.conf.urls.defaults import patterns +from django.test import TestCase +from django.test import Client +from djangorestframework.compat import RequestFactory +from djangorestframework.resource import Resource +from django.contrib.auth.models import User +from django.contrib.auth import login + +import base64 +try: + import json +except ImportError: + import simplejson as json + +class MockResource(Resource): + allowed_methods = ('POST',) + + def post(self, request, auth, content): + return {'a':1, 'b':2, 'c':3} + +urlpatterns = patterns('', + (r'^$', MockResource.as_view()), +) + + +class BasicAuthTests(TestCase): + """Basic authentication""" + urls = 'djangorestframework.tests.authentication' + + def setUp(self): + self.csrf_client = Client(enforce_csrf_checks=True) + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user(self.username, self.email, self.password) + + def test_post_form_passing_basic_auth(self): + """Ensure POSTing json over basic auth with correct credentials passes and does not require CSRF""" + auth = 'Basic %s' % base64.encodestring('%s:%s' % (self.username, self.password)).strip() + response = self.csrf_client.post('/', {'example': 'example'}, HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_post_json_passing_basic_auth(self): + """Ensure POSTing form over basic auth with correct credentials passes and does not require CSRF""" + auth = 'Basic %s' % base64.encodestring('%s:%s' % (self.username, self.password)).strip() + response = self.csrf_client.post('/', json.dumps({'example': 'example'}), 'application/json', HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_post_form_failing_basic_auth(self): + """Ensure POSTing form over basic auth without correct credentials fails""" + response = self.csrf_client.post('/', {'example': 'example'}) + self.assertEqual(response.status_code, 403) + + def test_post_json_failing_basic_auth(self): + """Ensure POSTing json over basic auth without correct credentials fails""" + response = self.csrf_client.post('/', json.dumps({'example': 'example'}), 'application/json') + self.assertEqual(response.status_code, 403) + + +class SessionAuthTests(TestCase): + """User session authentication""" + urls = 'djangorestframework.tests.authentication' + + def setUp(self): + self.csrf_client = Client(enforce_csrf_checks=True) + self.non_csrf_client = Client(enforce_csrf_checks=False) + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user(self.username, self.email, self.password) + + def tearDown(self): + self.csrf_client.logout() + + def test_post_form_session_auth_failing_csrf(self): + """Ensure POSTing form over session authentication without CSRF token fails.""" + self.csrf_client.login(username=self.username, password=self.password) + response = self.csrf_client.post('/', {'example': 'example'}) + self.assertEqual(response.status_code, 403) + + def test_post_form_session_auth_passing(self): + """Ensure POSTing form over session authentication with logged in user and CSRF token passes.""" + self.non_csrf_client.login(username=self.username, password=self.password) + response = self.non_csrf_client.post('/', {'example': 'example'}) + self.assertEqual(response.status_code, 200) + + def test_post_form_session_auth_failing(self): + """Ensure POSTing form over session authentication without logged in user fails.""" + response = self.csrf_client.post('/', {'example': 'example'}) + self.assertEqual(response.status_code, 403) diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py new file mode 100644 index 0000000000..cc0d283d63 --- /dev/null +++ b/djangorestframework/tests/breadcrumbs.py @@ -0,0 +1,67 @@ +from django.conf.urls.defaults import patterns, url +from django.test import TestCase +from djangorestframework.breadcrumbs import get_breadcrumbs +from djangorestframework.resource import Resource + +class Root(Resource): + pass + +class ResourceRoot(Resource): + pass + +class ResourceInstance(Resource): + pass + +class NestedResourceRoot(Resource): + pass + +class NestedResourceInstance(Resource): + pass + +urlpatterns = patterns('', + url(r'^$', Root), + url(r'^resource/$', ResourceRoot), + url(r'^resource/(?P[0-9]+)$', ResourceInstance), + url(r'^resource/(?P[0-9]+)/$', NestedResourceRoot), + url(r'^resource/(?P[0-9]+)/(?P[A-Za-z]+)$', NestedResourceInstance), +) + + +class BreadcrumbTests(TestCase): + """Tests the breadcrumb functionality used by the HTML emitter.""" + + urls = 'djangorestframework.tests.breadcrumbs' + + def test_root_breadcrumbs(self): + url = '/' + self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) + + def test_resource_root_breadcrumbs(self): + url = '/resource/' + self.assertEqual(get_breadcrumbs(url), [('Root', '/'), + ('Resource Root', '/resource/')]) + + def test_resource_instance_breadcrumbs(self): + url = '/resource/123' + self.assertEqual(get_breadcrumbs(url), [('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123')]) + + def test_nested_resource_breadcrumbs(self): + url = '/resource/123/' + self.assertEqual(get_breadcrumbs(url), [('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123'), + ('Nested Resource Root', '/resource/123/')]) + + def test_nested_resource_instance_breadcrumbs(self): + url = '/resource/123/abc' + self.assertEqual(get_breadcrumbs(url), [('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123'), + ('Nested Resource Root', '/resource/123/'), + ('Nested Resource Instance', '/resource/123/abc')]) + + def test_broken_url_breadcrumbs_handled_gracefully(self): + url = '/foobar' + self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) \ No newline at end of file diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index e3f2c41f59..9052f677ef 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -1,5 +1,5 @@ from django.test import TestCase -from djangorestframework.tests.utils import RequestFactory +from djangorestframework.compat import RequestFactory from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py new file mode 100644 index 0000000000..3e3f7b2104 --- /dev/null +++ b/djangorestframework/tests/description.py @@ -0,0 +1,93 @@ +from django.test import TestCase +from djangorestframework.resource import Resource +from djangorestframework.markdownwrapper import apply_markdown +from djangorestframework.description import get_name, get_description + +# We check that docstrings get nicely un-indented. +DESCRIPTION = """an example docstring +==================== + +* list +* list + +another header +-------------- + + code block + +indented + +# hash style header #""" + +# If markdown is installed we also test it's working (and that our wrapped forces '=' to h2 and '-' to h3) +MARKED_DOWN = """

an example docstring

+
    +
  • list
  • +
  • list
  • +
+

another header

+
code block
+
+

indented

+

hash style header

""" + + +class TestResourceNamesAndDescriptions(TestCase): + def test_resource_name_uses_classname_by_default(self): + """Ensure Resource names are based on the classname by default.""" + class MockResource(Resource): + pass + self.assertEquals(get_name(MockResource()), 'Mock Resource') + + def test_resource_name_can_be_set_explicitly(self): + """Ensure Resource names can be set using the 'name' class attribute.""" + example = 'Some Other Name' + class MockResource(Resource): + name = example + self.assertEquals(get_name(MockResource()), example) + + def test_resource_description_uses_docstring_by_default(self): + """Ensure Resource names are based on the docstring by default.""" + class MockResource(Resource): + """an example docstring + ==================== + + * list + * list + + another header + -------------- + + code block + + indented + + # hash style header #""" + + self.assertEquals(get_description(MockResource()), DESCRIPTION) + + def test_resource_description_can_be_set_explicitly(self): + """Ensure Resource descriptions can be set using the 'description' class attribute.""" + example = 'Some other description' + class MockResource(Resource): + """docstring""" + description = example + self.assertEquals(get_description(MockResource()), example) + + def test_resource_description_does_not_require_docstring(self): + """Ensure that empty docstrings do not affect the Resource's description if it has been set using the 'description' class attribute.""" + example = 'Some other description' + class MockResource(Resource): + description = example + self.assertEquals(get_description(MockResource()), example) + + def test_resource_description_can_be_empty(self): + """Ensure that if a resource has no doctring or 'description' class attribute, then it's description is the empty string""" + class MockResource(Resource): + pass + self.assertEquals(get_description(MockResource()), '') + + def test_markdown(self): + """Ensure markdown to HTML works as expected""" + if apply_markdown: + self.assertEquals(apply_markdown(DESCRIPTION), MARKED_DOWN) diff --git a/djangorestframework/tests/emitters.py b/djangorestframework/tests/emitters.py new file mode 100644 index 0000000000..7d024ccfc8 --- /dev/null +++ b/djangorestframework/tests/emitters.py @@ -0,0 +1,75 @@ +from django.conf.urls.defaults import patterns, url +from django import http +from django.test import TestCase +from djangorestframework.compat import View +from djangorestframework.emitters import EmitterMixin, BaseEmitter +from djangorestframework.response import Response + +DUMMYSTATUS = 200 +DUMMYCONTENT = 'dummycontent' + +EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x +EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x + +class MockView(EmitterMixin, View): + def get(self, request): + response = Response(DUMMYSTATUS, DUMMYCONTENT) + return self.emit(response) + +class EmitterA(BaseEmitter): + media_type = 'mock/emittera' + + def emit(self, output, verbose=False): + return EMITTER_A_SERIALIZER(output) + +class EmitterB(BaseEmitter): + media_type = 'mock/emitterb' + + def emit(self, output, verbose=False): + return EMITTER_B_SERIALIZER(output) + + +urlpatterns = patterns('', + url(r'^$', MockView.as_view(emitters=[EmitterA, EmitterB])), +) + + +class EmitterIntegrationTests(TestCase): + """End-to-end testing of emitters using an EmitterMixin on a generic view.""" + + urls = 'djangorestframework.tests.emitters' + + def test_default_emitter_serializes_content(self): + """If the Accept header is not set the default emitter should serialize the response.""" + resp = self.client.get('/') + self.assertEquals(resp['Content-Type'], EmitterA.media_type) + self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_default_emitter_serializes_content_on_accept_any(self): + """If the Accept header is set to */* the default emitter should serialize the response.""" + resp = self.client.get('/', HTTP_ACCEPT='*/*') + self.assertEquals(resp['Content-Type'], EmitterA.media_type) + self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_emitter_serializes_content_default_case(self): + """If the Accept header is set the specified emitter should serialize the response. + (In this case we check that works for the default emitter)""" + resp = self.client.get('/', HTTP_ACCEPT=EmitterA.media_type) + self.assertEquals(resp['Content-Type'], EmitterA.media_type) + self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_emitter_serializes_content_non_default_case(self): + """If the Accept header is set the specified emitter should serialize the response. + (In this case we check that works for a non-default emitter)""" + resp = self.client.get('/', HTTP_ACCEPT=EmitterB.media_type) + self.assertEquals(resp['Content-Type'], EmitterB.media_type) + self.assertEquals(resp.content, EMITTER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_unsatisfiable_accept_header_on_request_returns_406_status(self): + """If the Accept header is unsatisfiable we should return a 406 Not Acceptable response.""" + resp = self.client.get('/', HTTP_ACCEPT='foo/bar') + self.assertEquals(resp.status_code, 406) \ No newline at end of file diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 833457f540..64e2c12128 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,5 +1,5 @@ from django.test import TestCase -from djangorestframework.tests.utils import RequestFactory +from djangorestframework.compat import RequestFactory from djangorestframework.methods import MethodMixin, StandardMethodMixin, OverloadedPOSTMethodMixin diff --git a/djangorestframework/tests/response.py b/djangorestframework/tests/response.py index c199f30058..89cac6f9cc 100644 --- a/djangorestframework/tests/response.py +++ b/djangorestframework/tests/response.py @@ -1,25 +1,16 @@ from django.test import TestCase from djangorestframework.response import Response -try: - import unittest2 -except: - unittest2 = None -else: - import warnings - warnings.filterwarnings("ignore") -if unittest2: - class TestResponse(TestCase, unittest2.TestCase): - - # Interface tests - - # This is mainly to remind myself that the Response interface needs to change slightly - @unittest2.expectedFailure - def test_response_interface(self): - """Ensure the Response interface is as expected.""" - response = Response() - getattr(response, 'status') - getattr(response, 'content') - getattr(response, 'headers') +class TestResponse(TestCase): + + # Interface tests + + # This is mainly to remind myself that the Response interface needs to change slightly + def test_response_interface(self): + """Ensure the Response interface is as expected.""" + response = Response() + getattr(response, 'status') + getattr(response, 'content') + getattr(response, 'headers') diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py new file mode 100644 index 0000000000..a862e39a67 --- /dev/null +++ b/djangorestframework/tests/reverse.py @@ -0,0 +1,32 @@ +from django.conf.urls.defaults import patterns, url +from django.core.urlresolvers import reverse +from django.test import TestCase + +from djangorestframework.resource import Resource + +try: + import json +except ImportError: + import simplejson as json + + +class MockResource(Resource): + """Mock resource which simply returns a URL, so that we can ensure that reversed URLs are fully qualified""" + anon_allowed_methods = ('GET',) + + def get(self, request, auth): + return reverse('another') + +urlpatterns = patterns('', + url(r'^$', MockResource.as_view()), + url(r'^another$', MockResource.as_view(), name='another'), +) + + +class ReverseTests(TestCase): + """Tests for """ + urls = 'djangorestframework.tests.reverse' + + def test_reversed_urls_are_fully_qualified(self): + response = self.client.get('/') + self.assertEqual(json.loads(response.content), 'http://testserver/another') diff --git a/djangorestframework/tests/utils.py b/djangorestframework/tests/utils.py deleted file mode 100644 index ef0cb59cfe..0000000000 --- a/djangorestframework/tests/utils.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.test import Client -from django.core.handlers.wsgi import WSGIRequest - -# From: http://djangosnippets.org/snippets/963/ -# Lovely stuff -class RequestFactory(Client): - """ - Class that lets you create mock Request objects for use in testing. - - Usage: - - rf = RequestFactory() - get_request = rf.get('/hello/') - post_request = rf.post('/submit/', {'foo': 'bar'}) - - This class re-uses the django.test.client.Client interface, docs here: - http://www.djangoproject.com/documentation/testing/#the-test-client - - Once you have a request object you can pass it to any view function, - just as if that view had been hooked up using a URLconf. - - """ - def request(self, **request): - """ - Similar to parent class, but returns the request object as soon as it - has created it. - """ - environ = { - 'HTTP_COOKIE': self.cookies, - 'PATH_INFO': '/', - 'QUERY_STRING': '', - 'REQUEST_METHOD': 'GET', - 'SCRIPT_NAME': '', - 'SERVER_NAME': 'testserver', - 'SERVER_PORT': 80, - 'SERVER_PROTOCOL': 'HTTP/1.1', - } - environ.update(self.defaults) - environ.update(request) - return WSGIRequest(environ) diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index f72ea60dcf..8e6497643f 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -1,151 +1,291 @@ from django import forms +from django.db import models from django.test import TestCase -from djangorestframework.tests.utils import RequestFactory +from djangorestframework.compat import RequestFactory from djangorestframework.validators import ValidatorMixin, FormValidatorMixin, ModelFormValidatorMixin from djangorestframework.response import ResponseException -class TestValidatorMixins(TestCase): - def setUp(self): - self.req = RequestFactory() - - class MockForm(forms.Form): - qwerty = forms.CharField(required=True) - - class MockValidator(FormValidatorMixin): - form = MockForm - - class DisabledValidator(FormValidatorMixin): - form = None - - self.MockValidator = MockValidator - self.DisabledValidator = DisabledValidator - - - # Interface tests +class TestValidatorMixinInterfaces(TestCase): + """Basic tests to ensure that the ValidatorMixin classes expose the expected interfaces""" def test_validator_mixin_interface(self): - """Ensure the ContentMixin interface is as expected.""" + """Ensure the ValidatorMixin base class interface is as expected.""" self.assertRaises(NotImplementedError, ValidatorMixin().validate, None) def test_form_validator_mixin_interface(self): - """Ensure the OverloadedContentMixin interface is as expected.""" + """Ensure the FormValidatorMixin interface is as expected.""" self.assertTrue(issubclass(FormValidatorMixin, ValidatorMixin)) getattr(FormValidatorMixin, 'form') getattr(FormValidatorMixin, 'validate') def test_model_form_validator_mixin_interface(self): - """Ensure the OverloadedContentMixin interface is as expected.""" + """Ensure the ModelFormValidatorMixin interface is as expected.""" self.assertTrue(issubclass(ModelFormValidatorMixin, FormValidatorMixin)) getattr(ModelFormValidatorMixin, 'model') getattr(ModelFormValidatorMixin, 'form') + getattr(ModelFormValidatorMixin, 'fields') + getattr(ModelFormValidatorMixin, 'exclude_fields') getattr(ModelFormValidatorMixin, 'validate') - # Behavioural tests - FormValidatorMixin - - def test_validate_returns_content_unchanged_if_no_form_is_set(self): - """If the form attribute is None then validate(content) should just return the content unmodified.""" + +class TestDisabledValidations(TestCase): + """Tests on Validator Mixins with validation disabled by setting form to None""" + + def test_disabled_form_validator_returns_content_unchanged(self): + """If the form attribute is None on FormValidatorMixin then validate(content) should just return the content unmodified.""" + class DisabledFormValidator(FormValidatorMixin): + form = None + content = {'qwerty':'uiop'} - self.assertEqual(self.DisabledValidator().validate(content), content) + self.assertEqual(DisabledFormValidator().validate(content), content) + + def test_disabled_form_validator_get_bound_form_returns_none(self): + """If the form attribute is None on FormValidatorMixin then get_bound_form(content) should just return None.""" + class DisabledFormValidator(FormValidatorMixin): + form = None - def test_get_bound_form_returns_none_if_no_form_is_set(self): - """If the form attribute is None then get_bound_form(content) should just return None.""" content = {'qwerty':'uiop'} - self.assertEqual(self.DisabledValidator().get_bound_form(content), None) + self.assertEqual(DisabledFormValidator().get_bound_form(content), None) + + def test_disabled_model_form_validator_returns_content_unchanged(self): + """If the form attribute is None on FormValidatorMixin then validate(content) should just return the content unmodified.""" + class DisabledModelFormValidator(ModelFormValidatorMixin): + form = None + + content = {'qwerty':'uiop'} + self.assertEqual(DisabledModelFormValidator().validate(content), content) + + def test_disabled_model_form_validator_get_bound_form_returns_none(self): + """If the form attribute is None on FormValidatorMixin then get_bound_form(content) should just return None.""" + class DisabledModelFormValidator(ModelFormValidatorMixin): + form = None + + content = {'qwerty':'uiop'} + self.assertEqual(DisabledModelFormValidator().get_bound_form(content), None) + + +class TestNonFieldErrors(TestCase): + """Tests against form validation errors caused by non-field errors. (eg as might be caused by some custom form validation)""" + + def test_validate_failed_due_to_non_field_error_returns_appropriate_message(self): + """If validation fails with a non-field error, ensure the response a non-field error""" + class MockForm(forms.Form): + field1 = forms.CharField(required=False) + field2 = forms.CharField(required=False) + ERROR_TEXT = 'You may not supply both field1 and field2' + + def clean(self): + if 'field1' in self.cleaned_data and 'field2' in self.cleaned_data: + raise forms.ValidationError(self.ERROR_TEXT) + return self.cleaned_data #pragma: no cover + + class MockValidator(FormValidatorMixin): + form = MockForm + + content = {'field1': 'example1', 'field2': 'example2'} + try: + MockValidator().validate(content) + except ResponseException, exc: + self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) + else: + self.fail('ResourceException was not raised') #pragma: no cover + + +class TestFormValidation(TestCase): + """Tests which check basic form validation. + Also includes the same set of tests with a ModelFormValidator for which the form has been explicitly set. + (ModelFormValidatorMixin should behave as FormValidatorMixin if form is set rather than relying on the default ModelForm)""" + def setUp(self): + class MockForm(forms.Form): + qwerty = forms.CharField(required=True) - def test_validate_returns_content_unchanged_if_validates_and_does_not_need_cleanup(self): + class MockFormValidator(FormValidatorMixin): + form = MockForm + + class MockModelFormValidator(ModelFormValidatorMixin): + form = MockForm + + self.MockFormValidator = MockFormValidator + self.MockModelFormValidator = MockModelFormValidator + + + def validation_returns_content_unchanged_if_already_valid_and_clean(self, validator): """If the content is already valid and clean then validate(content) should just return the content unmodified.""" content = {'qwerty':'uiop'} - - self.assertEqual(self.MockValidator().validate(content), content) + self.assertEqual(validator.validate(content), content) - def test_form_validation_failure_raises_response_exception(self): + def validation_failure_raises_response_exception(self, validator): """If form validation fails a ResourceException 400 (Bad Request) should be raised.""" content = {} - self.assertRaises(ResponseException, self.MockValidator().validate, content) + self.assertRaises(ResponseException, validator.validate, content) - def test_validate_does_not_allow_extra_fields(self): + def validation_does_not_allow_extra_fields_by_default(self, validator): """If some (otherwise valid) content includes fields that are not in the form then validation should fail. It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up broken clients more easily (eg submitting content with a misnamed field)""" content = {'qwerty': 'uiop', 'extra': 'extra'} - self.assertRaises(ResponseException, self.MockValidator().validate, content) + self.assertRaises(ResponseException, validator.validate, content) - def test_validate_allows_extra_fields_if_explicitly_set(self): - """If we include an extra_fields paramater on _validate, then allow fields with those names.""" + def validation_allows_extra_fields_if_explicitly_set(self, validator): + """If we include an allowed_extra_fields paramater on _validate, then allow fields with those names.""" content = {'qwerty': 'uiop', 'extra': 'extra'} - self.MockValidator()._validate(content, extra_fields=('extra',)) + validator._validate(content, allowed_extra_fields=('extra',)) - def test_validate_checks_for_extra_fields_if_explicitly_set(self): - """If we include an extra_fields paramater on _validate, then fail unless we have fields with those names.""" + def validation_does_not_require_extra_fields_if_explicitly_set(self, validator): + """If we include an allowed_extra_fields paramater on _validate, then do not fail if we do not have fields with those names.""" content = {'qwerty': 'uiop'} - try: - self.MockValidator()._validate(content, extra_fields=('extra',)) - except ResponseException, exc: - self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field is required.']}}) - else: - self.fail('ResourceException was not raised') #pragma: no cover + self.assertEqual(validator._validate(content, allowed_extra_fields=('extra',)), content) - def test_validate_failed_due_to_no_content_returns_appropriate_message(self): + def validation_failed_due_to_no_content_returns_appropriate_message(self, validator): """If validation fails due to no content, ensure the response contains a single non-field error""" content = {} try: - self.MockValidator().validate(content) + validator.validate(content) except ResponseException, exc: self.assertEqual(exc.response.raw_content, {'errors': ['No content was supplied.']}) else: self.fail('ResourceException was not raised') #pragma: no cover - def test_validate_failed_due_to_field_error_returns_appropriate_message(self): + def validation_failed_due_to_field_error_returns_appropriate_message(self, validator): """If validation fails due to a field error, ensure the response contains a single field error""" content = {'qwerty': ''} try: - self.MockValidator().validate(content) + validator.validate(content) except ResponseException, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover - def test_validate_failed_due_to_invalid_field_returns_appropriate_message(self): + def validation_failed_due_to_invalid_field_returns_appropriate_message(self, validator): """If validation fails due to an invalid field, ensure the response contains a single field error""" content = {'qwerty': 'uiop', 'extra': 'extra'} try: - self.MockValidator().validate(content) + validator.validate(content) except ResponseException, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover - def test_validate_failed_due_to_multiple_errors_returns_appropriate_message(self): + def validation_failed_due_to_multiple_errors_returns_appropriate_message(self, validator): """If validation for multiple reasons, ensure the response contains each error""" content = {'qwerty': '', 'extra': 'extra'} try: - self.MockValidator().validate(content) + validator.validate(content) except ResponseException, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'], 'extra': ['This field does not exist.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover + + # Tests on FormValidtionMixin + + def test_form_validation_returns_content_unchanged_if_already_valid_and_clean(self): + self.validation_returns_content_unchanged_if_already_valid_and_clean(self.MockFormValidator()) + + def test_form_validation_failure_raises_response_exception(self): + self.validation_failure_raises_response_exception(self.MockFormValidator()) + + def test_validation_does_not_allow_extra_fields_by_default(self): + self.validation_does_not_allow_extra_fields_by_default(self.MockFormValidator()) + + def test_validation_allows_extra_fields_if_explicitly_set(self): + self.validation_allows_extra_fields_if_explicitly_set(self.MockFormValidator()) + + def test_validation_does_not_require_extra_fields_if_explicitly_set(self): + self.validation_does_not_require_extra_fields_if_explicitly_set(self.MockFormValidator()) + + def test_validation_failed_due_to_no_content_returns_appropriate_message(self): + self.validation_failed_due_to_no_content_returns_appropriate_message(self.MockFormValidator()) + + def test_validation_failed_due_to_field_error_returns_appropriate_message(self): + self.validation_failed_due_to_field_error_returns_appropriate_message(self.MockFormValidator()) + + def test_validation_failed_due_to_invalid_field_returns_appropriate_message(self): + self.validation_failed_due_to_invalid_field_returns_appropriate_message(self.MockFormValidator()) + + def test_validation_failed_due_to_multiple_errors_returns_appropriate_message(self): + self.validation_failed_due_to_multiple_errors_returns_appropriate_message(self.MockFormValidator()) + + # Same tests on ModelFormValidtionMixin + + def test_modelform_validation_returns_content_unchanged_if_already_valid_and_clean(self): + self.validation_returns_content_unchanged_if_already_valid_and_clean(self.MockModelFormValidator()) + + def test_modelform_validation_failure_raises_response_exception(self): + self.validation_failure_raises_response_exception(self.MockModelFormValidator()) + + def test_modelform_validation_does_not_allow_extra_fields_by_default(self): + self.validation_does_not_allow_extra_fields_by_default(self.MockModelFormValidator()) + + def test_modelform_validation_allows_extra_fields_if_explicitly_set(self): + self.validation_allows_extra_fields_if_explicitly_set(self.MockModelFormValidator()) + + def test_modelform_validation_does_not_require_extra_fields_if_explicitly_set(self): + self.validation_does_not_require_extra_fields_if_explicitly_set(self.MockModelFormValidator()) + + def test_modelform_validation_failed_due_to_no_content_returns_appropriate_message(self): + self.validation_failed_due_to_no_content_returns_appropriate_message(self.MockModelFormValidator()) + + def test_modelform_validation_failed_due_to_field_error_returns_appropriate_message(self): + self.validation_failed_due_to_field_error_returns_appropriate_message(self.MockModelFormValidator()) + + def test_modelform_validation_failed_due_to_invalid_field_returns_appropriate_message(self): + self.validation_failed_due_to_invalid_field_returns_appropriate_message(self.MockModelFormValidator()) + + def test_modelform_validation_failed_due_to_multiple_errors_returns_appropriate_message(self): + self.validation_failed_due_to_multiple_errors_returns_appropriate_message(self.MockModelFormValidator()) + + +class TestModelFormValidator(TestCase): + """Tests specific to ModelFormValidatorMixin""" - def test_validate_failed_due_to_non_field_error_returns_appropriate_message(self): - """If validation for with a non-field error, ensure the response a non-field error""" - class MockForm(forms.Form): - field1 = forms.CharField(required=False) - field2 = forms.CharField(required=False) - ERROR_TEXT = 'You may not supply both field1 and field2' - - def clean(self): - if 'field1' in self.cleaned_data and 'field2' in self.cleaned_data: - raise forms.ValidationError(self.ERROR_TEXT) - return self.cleaned_data #pragma: no cover - - class MockValidator(FormValidatorMixin): - form = MockForm - - content = {'field1': 'example1', 'field2': 'example2'} - try: - MockValidator().validate(content) - except ResponseException, exc: - self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) - else: - self.fail('ResourceException was not raised') #pragma: no cover \ No newline at end of file + def setUp(self): + """Create a validator for a model with two fields and a property.""" + class MockModel(models.Model): + qwerty = models.CharField(max_length=256) + uiop = models.CharField(max_length=256, blank=True) + + @property + def readonly(self): + return 'read only' + + class MockValidator(ModelFormValidatorMixin): + model = MockModel + + self.MockValidator = MockValidator + + + def test_property_fields_are_allowed_on_model_forms(self): + """Validation on ModelForms may include property fields that exist on the Model to be included in the input.""" + content = {'qwerty':'example', 'uiop': 'example', 'readonly': 'read only'} + self.assertEqual(self.MockValidator().validate(content), content) + + def test_property_fields_are_not_required_on_model_forms(self): + """Validation on ModelForms does not require property fields that exist on the Model to be included in the input.""" + content = {'qwerty':'example', 'uiop': 'example'} + self.assertEqual(self.MockValidator().validate(content), content) + + def test_extra_fields_not_allowed_on_model_forms(self): + """If some (otherwise valid) content includes fields that are not in the form then validation should fail. + It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up + broken clients more easily (eg submitting content with a misnamed field)""" + content = {'qwerty': 'example', 'uiop':'example', 'readonly': 'read only', 'extra': 'extra'} + self.assertRaises(ResponseException, self.MockValidator().validate, content) + + def test_validate_requires_fields_on_model_forms(self): + """If some (otherwise valid) content includes fields that are not in the form then validation should fail. + It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up + broken clients more easily (eg submitting content with a misnamed field)""" + content = {'readonly': 'read only'} + self.assertRaises(ResponseException, self.MockValidator().validate, content) + + def test_validate_does_not_require_blankable_fields_on_model_forms(self): + """Test standard ModelForm validation behaviour - fields with blank=True are not required.""" + content = {'qwerty':'example', 'readonly': 'read only'} + self.MockValidator().validate(content) + + def test_model_form_validator_uses_model_forms(self): + self.assertTrue(isinstance(self.MockValidator().get_bound_form(), forms.ModelForm)) + + diff --git a/djangorestframework/tests/views.py b/djangorestframework/tests/views.py new file mode 100644 index 0000000000..9e2e893fe7 --- /dev/null +++ b/djangorestframework/tests/views.py @@ -0,0 +1,43 @@ +from django.conf.urls.defaults import patterns, url +from django.test import TestCase +from django.test import Client + + +urlpatterns = patterns('djangorestframework.views', + url(r'^robots.txt$', 'deny_robots'), + url(r'^favicon.ico$', 'favicon'), + url(r'^accounts/login$', 'api_login'), + url(r'^accounts/logout$', 'api_logout'), +) + + +class ViewTests(TestCase): + """Test the extra views djangorestframework provides""" + urls = 'djangorestframework.tests.views' + + def test_robots_view(self): + """Ensure the robots view exists""" + response = self.client.get('/robots.txt') + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'text/plain') + + def test_favicon_view(self): + """Ensure the favicon view exists""" + response = self.client.get('/favicon.ico') + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'image/vnd.microsoft.icon') + + def test_login_view(self): + """Ensure the login view exists""" + response = self.client.get('/accounts/login') + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'].split(';')[0], 'text/html') + + def test_logout_view(self): + """Ensure the logout view exists""" + response = self.client.get('/accounts/logout') + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'].split(';')[0], 'text/html') + + + # TODO: Add login/logout behaviour tests diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py index bc797d60ff..266d1ca6e6 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils.py @@ -3,12 +3,18 @@ from django.utils.encoding import smart_unicode from django.utils.xmlutils import SimplerXMLGenerator from django.core.urlresolvers import resolve +from django.conf import settings try: import cStringIO as StringIO except ImportError: import StringIO +#def admin_media_prefix(request): +# """Adds the ADMIN_MEDIA_PREFIX to the request context.""" +# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} + + def as_tuple(obj): """Given obj return a tuple""" if obj is None: diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index aefad4b351..3d0a7794c9 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -22,6 +22,7 @@ class FormValidatorMixin(ValidatorMixin): """The form class that should be used for validation, or None to turn off form validation.""" form = None + bound_form_instance = None def validate(self, content): """Given some content as input return some cleaned, validated content. @@ -34,29 +35,39 @@ def validate(self, content): If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" return self._validate(content) - def _validate(self, content, extra_fields=()): + def _validate(self, content, allowed_extra_fields=()): """Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses. extra_fields is a list of fields which are not defined by the form, but which we still expect to see on the input.""" - if self.form is None: + bound_form = self.get_bound_form(content) + + if bound_form is None: return content - bound_form = self.get_bound_form(content) + self.bound_form_instance = bound_form - # In addition to regular validation we also ensure no additional fields are being passed in... - unknown_fields = set(content.keys()) - set(self.form().fields.keys()) - set(extra_fields) + seen_fields_set = set(content.keys()) + form_fields_set = set(bound_form.fields.keys()) + allowed_extra_fields_set = set(allowed_extra_fields) - # And that any extra fields we have specified are all present. - missing_extra_fields = set(extra_fields) - set(content.keys()) + # In addition to regular validation we also ensure no additional fields are being passed in... + unknown_fields = seen_fields_set - (form_fields_set | allowed_extra_fields_set) # Check using both regular validation, and our stricter no additional fields rule - if bound_form.is_valid() and not unknown_fields and not missing_extra_fields: - return bound_form.cleaned_data + if bound_form.is_valid() and not unknown_fields: + # Validation succeeded... + cleaned_data = bound_form.cleaned_data + + # Add in any extra fields to the cleaned content... + for key in (allowed_extra_fields_set & seen_fields_set) - set(cleaned_data.keys()): + cleaned_data[key] = content[key] + + return cleaned_data # Validation failed... detail = {} - if not bound_form.errors and not unknown_fields and not missing_extra_fields: + if not bound_form.errors and not unknown_fields: detail = {u'errors': [u'No content was supplied.']} else: @@ -70,10 +81,6 @@ def _validate(self, content, extra_fields=()): # Add any unknown field errors for key in unknown_fields: field_errors[key] = [u'This field does not exist.'] - - # Add any missing fields that we required by the extra fields argument - for key in missing_extra_fields: - field_errors[key] = [u'This field is required.'] if field_errors: detail[u'field-errors'] = field_errors @@ -105,8 +112,14 @@ class ModelFormValidatorMixin(FormValidatorMixin): model = None """The list of fields we expect to receive as input. Fields in this list will may be received with - raising non-existent field errors, even if they do not exist as fields on the ModelForm.""" + raising non-existent field errors, even if they do not exist as fields on the ModelForm. + + Setting the fields class attribute causes the exclude_fields class attribute to be disregarded.""" fields = None + + """The list of fields to exclude from the Model. This is only used if the fields class attribute is not set.""" + exclude_fields = ('id', 'pk') + # TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out # TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.) @@ -122,8 +135,7 @@ def validate(self, content): On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys. If the 'errors' key exists it is a list of strings of non-field errors. If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" - extra_fields = set(as_tuple(self.fields)) - set(self.get_bound_form().fields) - return self._validate(content, extra_fields) + return self._validate(content, allowed_extra_fields=self._property_fields_set) def get_bound_form(self, content=None): @@ -131,23 +143,50 @@ def get_bound_form(self, content=None): If the form class attribute has been explicitly set then use that class to create a Form, otherwise if model is set use that class to create a ModelForm, otherwise return None.""" + if self.form: # Use explict Form return super(ModelFormValidatorMixin, self).get_bound_form(content) elif self.model: # Fall back to ModelForm which we create on the fly - class ModelForm(forms.ModelForm): + class OnTheFlyModelForm(forms.ModelForm): class Meta: model = self.model - fields = tuple(set.intersection(self.model._meta.fields, self.fields)) - + #fields = tuple(self._model_fields_set) + # Instantiate the ModelForm as appropriate if content and isinstance(content, models.Model): - return ModelForm(instance=content) + return OnTheFlyModelForm(instance=content) elif content: - return ModelForm(content) - return ModelForm() + return OnTheFlyModelForm(content) + return OnTheFlyModelForm() # Both form and model not set? Okay bruv, whatevs... - return None \ No newline at end of file + return None + + + @property + def _model_fields_set(self): + """Return a set containing the names of validated fields on the model.""" + model_fields = set(field.name for field in self.model._meta.fields) + + if self.fields: + return model_fields & set(as_tuple(self.fields)) + + return model_fields - set(as_tuple(self.exclude_fields)) + + @property + def _property_fields_set(self): + """Returns a set containing the names of validated properties on the model.""" + property_fields = set(attr for attr in dir(self.model) if + isinstance(getattr(self.model, attr, None), property) + and not attr.startswith('_')) + + if self.fields: + return property_fields & set(as_tuple(self.fields)) + + return property_fields - set(as_tuple(self.exclude_fields)) + + + \ No newline at end of file diff --git a/djangorestframework/views.py b/djangorestframework/views.py new file mode 100644 index 0000000000..d50e126fc9 --- /dev/null +++ b/djangorestframework/views.py @@ -0,0 +1,66 @@ +from django.contrib.auth.views import * +#from django.contrib.sites.models import get_current_site +from django.conf import settings +from django.http import HttpResponse +import base64 + +def deny_robots(request): + return HttpResponse('User-agent: *\nDisallow: /', mimetype='text/plain') + +def favicon(request): + data = 'AAABAAEAEREAAAEAIADwBAAAFgAAACgAAAARAAAAIgAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADLy8tLy8vL3svLy1QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAy8vLBsvLywkAAAAATkZFS1xUVPqhn57/y8vL0gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJmVlQ/GxcXiy8vL88vLy4FdVlXzTkZF/2RdXP/Ly8vty8vLtMvLy5DLy8vty8vLxgAAAAAAAAAAAAAAAAAAAABORkUJTkZF4lNMS/+Lh4f/cWtq/05GRf9ORkX/Vk9O/3JtbP+Ef3//Vk9O/2ljYv/Ly8v5y8vLCQAAAAAAAAAAAAAAAE5GRQlORkX2TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/UElI/8PDw5cAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRZZORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf+Cfn3/y8vLvQAAAAAAAAAAAAAAAAAAAADLy8tIaWNi805GRf9ORkX/YVpZ/396eV7Ly8t7qaen9lZOTu5ORkX/TkZF/25oZ//Ly8v/y8vLycvLy0gAAAAATkZFSGNcXPpORkX/TkZF/05GRf+ysLDzTkZFe1NLSv6Oior/raur805GRf9ORkX/TkZF/2hiYf+npaX/y8vL5wAAAABORkXnTkZF/05GRf9ORkX/VU1M/8vLy/9PR0b1TkZF/1VNTP/Ly8uQT0dG+E5GRf9ORkX/TkZF/1hRUP3Ly8tmAAAAAE5GRWBORkXkTkZF/05GRf9ORkX/t7a2/355eOpORkX/TkZFkISAf1BORkX/TkZF/05GRf9XT075TkZFZgAAAAAAAAAAAAAAAAAAAABORkXDTkZF/05GRf9lX17/ubi4/8vLy/+2tbT/Yltb/05GRf9ORkX/a2Vk/8vLy5MAAAAAAAAAAAAAAAAAAAAAAAAAAFNLSqNORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf+Cfn3/y8vL+cvLyw8AAAAAAAAAAAAAAABORkUSTkZF+U5GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/1BJSP/CwsLmy8vLDwAAAAAAAAAAAAAAAE5GRRJORkXtTkZF9FFJSJ1ORkXJTkZF/05GRf9ORkX/ZF5d9k5GRZ9ORkXtTkZF5HFsaxUAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRQxORkUJAAAAAAAAAABORkXhTkZF/2JbWv7Ly8tgAAAAAAAAAABORkUGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRWBORkX2TkZFYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//+AAP9/gAD+P4AA4AOAAMADgADAA4AAwAOAAMMBgACCAIAAAAGAAIBDgADAA4AAwAOAAMADgADAB4AA/H+AAP7/gAA=' + return HttpResponse(base64.b64decode(data), mimetype='image/vnd.microsoft.icon') + +# BLERGH +# Replicate django.contrib.auth.views.login simply so we don't have get users to update TEMPLATE_CONTEXT_PROCESSORS +# to add ADMIN_MEDIA_PREFIX to the RequestContext. I don't like this but really really want users to not have to +# be making settings changes in order to accomodate django-rest-framework +@csrf_protect +@never_cache +def api_login(request, template_name='api_login.html', + redirect_field_name=REDIRECT_FIELD_NAME, + authentication_form=AuthenticationForm): + """Displays the login form and handles the login action.""" + + redirect_to = request.REQUEST.get(redirect_field_name, '') + + if request.method == "POST": + form = authentication_form(data=request.POST) + if form.is_valid(): + # Light security check -- make sure redirect_to isn't garbage. + if not redirect_to or ' ' in redirect_to: + redirect_to = settings.LOGIN_REDIRECT_URL + + # Heavier security check -- redirects to http://example.com should + # not be allowed, but things like /view/?param=http://example.com + # should be allowed. This regex checks if there is a '//' *before* a + # question mark. + elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to): + redirect_to = settings.LOGIN_REDIRECT_URL + + # Okay, security checks complete. Log the user in. + auth_login(request, form.get_user()) + + if request.session.test_cookie_worked(): + request.session.delete_test_cookie() + + return HttpResponseRedirect(redirect_to) + + else: + form = authentication_form(request) + + request.session.set_test_cookie() + + #current_site = get_current_site(request) + + return render_to_response(template_name, { + 'form': form, + redirect_field_name: redirect_to, + #'site': current_site, + #'site_name': current_site.name, + 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX, + }, context_instance=RequestContext(request)) + + +def api_logout(request, next_page=None, template_name='api_login.html', redirect_field_name=REDIRECT_FIELD_NAME): + return logout(request, next_page, template_name, redirect_field_name) diff --git a/examples/blogpost/models.py b/examples/blogpost/models.py index e19684157b..c85ca78821 100644 --- a/examples/blogpost/models.py +++ b/examples/blogpost/models.py @@ -24,13 +24,13 @@ class Meta: @models.permalink def get_absolute_url(self): - return ('blogpost.views.BlogPostInstance', (), {'key': self.key}) + return ('blog-post', (), {'key': self.key}) @property @models.permalink def comments_url(self): """Link to a resource which lists all comments for this blog post.""" - return ('blogpost.views.CommentRoot', (), {'blogpost_id': self.key}) + return ('comments', (), {'blogpost_id': self.key}) def __unicode__(self): return self.title @@ -52,11 +52,11 @@ class Meta: @models.permalink def get_absolute_url(self): - return ('blogpost.views.CommentInstance', (), {'blogpost': self.blogpost.key, 'id': self.id}) + return ('comment', (), {'blogpost': self.blogpost.key, 'id': self.id}) @property @models.permalink def blogpost_url(self): """Link to the blog post resource which this comment corresponds to.""" - return ('blogpost.views.BlogPostInstance', (), {'key': self.blogpost.key}) + return ('blog-post', (), {'key': self.blogpost.key}) diff --git a/examples/blogpost/urls.py b/examples/blogpost/urls.py index ee209b3e6a..b2df96a174 100644 --- a/examples/blogpost/urls.py +++ b/examples/blogpost/urls.py @@ -1,8 +1,9 @@ -from django.conf.urls.defaults import patterns +from django.conf.urls.defaults import patterns, url +from blogpost.views import BlogPosts, BlogPostInstance, Comments, CommentInstance -urlpatterns = patterns('blogpost.views', - (r'^$', 'BlogPostRoot'), - (r'^(?P[^/]+)/$', 'BlogPostInstance'), - (r'^(?P[^/]+)/comments/$', 'CommentRoot'), - (r'^(?P[^/]+)/comments/(?P[^/]+)/$', 'CommentInstance'), +urlpatterns = patterns('', + url(r'^$', BlogPosts.as_view(), name='blog-posts'), + url(r'^(?P[^/]+)/$', BlogPostInstance.as_view(), name='blog-post'), + url(r'^(?P[^/]+)/comments/$', Comments.as_view(), name='comments'), + url(r'^(?P[^/]+)/comments/(?P[^/]+)/$', CommentInstance.as_view(), name='comment'), ) diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py index bfb53b5d5c..0377b4474d 100644 --- a/examples/blogpost/views.py +++ b/examples/blogpost/views.py @@ -1,33 +1,33 @@ from djangorestframework.response import Response, status from djangorestframework.resource import Resource from djangorestframework.modelresource import ModelResource, RootModelResource -from blogpost.models import BlogPost, Comment +from blogpost import models BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url') COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url') -class BlogPostRoot(RootModelResource): +class BlogPosts(RootModelResource): """A resource with which lists all existing blog posts and creates new blog posts.""" - allowed_methods = ('GET', 'POST',) - model = BlogPost + anon_allowed_methods = allowed_methods = ('GET', 'POST',) + model = models.BlogPost fields = BLOG_POST_FIELDS class BlogPostInstance(ModelResource): """A resource which represents a single blog post.""" - allowed_methods = ('GET', 'PUT', 'DELETE') - model = BlogPost + anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE') + model = models.BlogPost fields = BLOG_POST_FIELDS -class CommentRoot(RootModelResource): +class Comments(RootModelResource): """A resource which lists all existing comments for a given blog post, and creates new blog comments for a given blog post.""" - allowed_methods = ('GET', 'POST',) - model = Comment + anon_allowed_methods = allowed_methods = ('GET', 'POST',) + model = models.Comment fields = COMMENT_FIELDS class CommentInstance(ModelResource): """A resource which represents a single comment.""" - allowed_methods = ('GET', 'PUT', 'DELETE') - model = Comment + anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE') + model = models.Comment fields = COMMENT_FIELDS diff --git a/examples/mixin/__init__.py b/examples/mixin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/mixin/urls.py b/examples/mixin/urls.py new file mode 100644 index 0000000000..0500928450 --- /dev/null +++ b/examples/mixin/urls.py @@ -0,0 +1,23 @@ +from djangorestframework.compat import View # Use Django 1.3's django.views.generic.View, or fall back to a clone of that if Django < 1.3 +from djangorestframework.emitters import EmitterMixin, DEFAULT_EMITTERS +from djangorestframework.response import Response + +from django.conf.urls.defaults import patterns, url +from django.core.urlresolvers import reverse + + +class ExampleView(EmitterMixin, View): + """An example view using Django 1.3's class based views. + Uses djangorestframework's EmitterMixin to provide support for multiple output formats.""" + emitters = DEFAULT_EMITTERS + + def get(self, request): + response = Response(200, {'description': 'Some example content', + 'url': reverse('mixin-view')}) + return self.emit(response) + + +urlpatterns = patterns('', + url(r'^$', ExampleView.as_view(), name='mixin-view'), +) + diff --git a/examples/modelresourceexample/models.py b/examples/modelresourceexample/models.py index 036501d0df..1604752431 100644 --- a/examples/modelresourceexample/models.py +++ b/examples/modelresourceexample/models.py @@ -19,5 +19,5 @@ def save(self, *args, **kwargs): @models.permalink def get_absolute_url(self): - return ('modelresourceexample.views.MyModelResource', (self.pk,)) + return ('my-model-resource', (self.pk,)) diff --git a/examples/modelresourceexample/urls.py b/examples/modelresourceexample/urls.py index c43cf56a84..53d950cd69 100644 --- a/examples/modelresourceexample/urls.py +++ b/examples/modelresourceexample/urls.py @@ -1,6 +1,7 @@ from django.conf.urls.defaults import patterns, url +from modelresourceexample.views import MyModelRootResource, MyModelResource urlpatterns = patterns('modelresourceexample.views', - url(r'^$', 'MyModelRootResource'), - url(r'^([0-9]+)/$', 'MyModelResource'), + url(r'^$', MyModelRootResource.as_view(), name='my-model-root-resource'), + url(r'^([0-9]+)/$', MyModelResource.as_view(), name='my-model-resource'), ) diff --git a/examples/objectstore/urls.py b/examples/objectstore/urls.py index c04e731e4c..2c685f5920 100644 --- a/examples/objectstore/urls.py +++ b/examples/objectstore/urls.py @@ -1,6 +1,7 @@ -from django.conf.urls.defaults import patterns - +from django.conf.urls.defaults import patterns, url +from objectstore.views import ObjectStoreRoot, StoredObject + urlpatterns = patterns('objectstore.views', - (r'^$', 'ObjectStoreRoot'), - (r'^(?P[A-Za-z0-9_-]{1,64})/$', 'StoredObject'), + url(r'^$', ObjectStoreRoot.as_view(), name='object-store-root'), + url(r'^(?P[A-Za-z0-9_-]{1,64})/$', StoredObject.as_view(), name='stored-object'), ) diff --git a/examples/objectstore/views.py b/examples/objectstore/views.py index e1b239dc92..b3ed5533ae 100644 --- a/examples/objectstore/views.py +++ b/examples/objectstore/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.urlresolvers import reverse from djangorestframework.resource import Resource from djangorestframework.response import Response, status @@ -29,7 +30,7 @@ class ObjectStoreRoot(Resource): def get(self, request, auth): """Return a list of all the stored object URLs.""" keys = sorted(os.listdir(OBJECT_STORE_DIR)) - return [self.reverse(StoredObject, key=key) for key in keys] + return [reverse('stored-object', kwargs={'key':key}) for key in keys] def post(self, request, auth, content): """Create a new stored object, with a unique key.""" @@ -37,9 +38,9 @@ def post(self, request, auth, content): pathname = os.path.join(OBJECT_STORE_DIR, key) pickle.dump(content, open(pathname, 'wb')) remove_oldest_files(OBJECT_STORE_DIR, MAX_FILES) - return Response(status.HTTP_201_CREATED, content, {'Location': self.reverse(StoredObject, key=key)}) - - + return Response(status.HTTP_201_CREATED, content, {'Location': reverse('stored-object', kwargs={'key':key})}) + + class StoredObject(Resource): """Represents a stored object. The object may be any picklable content.""" diff --git a/examples/pygments_api/urls.py b/examples/pygments_api/urls.py index f96f4518c4..905e31c5f7 100644 --- a/examples/pygments_api/urls.py +++ b/examples/pygments_api/urls.py @@ -1,6 +1,7 @@ -from django.conf.urls.defaults import patterns +from django.conf.urls.defaults import patterns, url +from pygments_api.views import PygmentsRoot, PygmentsInstance -urlpatterns = patterns('pygments_api.views', - (r'^$', 'PygmentsRoot'), - (r'^([a-zA-Z0-9-]+)/$', 'PygmentsInstance'), +urlpatterns = patterns('', + url(r'^$', PygmentsRoot.as_view(), name='pygments-root'), + url(r'^([a-zA-Z0-9-]+)/$', PygmentsInstance.as_view(), name='pygments-instance'), ) diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index e22705d968..84e5e7038c 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -1,4 +1,6 @@ +from __future__ import with_statement # for python 2.5 from django.conf import settings +from django.core.urlresolvers import reverse from djangorestframework.resource import Resource from djangorestframework.response import Response, status @@ -41,7 +43,7 @@ class PygmentsRoot(Resource): def get(self, request, auth): """Return a list of all currently existing snippets.""" unique_ids = sorted(os.listdir(HIGHLIGHTED_CODE_DIR)) - return [self.reverse(PygmentsInstance, unique_id) for unique_id in unique_ids] + return [reverse('pygments-instance', args=[unique_id]) for unique_id in unique_ids] def post(self, request, auth, content): """Create a new highlighed snippet and return it's location. @@ -59,7 +61,7 @@ def post(self, request, auth, content): remove_oldest_files(HIGHLIGHTED_CODE_DIR, MAX_FILES) - return Response(status.HTTP_201_CREATED, headers={'Location': self.reverse(PygmentsInstance, unique_id)}) + return Response(status.HTTP_201_CREATED, headers={'Location': reverse('pygments-instance', args=[unique_id])}) class PygmentsInstance(Resource): diff --git a/examples/requirements.txt b/examples/requirements.txt index 4ae9e3c7bd..09cda94514 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -4,3 +4,5 @@ Django==1.2.4 wsgiref==0.1.2 Pygments==1.4 httplib2==0.6.0 +Markdown==2.0.3 + diff --git a/examples/resourceexample/urls.py b/examples/resourceexample/urls.py index 828caef20a..cb6435bb7e 100644 --- a/examples/resourceexample/urls.py +++ b/examples/resourceexample/urls.py @@ -1,6 +1,7 @@ from django.conf.urls.defaults import patterns, url +from resourceexample.views import ExampleResource, AnotherExampleResource -urlpatterns = patterns('resourceexample.views', - url(r'^$', 'ExampleResource'), - url(r'^(?P[0-9]+)/$', 'AnotherExampleResource'), +urlpatterns = patterns('', + url(r'^$', ExampleResource.as_view(), name='example-resource'), + url(r'^(?P[0-9]+)/$', AnotherExampleResource.as_view(), name='another-example-resource'), ) diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index e5bb5efab4..c4462ee2e5 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -1,13 +1,14 @@ +from django.core.urlresolvers import reverse from djangorestframework.resource import Resource from djangorestframework.response import Response, status from resourceexample.forms import MyForm class ExampleResource(Resource): - """A basic read only resource that points to 3 other resources.""" + """A basic read-only resource that points to 3 other resources.""" allowed_methods = anon_allowed_methods = ('GET',) def get(self, request, auth): - return {"Some other resources": [self.reverse(AnotherExampleResource, num=num) for num in range(3)]} + return {"Some other resources": [reverse('another-example-resource', kwargs={'num':num}) for num in range(3)]} class AnotherExampleResource(Resource): """A basic GET-able/POST-able resource.""" diff --git a/examples/sandbox/__init__.py b/examples/sandbox/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py new file mode 100644 index 0000000000..561bdb1d74 --- /dev/null +++ b/examples/sandbox/views.py @@ -0,0 +1,35 @@ +"""The root view for the examples provided with Django REST framework""" + +from django.core.urlresolvers import reverse +from djangorestframework.resource import Resource + + +class Sandbox(Resource): + """This is the sandbox for the examples provided with [Django REST framework](http://django-rest-framework.org). + + These examples are provided to help you get a better idea of the some of the features of RESTful APIs created using the framework. + + All the example APIs allow anonymous access, and can be navigated either through the browser or from the command line... + + bash: curl -X GET http://api.django-rest-framework.org/ # (Use default emitter) + bash: curl -X GET http://api.django-rest-framework.org/ -H 'Accept: text/plain' # (Use plaintext documentation emitter) + + The examples provided: + + 1. A basic example using the [Resource](http://django-rest-framework.org/library/resource.html) class. + 2. A basic example using the [ModelResource](http://django-rest-framework.org/library/modelresource.html) class. + 3. An basic example using Django 1.3's [class based views](http://docs.djangoproject.com/en/dev/topics/class-based-views/) and djangorestframework's [EmitterMixin](http://django-rest-framework.org/library/emitters.html). + 4. A generic object store API. + 5. A code highlighting API. + 6. A blog posts and comments API. + + Please feel free to browse, create, edit and delete the resources in these examples.""" + allowed_methods = anon_allowed_methods = ('GET',) + + def get(self, request, auth): + return [{'name': 'Simple Resource example', 'url': reverse('example-resource')}, + {'name': 'Simple ModelResource example', 'url': reverse('my-model-root-resource')}, + {'name': 'Simple Mixin-only example', 'url': reverse('mixin-view')}, + {'name': 'Object store API', 'url': reverse('object-store-root')}, + {'name': 'Code highlighting API', 'url': reverse('pygments-root')}, + {'name': 'Blog posts API', 'url': reverse('blog-posts')}] diff --git a/examples/settings.py b/examples/settings.py index 865dc3942d..c4cdd992f4 100644 --- a/examples/settings.py +++ b/examples/settings.py @@ -1,7 +1,4 @@ -# Django settings for src project. -import os - -BASE_DIR = os.path.dirname(__file__) +# Settings for djangorestframework examples project DEBUG = True TEMPLATE_DEBUG = DEBUG @@ -48,17 +45,23 @@ # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/home/media/media.lawrence.com/" +# NOTE: Some of the djangorestframework examples use MEDIA_ROOT to store content. MEDIA_ROOT = 'media/' # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). # Examples: "http://media.lawrence.com", "http://example.com/media/" +# NOTE: None of the djangorestframework examples serve media content via MEDIA_URL. MEDIA_URL = '' # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a # trailing slash. # Examples: "http://foo.com/media/", "/media/". -ADMIN_MEDIA_PREFIX = '/media/' +# NOTE: djangorestframework does not require the admin app to be installed, +# but it does require the admin media be served. Django's test server will do +# this for you automatically, but in production you'll want to make sure you +# serve the admin media from somewhere. +ADMIN_MEDIA_PREFIX = '/admin-media/' # Make this unique, and don't share it with anybody. SECRET_KEY = 't&9mru2_k$t8e2-9uq-wu2a1)9v*us&j3i#lsqkt(lbx*vh1cu' @@ -84,16 +87,16 @@ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". # Always use forward slashes, even on Windows. # Don't forget to use absolute paths, not relative paths. - os.path.join(BASE_DIR, 'templates') ) + INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', - #'django.contrib.admin', + 'djangorestframework', 'resourceexample', diff --git a/examples/templates/base.html b/examples/templates/base.html deleted file mode 100644 index 1ff37dabaa..0000000000 --- a/examples/templates/base.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - -{% block content %}{% endblock %} - - \ No newline at end of file diff --git a/examples/templates/registration/login.html b/examples/templates/registration/login.html deleted file mode 100644 index 9d0b481b55..0000000000 --- a/examples/templates/registration/login.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - -{% if form.errors %} -

Your username and password didn't match. Please try again.

-{% endif %} - -
-{% csrf_token %} - - - - - - - - - -
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
- - - -
- -{% endblock %} \ No newline at end of file diff --git a/examples/urls.py b/examples/urls.py index 41b80d589c..03894e4e02 100644 --- a/examples/urls.py +++ b/examples/urls.py @@ -1,37 +1,19 @@ -from django.conf.urls.defaults import patterns, include -#from django.contrib import admin -from djangorestframework.resource import Resource +from django.conf.urls.defaults import patterns, include, url +from sandbox.views import Sandbox -#admin.autodiscover() +urlpatterns = patterns('djangorestframework.views', + (r'robots.txt', 'deny_robots'), + (r'favicon.ico', 'favicon'), -class RootResource(Resource): - """This is the sandbox for the examples provided with django-rest-framework. + (r'^$', Sandbox.as_view()), - These examples are here to help you get a better idea of the some of the - features of django-rest-framework API, such as automatic form and model validation, - support for multiple input and output media types, etc... - - Please feel free to browse, create, edit and delete the resources here, either - in the browser, from the command line, or programmatically.""" - allowed_methods = anon_allowed_methods = ('GET',) - - def get(self, request, auth): - return {'Simple Resource example': self.reverse('resourceexample.views.ExampleResource'), - 'Simple ModelResource example': self.reverse('modelresourceexample.views.MyModelRootResource'), - 'Object store API (Resource)': self.reverse('objectstore.views.ObjectStoreRoot'), - 'A pygments pastebin API (Resource + forms)': self.reverse('pygments_api.views.PygmentsRoot'), - 'Blog posts API (ModelResource)': self.reverse('blogpost.views.BlogPostRoot'),} - - -urlpatterns = patterns('', - (r'^$', RootResource), - (r'^model-resource-example/', include('modelresourceexample.urls')), (r'^resource-example/', include('resourceexample.urls')), + (r'^model-resource-example/', include('modelresourceexample.urls')), + (r'^mixin/', include('mixin.urls')), (r'^object-store/', include('objectstore.urls')), (r'^pygments/', include('pygments_api.urls')), (r'^blog-post/', include('blogpost.urls')), - (r'^accounts/login/$', 'django.contrib.auth.views.login'), - (r'^accounts/logout/$', 'django.contrib.auth.views.logout'), - #(r'^admin/doc/', include('django.contrib.admindocs.urls')), - #(r'^admin/', include(admin.site.urls)), + + (r'^accounts/login/$', 'api_login'), + (r'^accounts/logout/$', 'api_logout'), ) diff --git a/requirements.txt b/requirements.txt index 84f0c4eb27..4c2b903757 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -# Django and pip are required if installing into a virtualenv environment... +# We need Django. Duh. Django==1.2.4 -distribute==0.6.14 wsgiref==0.1.2 + diff --git a/testproject/__init__.py b/testproject/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testproject/manage.py b/testproject/manage.py new file mode 100755 index 0000000000..5e78ea979e --- /dev/null +++ b/testproject/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +try: + import settings # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) + sys.exit(1) + +if __name__ == "__main__": + execute_manager(settings) diff --git a/testproject/settings.py b/testproject/settings.py new file mode 100644 index 0000000000..2bf955aad0 --- /dev/null +++ b/testproject/settings.py @@ -0,0 +1,97 @@ +# Django settings for testproject project. + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + # ('Your Name', 'your_email@domain.com'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'sqlite.db', # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = 'Europe/London' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-uk' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/" +MEDIA_ROOT = '' + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash if there is a path component (optional in other cases). +# Examples: "http://media.lawrence.com", "http://example.com/media/" +MEDIA_URL = '' + +# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a +# trailing slash. +# Examples: "http://foo.com/media/", "/media/". +ADMIN_MEDIA_PREFIX = '/media/' + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'urls' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + # Uncomment the next line to enable the admin: + # 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + 'djangorestframework', +) diff --git a/testproject/urls.py b/testproject/urls.py new file mode 100644 index 0000000000..d131278996 --- /dev/null +++ b/testproject/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls.defaults import * + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Example: + # (r'^testproject/', include('testproject.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # (r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # (r'^admin/', include(admin.site.urls)), +)