=== modified file 'django/views/decorators/http.py'
--- django/views/decorators/http.py	2008-02-25 06:02:35 +0000
+++ django/views/decorators/http.py	2008-11-16 21:14:28 +0000
@@ -7,9 +7,12 @@
 except ImportError:
     from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
 
+from calendar import timegm
+from datetime import timedelta
+from email.Utils import formatdate
 from django.utils.decorators import decorator_from_middleware
 from django.middleware.http import ConditionalGetMiddleware
-from django.http import HttpResponseNotAllowed
+from django.http import HttpResponseNotAllowed, HttpResponseNotModified
 
 conditional_page = decorator_from_middleware(ConditionalGetMiddleware)
 
@@ -36,4 +39,71 @@
 require_GET.__doc__ = "Decorator to require that a view only accept the GET method."
 
 require_POST = require_http_methods(["POST"])
-require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
\ No newline at end of file
+require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
+
+def condition(etag=None, last_modified=None):
+    """
+    Decorator to support conditional get for a view.  It takes as parameters
+    user-defined functions that calculate etag and/or last modified time.
+    
+    Both functions are passed the same parameters as the view itself. "last_modified"
+    should return a standard datetime value and "etag" should return a string.
+    
+    Example usage with last_modified::
+
+        @condition(last_modified=lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
+        def my_object_view(request, obj_id):
+            # ...
+
+    You can pass last_modified, etag or both of them (if you really need it).
+    """
+    def decorator(func):
+        def inner(request, *args, **kwargs):
+            if request.method not in ('GET', 'HEAD'):
+                return func(request, *args, **kwargs)
+
+            # Get HTTP request headers
+            if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', None)
+            if_none_match = request.META.get('HTTP_IF_NONE_MATCH', None)
+            if if_none_match:
+                if_none_match = [e.strip() for e in if_none_match.split(',')]
+
+            # Get and convert user-defined values
+            if last_modified is not None:
+                dt = last_modified(request, *args, **kwargs)
+                last_modified_value = dt and (formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT')
+            else:
+                last_modified_value = None
+                
+            if etag is not None:
+                etag_value = etag(request, *args, **kwargs)
+            else:
+                etag_value = None
+
+            # Calculate "not modified" condition
+            not_modified = (if_modified_since or if_none_match) and \
+                           (not if_modified_since or last_modified_value == if_modified_since) and \
+                           (not if_none_match or etag_value in if_none_match)
+
+            # Create appropriate response
+            if not_modified:
+                response = HttpResponseNotModified()
+            else:
+                response = func(request, *args, **kwargs)
+
+            # Set relevant headers for response
+            if last_modified_value and not response.has_header('Last-Modified'):
+                response['Last-Modified'] = last_modified_value
+            if etag_value and not response.has_header('ETag'):
+                response['ETag'] = etag_value
+
+            return response
+        return inner
+    return decorator
+
+# Shortcut decorators for common cases based on ETag or Last-Modified only
+def etag(callable):
+    return condition(etag=callable)
+
+def last_modified(callable):
+    return condition(last_modified=callable)
\ No newline at end of file

=== modified file 'docs/index.txt'
--- docs/index.txt	2008-10-05 06:38:59 +0000
+++ docs/index.txt	2008-11-16 21:18:17 +0000
@@ -74,6 +74,7 @@
 
     * :ref:`Authentication <topics-auth>`
     * :ref:`Caching <topics-cache>`
+    * :ref:`Conditional get <topics-conditional-get>`
     * :ref:`E-mail <topics-email>`
     * :ref:`File-access APIs <topics-files>`
     * :ref:`topics-i18n`

=== added file 'docs/topics/conditional-get.txt'
--- docs/topics/conditional-get.txt	1970-01-01 00:00:00 +0000
+++ docs/topics/conditional-get.txt	2008-11-16 21:14:28 +0000
@@ -0,0 +1,60 @@
+========================
+Per-view conditional get
+========================
+
+`Conditional get`_ is a feature of HTTP that allows you to omit sending a response
+if the client signals in a request that it has a cached copy of reponse. To control
+whether the cached copy is expired the client may use time of its last modification
+or an "entity tag" - a short value that changes whenever the response itself changes
+(typically it's simply a hash over the response).
+
+.. _`Conditional get`: http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
+
+Django allows simple usage of this feature with ``ConditionalGetMiddleware`` and 
+``CommonMiddleware`` (see `middleware documentation`_ for their usage). But, while 
+simple, they both have limitations: 
+
+* they are applied gloablly to all views in your project
+* they don't spare you from generating the response itself, which may be expensive
+
+.. _`middleware documentation`: ../middleware/
+
+Decorators
+==========
+
+When you need more fine-grained control you may use per-view conditional get 
+decorators.
+
+Decorators ``etag`` and ``last_modified`` accept a user-defined function that
+takes the same parameters as the view itself. ``last_modified`` should return a 
+standard datetime value and ``etag`` should return a string.
+
+Example usage of ``last_modified``::
+
+    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
+    def my_object_view(request, obj_id):
+        # Expensive generation of response with MyObject instance
+
+In this example we're taking advantage of the fact that MyObject instance stores
+the time when it was last updated in a field.
+
+Usage of ``etag`` is similar.
+
+HTTP allows to use both "ETag" and "Last-Modified" headers in your response. Then
+a response is considered not modified only if the client sends both headers back and
+they're both equal to the response headers. This means that you can't just chain 
+decorators on your view:
+
+    @etag(etag_func)
+    @last_modified(last_modified_func)
+    def my_view(request):
+        # ...
+
+Because the first decorator doesn't know anything about the second and can answer
+that the response is not modified even if the second decorators would think otherwise.
+In this case you should use a more general decorator - ``condition`` that accepts 
+two functions at once:
+
+    @condition(etag_func, last_modified_func)
+    def my_view(request):
+        # ...

=== added directory 'tests/regressiontests/conditional_get'
=== added file 'tests/regressiontests/conditional_get/__init__.py'
--- tests/regressiontests/conditional_get/__init__.py	1970-01-01 00:00:00 +0000
+++ tests/regressiontests/conditional_get/__init__.py	2008-11-16 21:14:28 +0000
@@ -0,0 +1,1 @@
+# -*- coding:utf-8 -*-

=== added file 'tests/regressiontests/conditional_get/models.py'
--- tests/regressiontests/conditional_get/models.py	1970-01-01 00:00:00 +0000
+++ tests/regressiontests/conditional_get/models.py	2008-11-16 21:14:28 +0000
@@ -0,0 +1,84 @@
+# -*- coding:utf-8 -*-
+from datetime import datetime, timedelta
+from calendar import timegm
+from django.test import TestCase
+
+FULL_RESPONSE = 'Test conditional get response'
+LAST_MODIFIED = datetime(2007, 10, 21, 23, 21, 47)
+LAST_MODIFIED_STR = 'Sun, 21 Oct 2007 23:21:47 GMT'
+EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
+ETAG = '"b4246ffc4f62314ca13147c9d4f76974"'
+EXPIRED_ETAG = '"7fae4cd4b0f81e7d2914700043aa8ed6"'
+
+class ConditionalGet(TestCase):
+    def assertFullResponse(self, response, check_last_modified=True, check_etag=True):
+        self.assertEquals(response.status_code, 200)
+        self.assertEquals(response.content, FULL_RESPONSE)
+        if check_last_modified:
+            self.assertEquals(response['Last-Modified'], LAST_MODIFIED_STR)
+        if check_etag:
+            self.assertEquals(response['ETag'], ETAG)
+
+    def assertNotModified(self, response):
+        self.assertEquals(response.status_code, 304)
+        self.assertEquals(response.content, '')
+
+    def testWithoutConditions(self):
+        response = self.client.get('/condition/')
+        self.assertFullResponse(response)
+
+    def testIfModifiedSince(self):
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
+        response = self.client.get('/condition/')
+        self.assertNotModified(response)
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
+        response = self.client.get('/condition/')
+        self.assertFullResponse(response)
+
+    def testIfNoneMatch(self):
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
+        response = self.client.get('/condition/')
+        self.assertNotModified(response)
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
+        response = self.client.get('/condition/')
+        self.assertFullResponse(response)
+        
+        # Several etags in If-None-Match is a bit exotic but why not?
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ', '.join([ETAG, EXPIRED_ETAG])
+        response = self.client.get('/condition/')
+        self.assertNotModified(response)
+    
+    def testBothHeaders(self):
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
+        response = self.client.get('/condition/')
+        self.assertNotModified(response)
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
+        response = self.client.get('/condition/')
+        self.assertFullResponse(response)
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
+        response = self.client.get('/condition/')
+        self.assertFullResponse(response)
+
+    def testSingleConditions(self):
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
+        response = self.client.get('/condition/last_modified/')
+        self.assertNotModified(response)
+        response = self.client.get('/condition/etag/')
+        self.assertFullResponse(response, check_last_modified=False)
+        
+        del self.client.defaults['HTTP_IF_MODIFIED_SINCE']
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
+        response = self.client.get('/condition/etag/')
+        self.assertNotModified(response)
+        response = self.client.get('/condition/last_modified/')
+        self.assertFullResponse(response, check_etag=False)
+        
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
+        response = self.client.get('/condition/last_modified/')
+        self.assertFullResponse(response, check_etag=False)
+        response = self.client.get('/condition/etag/')
+        self.assertFullResponse(response, check_last_modified=False)
\ No newline at end of file

=== added file 'tests/regressiontests/conditional_get/urls.py'
--- tests/regressiontests/conditional_get/urls.py	1970-01-01 00:00:00 +0000
+++ tests/regressiontests/conditional_get/urls.py	2008-11-16 21:14:28 +0000
@@ -0,0 +1,8 @@
+from django.conf.urls.defaults import *
+import views
+
+urlpatterns = patterns('',
+    ('^$', views.index),
+    ('^last_modified/$', views.last_modified),
+    ('^etag/$', views.etag),
+)

=== added file 'tests/regressiontests/conditional_get/views.py'
--- tests/regressiontests/conditional_get/views.py	1970-01-01 00:00:00 +0000
+++ tests/regressiontests/conditional_get/views.py	2008-11-16 21:14:28 +0000
@@ -0,0 +1,17 @@
+# -*- coding:utf-8 -*-
+from django.views.decorators.http import condition
+from django.http import HttpResponse
+
+from models import FULL_RESPONSE, LAST_MODIFIED, ETAG
+
+@condition(lambda r: ETAG, lambda r: LAST_MODIFIED)
+def index(request):
+    return HttpResponse(FULL_RESPONSE)
+
+@condition(last_modified=lambda r: LAST_MODIFIED)
+def last_modified(request):
+    return HttpResponse(FULL_RESPONSE)
+
+@condition(etag=lambda r: ETAG)
+def etag(request):
+    return HttpResponse(FULL_RESPONSE)
\ No newline at end of file

=== modified file 'tests/urls.py'
--- tests/urls.py	2008-11-13 19:03:44 +0000
+++ tests/urls.py	2008-11-16 21:14:28 +0000
@@ -29,4 +29,7 @@
 
     # test urlconf for syndication tests
     (r'^syndication/', include('regressiontests.syndication.urls')),
+
+    # conditional get views
+    (r'condition/', include('regressiontests.conditional_get.urls')),
 )

