Ticket #5791: 5791.2.diff

File 5791.2.diff, 12.8 KB (added by Ivan Sagalaev, 16 years ago)

Patch updated to current trunk + docs

  • django/views/decorators/http.py

     
    77except ImportError:
    88    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
    99
     10from calendar import timegm
     11from datetime import timedelta
     12from email.Utils import formatdate
    1013from django.utils.decorators import decorator_from_middleware
    1114from django.middleware.http import ConditionalGetMiddleware
    12 from django.http import HttpResponseNotAllowed
     15from django.http import HttpResponseNotAllowed, HttpResponseNotModified
    1316
    1417conditional_page = decorator_from_middleware(ConditionalGetMiddleware)
    1518
     
    3639require_GET.__doc__ = "Decorator to require that a view only accept the GET method."
    3740
    3841require_POST = require_http_methods(["POST"])
    39 require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
    40  No newline at end of file
     42require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
     43
     44def condition(etag=None, last_modified=None):
     45    """
     46    Decorator to support conditional get for a view.  It takes as parameters
     47    user-defined functions that calculate etag and/or last modified time.
     48   
     49    Both functions are passed the same parameters as the view itself. "last_modified"
     50    should return a standard datetime value and "etag" should return a string.
     51   
     52    Example usage with last_modified::
     53
     54        @condition(last_modified=lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
     55        def my_object_view(request, obj_id):
     56            # ...
     57
     58    You can pass last_modified, etag or both of them (if you really need it).
     59    """
     60    def decorator(func):
     61        def inner(request, *args, **kwargs):
     62            if request.method not in ('GET', 'HEAD'):
     63                return func(request, *args, **kwargs)
     64
     65            # Get HTTP request headers
     66            if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', None)
     67            if_none_match = request.META.get('HTTP_IF_NONE_MATCH', None)
     68            if if_none_match:
     69                if_none_match = [e.strip() for e in if_none_match.split(',')]
     70
     71            # Get and convert user-defined values
     72            if last_modified is not None:
     73                dt = last_modified(request, *args, **kwargs)
     74                last_modified_value = dt and (formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT')
     75            else:
     76                last_modified_value = None
     77               
     78            if etag is not None:
     79                etag_value = etag(request, *args, **kwargs)
     80            else:
     81                etag_value = None
     82
     83            # Calculate "not modified" condition
     84            not_modified = (if_modified_since or if_none_match) and \
     85                           (not if_modified_since or last_modified_value == if_modified_since) and \
     86                           (not if_none_match or etag_value in if_none_match)
     87
     88            # Create appropriate response
     89            if not_modified:
     90                response = HttpResponseNotModified()
     91            else:
     92                response = func(request, *args, **kwargs)
     93
     94            # Set relevant headers for response
     95            if last_modified_value and not response.has_header('Last-Modified'):
     96                response['Last-Modified'] = last_modified_value
     97            if etag_value and not response.has_header('ETag'):
     98                response['ETag'] = etag_value
     99
     100            return response
     101        return inner
     102    return decorator
     103
     104# Shortcut decorators for common cases based on ETag or Last-Modified only
     105def etag(callable):
     106    return condition(etag=callable)
     107
     108def last_modified(callable):
     109    return condition(last_modified=callable)
     110 No newline at end of file
  • tests/regressiontests/conditional_get/views.py

     
     1# -*- coding:utf-8 -*-
     2from django.views.decorators.http import condition
     3from django.http import HttpResponse
     4
     5from models import FULL_RESPONSE, LAST_MODIFIED, ETAG
     6
     7@condition(lambda r: ETAG, lambda r: LAST_MODIFIED)
     8def index(request):
     9    return HttpResponse(FULL_RESPONSE)
     10
     11@condition(last_modified=lambda r: LAST_MODIFIED)
     12def last_modified(request):
     13    return HttpResponse(FULL_RESPONSE)
     14
     15@condition(etag=lambda r: ETAG)
     16def etag(request):
     17    return HttpResponse(FULL_RESPONSE)
     18 No newline at end of file
  • tests/regressiontests/conditional_get/__init__.py

     
     1# -*- coding:utf-8 -*-
  • tests/regressiontests/conditional_get/models.py

     
     1# -*- coding:utf-8 -*-
     2from datetime import datetime, timedelta
     3from calendar import timegm
     4from django.test import TestCase
     5
     6FULL_RESPONSE = 'Test conditional get response'
     7LAST_MODIFIED = datetime(2007, 10, 21, 23, 21, 47)
     8LAST_MODIFIED_STR = 'Sun, 21 Oct 2007 23:21:47 GMT'
     9EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
     10ETAG = '"b4246ffc4f62314ca13147c9d4f76974"'
     11EXPIRED_ETAG = '"7fae4cd4b0f81e7d2914700043aa8ed6"'
     12
     13class ConditionalGet(TestCase):
     14    def assertFullResponse(self, response, check_last_modified=True, check_etag=True):
     15        self.assertEquals(response.status_code, 200)
     16        self.assertEquals(response.content, FULL_RESPONSE)
     17        if check_last_modified:
     18            self.assertEquals(response['Last-Modified'], LAST_MODIFIED_STR)
     19        if check_etag:
     20            self.assertEquals(response['ETag'], ETAG)
     21
     22    def assertNotModified(self, response):
     23        self.assertEquals(response.status_code, 304)
     24        self.assertEquals(response.content, '')
     25
     26    def testWithoutConditions(self):
     27        response = self.client.get('/condition/')
     28        self.assertFullResponse(response)
     29
     30    def testIfModifiedSince(self):
     31        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     32        response = self.client.get('/condition/')
     33        self.assertNotModified(response)
     34        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     35        response = self.client.get('/condition/')
     36        self.assertFullResponse(response)
     37
     38    def testIfNoneMatch(self):
     39        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
     40        response = self.client.get('/condition/')
     41        self.assertNotModified(response)
     42        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
     43        response = self.client.get('/condition/')
     44        self.assertFullResponse(response)
     45       
     46        # Several etags in If-None-Match is a bit exotic but why not?
     47        self.client.defaults['HTTP_IF_NONE_MATCH'] = ', '.join([ETAG, EXPIRED_ETAG])
     48        response = self.client.get('/condition/')
     49        self.assertNotModified(response)
     50   
     51    def testBothHeaders(self):
     52        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     53        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
     54        response = self.client.get('/condition/')
     55        self.assertNotModified(response)
     56        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     57        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
     58        response = self.client.get('/condition/')
     59        self.assertFullResponse(response)
     60        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     61        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
     62        response = self.client.get('/condition/')
     63        self.assertFullResponse(response)
     64
     65    def testSingleConditions(self):
     66        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     67        response = self.client.get('/condition/last_modified/')
     68        self.assertNotModified(response)
     69        response = self.client.get('/condition/etag/')
     70        self.assertFullResponse(response, check_last_modified=False)
     71       
     72        del self.client.defaults['HTTP_IF_MODIFIED_SINCE']
     73        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
     74        response = self.client.get('/condition/etag/')
     75        self.assertNotModified(response)
     76        response = self.client.get('/condition/last_modified/')
     77        self.assertFullResponse(response, check_etag=False)
     78       
     79        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     80        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
     81        response = self.client.get('/condition/last_modified/')
     82        self.assertFullResponse(response, check_etag=False)
     83        response = self.client.get('/condition/etag/')
     84        self.assertFullResponse(response, check_last_modified=False)
     85 No newline at end of file
  • tests/regressiontests/conditional_get/urls.py

     
     1from django.conf.urls.defaults import *
     2import views
     3
     4urlpatterns = patterns('',
     5    ('^$', views.index),
     6    ('^last_modified/$', views.last_modified),
     7    ('^etag/$', views.etag),
     8)
  • tests/urls.py

     
    2525
    2626    # test urlconf for syndication tests
    2727    (r'^syndication/', include('regressiontests.syndication.urls')),
     28
     29    # conditional get views
     30    (r'condition/', include('regressiontests.conditional_get.urls')),
    2831)
  • docs/conditional_get.txt

     
     1========================
     2Per-view conditional get
     3========================
     4
     5`Conditional get`_ is a feature of HTTP that allows you to omit sending a response
     6if the client signals in a request that it has a cached copy of reponse. To control
     7whether the cached copy is expired the client may use time of its last modification
     8or an "entity tag" - a short value that changes whenever the response itself changes
     9(typically it's simply a hash over the response).
     10
     11.. _`Conditional get`: http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
     12
     13Django allows simple usage of this feature with ``ConditionalGetMiddleware`` and
     14``CommonMiddleware`` (see `middleware documentation`_ for their usage). But, while
     15simple, they both have limitations:
     16
     17* they are applied gloablly to all views in your project
     18* they don't spare you from generating the response itself, which may be expensive
     19
     20.. _`middleware documentation`: ../middleware/
     21
     22Decorators
     23==========
     24
     25When you need more fine-grained control you may use per-view conditional get
     26decorators.
     27
     28Decorators ``etag`` and ``last_modified`` accept a user-defined function that
     29takes the same parameters as the view itself. ``last_modified`` should return a
     30standard datetime value and ``etag`` should return a string.
     31
     32Example usage of ``last_modified``::
     33
     34    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
     35    def my_object_view(request, obj_id):
     36        # Expensive generation of response with MyObject instance
     37
     38In this example we're taking advantage of the fact that MyObject instance stores
     39the time when it was last updated in a field.
     40
     41Usage of ``etag`` is similar.
     42
     43HTTP allows to use both "ETag" and "Last-Modified" headers in your response. Then
     44a response is considered not modified only if the client sends both headers back and
     45they're both equal to the response headers. This means that you can't just chain
     46decorators on your view:
     47
     48    @etag(etag_func)
     49    @last_modified(last_modified_func)
     50    def my_view(request):
     51        # ...
     52
     53Because the first decorator doesn't know anything about the second and can answer
     54that the response is not modified even if the second decorators would think otherwise.
     55In this case you should use a more general decorator - ``condition`` that accepts
     56two functions at once:
     57
     58    @condition(etag_func, last_modified_func)
     59    def my_view(request):
     60        # ...
  • docs/index.txt

     
    3838   testing
    3939   sessions
    4040   cache
     41   conditional_get
    4142   settings
    4243   url_dispatch
    4344   request_response
Back to Top