Code

Ticket #5791: 5791.2.diff

File 5791.2.diff, 12.8 KB (added by isagalaev, 6 years ago)

Patch updated to current trunk + docs

Line 
1Index: django/views/decorators/http.py
2===================================================================
3--- django/views/decorators/http.py     (revision 7920)
4+++ django/views/decorators/http.py     (working copy)
5@@ -7,9 +7,12 @@
6 except ImportError:
7     from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
8 
9+from calendar import timegm
10+from datetime import timedelta
11+from email.Utils import formatdate
12 from django.utils.decorators import decorator_from_middleware
13 from django.middleware.http import ConditionalGetMiddleware
14-from django.http import HttpResponseNotAllowed
15+from django.http import HttpResponseNotAllowed, HttpResponseNotModified
16 
17 conditional_page = decorator_from_middleware(ConditionalGetMiddleware)
18 
19@@ -36,4 +39,71 @@
20 require_GET.__doc__ = "Decorator to require that a view only accept the GET method."
21 
22 require_POST = require_http_methods(["POST"])
23-require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
24\ No newline at end of file
25+require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
26+
27+def condition(etag=None, last_modified=None):
28+    """
29+    Decorator to support conditional get for a view.  It takes as parameters
30+    user-defined functions that calculate etag and/or last modified time.
31+   
32+    Both functions are passed the same parameters as the view itself. "last_modified"
33+    should return a standard datetime value and "etag" should return a string.
34+   
35+    Example usage with last_modified::
36+
37+        @condition(last_modified=lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
38+        def my_object_view(request, obj_id):
39+            # ...
40+
41+    You can pass last_modified, etag or both of them (if you really need it).
42+    """
43+    def decorator(func):
44+        def inner(request, *args, **kwargs):
45+            if request.method not in ('GET', 'HEAD'):
46+                return func(request, *args, **kwargs)
47+
48+            # Get HTTP request headers
49+            if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', None)
50+            if_none_match = request.META.get('HTTP_IF_NONE_MATCH', None)
51+            if if_none_match:
52+                if_none_match = [e.strip() for e in if_none_match.split(',')]
53+
54+            # Get and convert user-defined values
55+            if last_modified is not None:
56+                dt = last_modified(request, *args, **kwargs)
57+                last_modified_value = dt and (formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT')
58+            else:
59+                last_modified_value = None
60+               
61+            if etag is not None:
62+                etag_value = etag(request, *args, **kwargs)
63+            else:
64+                etag_value = None
65+
66+            # Calculate "not modified" condition
67+            not_modified = (if_modified_since or if_none_match) and \
68+                           (not if_modified_since or last_modified_value == if_modified_since) and \
69+                           (not if_none_match or etag_value in if_none_match)
70+
71+            # Create appropriate response
72+            if not_modified:
73+                response = HttpResponseNotModified()
74+            else:
75+                response = func(request, *args, **kwargs)
76+
77+            # Set relevant headers for response
78+            if last_modified_value and not response.has_header('Last-Modified'):
79+                response['Last-Modified'] = last_modified_value
80+            if etag_value and not response.has_header('ETag'):
81+                response['ETag'] = etag_value
82+
83+            return response
84+        return inner
85+    return decorator
86+
87+# Shortcut decorators for common cases based on ETag or Last-Modified only
88+def etag(callable):
89+    return condition(etag=callable)
90+
91+def last_modified(callable):
92+    return condition(last_modified=callable)
93\ No newline at end of file
94Index: tests/regressiontests/conditional_get/views.py
95===================================================================
96--- tests/regressiontests/conditional_get/views.py      (revision 0)
97+++ tests/regressiontests/conditional_get/views.py      (revision 0)
98@@ -0,0 +1,17 @@
99+# -*- coding:utf-8 -*-
100+from django.views.decorators.http import condition
101+from django.http import HttpResponse
102+
103+from models import FULL_RESPONSE, LAST_MODIFIED, ETAG
104+
105+@condition(lambda r: ETAG, lambda r: LAST_MODIFIED)
106+def index(request):
107+    return HttpResponse(FULL_RESPONSE)
108+
109+@condition(last_modified=lambda r: LAST_MODIFIED)
110+def last_modified(request):
111+    return HttpResponse(FULL_RESPONSE)
112+
113+@condition(etag=lambda r: ETAG)
114+def etag(request):
115+    return HttpResponse(FULL_RESPONSE)
116\ No newline at end of file
117Index: tests/regressiontests/conditional_get/__init__.py
118===================================================================
119--- tests/regressiontests/conditional_get/__init__.py   (revision 0)
120+++ tests/regressiontests/conditional_get/__init__.py   (revision 0)
121@@ -0,0 +1 @@
122+# -*- coding:utf-8 -*-
123Index: tests/regressiontests/conditional_get/models.py
124===================================================================
125--- tests/regressiontests/conditional_get/models.py     (revision 0)
126+++ tests/regressiontests/conditional_get/models.py     (revision 0)
127@@ -0,0 +1,84 @@
128+# -*- coding:utf-8 -*-
129+from datetime import datetime, timedelta
130+from calendar import timegm
131+from django.test import TestCase
132+
133+FULL_RESPONSE = 'Test conditional get response'
134+LAST_MODIFIED = datetime(2007, 10, 21, 23, 21, 47)
135+LAST_MODIFIED_STR = 'Sun, 21 Oct 2007 23:21:47 GMT'
136+EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
137+ETAG = '"b4246ffc4f62314ca13147c9d4f76974"'
138+EXPIRED_ETAG = '"7fae4cd4b0f81e7d2914700043aa8ed6"'
139+
140+class ConditionalGet(TestCase):
141+    def assertFullResponse(self, response, check_last_modified=True, check_etag=True):
142+        self.assertEquals(response.status_code, 200)
143+        self.assertEquals(response.content, FULL_RESPONSE)
144+        if check_last_modified:
145+            self.assertEquals(response['Last-Modified'], LAST_MODIFIED_STR)
146+        if check_etag:
147+            self.assertEquals(response['ETag'], ETAG)
148+
149+    def assertNotModified(self, response):
150+        self.assertEquals(response.status_code, 304)
151+        self.assertEquals(response.content, '')
152+
153+    def testWithoutConditions(self):
154+        response = self.client.get('/condition/')
155+        self.assertFullResponse(response)
156+
157+    def testIfModifiedSince(self):
158+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
159+        response = self.client.get('/condition/')
160+        self.assertNotModified(response)
161+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
162+        response = self.client.get('/condition/')
163+        self.assertFullResponse(response)
164+
165+    def testIfNoneMatch(self):
166+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
167+        response = self.client.get('/condition/')
168+        self.assertNotModified(response)
169+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
170+        response = self.client.get('/condition/')
171+        self.assertFullResponse(response)
172+       
173+        # Several etags in If-None-Match is a bit exotic but why not?
174+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ', '.join([ETAG, EXPIRED_ETAG])
175+        response = self.client.get('/condition/')
176+        self.assertNotModified(response)
177+   
178+    def testBothHeaders(self):
179+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
180+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
181+        response = self.client.get('/condition/')
182+        self.assertNotModified(response)
183+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
184+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
185+        response = self.client.get('/condition/')
186+        self.assertFullResponse(response)
187+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
188+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
189+        response = self.client.get('/condition/')
190+        self.assertFullResponse(response)
191+
192+    def testSingleConditions(self):
193+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
194+        response = self.client.get('/condition/last_modified/')
195+        self.assertNotModified(response)
196+        response = self.client.get('/condition/etag/')
197+        self.assertFullResponse(response, check_last_modified=False)
198+       
199+        del self.client.defaults['HTTP_IF_MODIFIED_SINCE']
200+        self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
201+        response = self.client.get('/condition/etag/')
202+        self.assertNotModified(response)
203+        response = self.client.get('/condition/last_modified/')
204+        self.assertFullResponse(response, check_etag=False)
205+       
206+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
207+        self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
208+        response = self.client.get('/condition/last_modified/')
209+        self.assertFullResponse(response, check_etag=False)
210+        response = self.client.get('/condition/etag/')
211+        self.assertFullResponse(response, check_last_modified=False)
212\ No newline at end of file
213Index: tests/regressiontests/conditional_get/urls.py
214===================================================================
215--- tests/regressiontests/conditional_get/urls.py       (revision 0)
216+++ tests/regressiontests/conditional_get/urls.py       (revision 0)
217@@ -0,0 +1,8 @@
218+from django.conf.urls.defaults import *
219+import views
220+
221+urlpatterns = patterns('',
222+    ('^$', views.index),
223+    ('^last_modified/$', views.last_modified),
224+    ('^etag/$', views.etag),
225+)
226Index: tests/urls.py
227===================================================================
228--- tests/urls.py       (revision 7920)
229+++ tests/urls.py       (working copy)
230@@ -25,4 +25,7 @@
231 
232     # test urlconf for syndication tests
233     (r'^syndication/', include('regressiontests.syndication.urls')),
234+
235+    # conditional get views
236+    (r'condition/', include('regressiontests.conditional_get.urls')),
237 )
238Index: docs/conditional_get.txt
239===================================================================
240--- docs/conditional_get.txt    (revision 0)
241+++ docs/conditional_get.txt    (revision 0)
242@@ -0,0 +1,60 @@
243+========================
244+Per-view conditional get
245+========================
246+
247+`Conditional get`_ is a feature of HTTP that allows you to omit sending a response
248+if the client signals in a request that it has a cached copy of reponse. To control
249+whether the cached copy is expired the client may use time of its last modification
250+or an "entity tag" - a short value that changes whenever the response itself changes
251+(typically it's simply a hash over the response).
252+
253+.. _`Conditional get`: http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
254+
255+Django allows simple usage of this feature with ``ConditionalGetMiddleware`` and
256+``CommonMiddleware`` (see `middleware documentation`_ for their usage). But, while
257+simple, they both have limitations:
258+
259+* they are applied gloablly to all views in your project
260+* they don't spare you from generating the response itself, which may be expensive
261+
262+.. _`middleware documentation`: ../middleware/
263+
264+Decorators
265+==========
266+
267+When you need more fine-grained control you may use per-view conditional get
268+decorators.
269+
270+Decorators ``etag`` and ``last_modified`` accept a user-defined function that
271+takes the same parameters as the view itself. ``last_modified`` should return a
272+standard datetime value and ``etag`` should return a string.
273+
274+Example usage of ``last_modified``::
275+
276+    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
277+    def my_object_view(request, obj_id):
278+        # Expensive generation of response with MyObject instance
279+
280+In this example we're taking advantage of the fact that MyObject instance stores
281+the time when it was last updated in a field.
282+
283+Usage of ``etag`` is similar.
284+
285+HTTP allows to use both "ETag" and "Last-Modified" headers in your response. Then
286+a response is considered not modified only if the client sends both headers back and
287+they're both equal to the response headers. This means that you can't just chain
288+decorators on your view:
289+
290+    @etag(etag_func)
291+    @last_modified(last_modified_func)
292+    def my_view(request):
293+        # ...
294+
295+Because the first decorator doesn't know anything about the second and can answer
296+that the response is not modified even if the second decorators would think otherwise.
297+In this case you should use a more general decorator - ``condition`` that accepts
298+two functions at once:
299+
300+    @condition(etag_func, last_modified_func)
301+    def my_view(request):
302+        # ...
303Index: docs/index.txt
304===================================================================
305--- docs/index.txt      (revision 7920)
306+++ docs/index.txt      (working copy)
307@@ -38,6 +38,7 @@
308    testing
309    sessions
310    cache
311+   conditional_get
312    settings
313    url_dispatch
314    request_response