Fixed #15012 -- Added post-rendering callbacks to TemplateResponse so that decorators (in particular, the cache decorator) can defer processing until after rendering has occurred. Thanks to Joshua Ginsberg for the draft patch.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@15295 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Russell Keith-Magee 2011-01-24 14:24:35 +00:00
parent 3d7afd5d2b
commit 3f528e10d5
9 changed files with 256 additions and 8 deletions

View File

@ -52,6 +52,7 @@ from django.conf import settings
from django.core.cache import get_cache, DEFAULT_CACHE_ALIAS
from django.utils.cache import get_cache_key, learn_cache_key, patch_response_headers, get_max_age
class UpdateCacheMiddleware(object):
"""
Response-phase cache middleware that updates the cache if the response is
@ -87,7 +88,12 @@ class UpdateCacheMiddleware(object):
patch_response_headers(response, timeout)
if timeout:
cache_key = learn_cache_key(request, response, timeout, self.key_prefix, cache=self.cache)
self.cache.set(cache_key, response, timeout)
if hasattr(response, 'render') and callable(response.render):
response.add_post_render_callback(
lambda r: self.cache.set(cache_key, r, timeout)
)
else:
self.cache.set(cache_key, response, timeout)
return response
class FetchFromCacheMiddleware(object):

View File

@ -19,12 +19,30 @@ class SimpleTemplateResponse(HttpResponse):
# a final response.
self._is_rendered = False
self._post_render_callbacks = []
# content argument doesn't make sense here because it will be replaced
# with rendered template so we always pass empty string in order to
# prevent errors and provide shorter signature.
super(SimpleTemplateResponse, self).__init__('', mimetype, status,
content_type)
def __getstate__(self):
"""Pickling support function.
Ensures that the object can't be pickled before it has been
rendered, and that the pickled state only includes rendered
data, not the data used to construct the response.
"""
obj_dict = self.__dict__.copy()
if not self._is_rendered:
raise ContentNotRenderedError('The response content must be rendered before it can be pickled.')
del obj_dict['template_name']
del obj_dict['context_data']
del obj_dict['_post_render_callbacks']
return obj_dict
def resolve_template(self, template):
"Accepts a template object, path-to-template or list of paths"
if isinstance(template, (list, tuple)):
@ -57,6 +75,16 @@ class SimpleTemplateResponse(HttpResponse):
content = template.render(context)
return content
def add_post_render_callback(self, callback):
"""Add a new post-rendering callback.
If the response has already been rendered, invoke the callback immediately.
"""
if self._is_rendered:
callback(self)
else:
self._post_render_callbacks.append(callback)
def render(self):
"""Render (thereby finalizing) the content of the response.
@ -66,6 +94,8 @@ class SimpleTemplateResponse(HttpResponse):
"""
if not self._is_rendered:
self._set_content(self.rendered_content)
for post_callback in self._post_render_callbacks:
post_callback(self)
return self
is_rendered = property(lambda self: self._is_rendered)
@ -81,7 +111,7 @@ class SimpleTemplateResponse(HttpResponse):
return super(SimpleTemplateResponse, self)._get_content()
def _set_content(self, value):
"Overrides rendered content, unless you later call render()"
"Sets the content for the response"
super(SimpleTemplateResponse, self)._set_content(value)
self._is_rendered = True
@ -101,6 +131,20 @@ class TemplateResponse(SimpleTemplateResponse):
super(TemplateResponse, self).__init__(
template, context, mimetype, status, content_type)
def __getstate__(self):
"""Pickling support function.
Ensures that the object can't be pickled before it has been
rendered, and that the pickled state only includes rendered
data, not the data used to construct the response.
"""
obj_dict = super(TemplateResponse, self).__getstate__()
del obj_dict['_request']
del obj_dict['_current_app']
return obj_dict
def resolve_context(self, context):
"""Convert context data into a full RequestContext object
(assuming it isn't already a Context object).
@ -109,3 +153,5 @@ class TemplateResponse(SimpleTemplateResponse):
return context
else:
return RequestContext(self._request, context, current_app=self._current_app)

View File

@ -55,7 +55,6 @@ Attributes
A boolean indicating whether the response content has been rendered.
Methods
-------
@ -106,6 +105,20 @@ Methods
Override this method in order to customize template rendering.
.. method:: SimpleTemplateResponse.add_post_rendering_callback
Add a callback that will be invoked after rendering has taken
place. This hook can be used to defer certain processing
operations (such as caching) until after rendering has occurred.
If the :class:`~django.template.response.SimpleTemplateResponse`
has already been rendered, the callback will be invoked
immediately.
When called, callbacks will be passed a single argument -- the
rendered :class:`~django.template.response.SimpleTemplateResponse`
instance.
.. method:: SimpleTemplateResponse.render():
Sets :attr:`response.content` to the result obtained by
@ -211,6 +224,50 @@ the content of the response manually::
>>> print t.content
New content
Post-render callbacks
---------------------
Some operations -- such as caching -- cannot be performed on an
unrendered template. They must be performed on a fully complete and
rendered response.
If you're using middleware, the solution is easy. Middleware provides
multiple opportunities to process a response on exit from a view. If
you put behavior in the Response middleware is guaranteed to execute
after template rendering has taken place.
However, if you're using a decorator, the same opportunities do not
exist. Any behavior defined in a decorator is handled immediately.
To compensate for this (and any other analogous use cases),
:class:`TemplateResponse` allows you to register callbacks that will
be invoked when rendering has completed. Using this callback, you can
defer critical processing until a point where you can guarantee that
rendered content will be available.
To define a post-render callback, just define a function that takes
a single argument -- response -- and register that function with
the template response::
def my_render_callback(response):
# Do content-sensitive processing
do_post_processing()
def my_view(request):
# Create a response
response = TemplateResponse(request, 'mytemplate.html', {})
# Register the callback
response.add_post_render_callback(my_render_callback)
# Return the response
return response
``my_render_callback()`` will be invoked after the ``mytemplate.html``
has been rendered, and will be provided the fully rendered
:class:`TemplateResponse` instance as an argument.
If the template has already been rendered, the callback will be
invoked immediately.
Using TemplateResponse and SimpleTemplateResponse
=================================================

View File

@ -1,3 +1,4 @@
import time
import unittest
from django.core.exceptions import ImproperlyConfigured
@ -158,7 +159,7 @@ class TemplateViewTest(TestCase):
def _assert_about(self, response):
response.render()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, '<h1>About</h1>')
self.assertContains(response, '<h1>About</h1>')
def test_get(self):
"""
@ -197,6 +198,28 @@ class TemplateViewTest(TestCase):
self.assertEqual(response.context['params'], {'foo': 'bar'})
self.assertEqual(response.context['key'], 'value')
def test_cached_views(self):
"""
A template view can be cached
"""
response = self.client.get('/template/cached/bar/')
self.assertEqual(response.status_code, 200)
time.sleep(1.0)
response2 = self.client.get('/template/cached/bar/')
self.assertEqual(response2.status_code, 200)
self.assertEqual(response.content, response2.content)
time.sleep(2.0)
# Let the cache expire and test again
response2 = self.client.get('/template/cached/bar/')
self.assertEqual(response2.status_code, 200)
self.assertNotEqual(response.content, response2.content)
class RedirectViewTest(unittest.TestCase):
rf = RequestFactory()

View File

@ -1 +1,2 @@
<h1>About</h1>
<h1>About</h1>
{% now "U.u" %}

View File

@ -1,5 +1,6 @@
from django.conf.urls.defaults import *
from django.views.generic import TemplateView
from django.views.decorators.cache import cache_page
import views
@ -15,6 +16,9 @@ urlpatterns = patterns('',
(r'^template/custom/(?P<foo>\w+)/$',
views.CustomTemplateView.as_view(template_name='generic_views/about.html')),
(r'^template/cached/(?P<foo>\w+)/$',
cache_page(2.0)(TemplateView.as_view(template_name='generic_views/about.html'))),
# DetailView
(r'^detail/obj/$',
views.ObjectDetail.as_view()),

View File

@ -1,10 +1,12 @@
# coding: utf-8
from django.conf.urls.defaults import *
from regressiontests.templates import views
urlpatterns = patterns('',
# View returning a template response
(r'^template_response_view/', views.template_response_view),
(r'^template_response_view/$', views.template_response_view),
# A view that can be hard to find...
url(r'^snark/', views.snark, name='snark'),

View File

@ -1,4 +1,7 @@
from datetime import datetime
import os
import pickle
import time
from django.utils import unittest
from django.test import RequestFactory, TestCase
from django.conf import settings
@ -147,6 +150,49 @@ class SimpleTemplateResponseTest(BaseTemplateResponseTest):
self.assertEqual(response['content-type'], 'application/json')
self.assertEqual(response.status_code, 504)
def test_post_callbacks(self):
"Rendering a template response triggers the post-render callbacks"
post = []
def post1(obj):
post.append('post1')
def post2(obj):
post.append('post2')
response = SimpleTemplateResponse('first/test.html', {})
response.add_post_render_callback(post1)
response.add_post_render_callback(post2)
# When the content is rendered, all the callbacks are invoked, too.
response.render()
self.assertEqual('First template\n', response.content)
self.assertEquals(post, ['post1','post2'])
def test_pickling(self):
# Create a template response. The context is
# known to be unpickleable (e.g., a function).
response = SimpleTemplateResponse('first/test.html', {
'value': 123,
'fn': datetime.now,
})
self.assertRaises(ContentNotRenderedError,
pickle.dumps, response)
# But if we render the response, we can pickle it.
response.render()
pickled_response = pickle.dumps(response)
unpickled_response = pickle.loads(pickled_response)
self.assertEquals(unpickled_response.content, response.content)
self.assertEquals(unpickled_response['content-type'], response['content-type'])
self.assertEquals(unpickled_response.status_code, response.status_code)
# ...and the unpickled reponse doesn't have the
# template-related attributes, so it can't be re-rendered
self.assertFalse(hasattr(unpickled_response, 'template_name'))
self.assertFalse(hasattr(unpickled_response, 'context_data'))
self.assertFalse(hasattr(unpickled_response, '_post_render_callbacks'))
class TemplateResponseTest(BaseTemplateResponseTest):
@ -187,6 +233,33 @@ class TemplateResponseTest(BaseTemplateResponseTest):
self.assertEqual(rc.current_app, 'foobar')
def test_pickling(self):
# Create a template response. The context is
# known to be unpickleable (e.g., a function).
response = TemplateResponse(self.factory.get('/'),
'first/test.html', {
'value': 123,
'fn': datetime.now,
})
self.assertRaises(ContentNotRenderedError,
pickle.dumps, response)
# But if we render the response, we can pickle it.
response.render()
pickled_response = pickle.dumps(response)
unpickled_response = pickle.loads(pickled_response)
self.assertEquals(unpickled_response.content, response.content)
self.assertEquals(unpickled_response['content-type'], response['content-type'])
self.assertEquals(unpickled_response.status_code, response.status_code)
# ...and the unpickled reponse doesn't have the
# template-related attributes, so it can't be re-rendered
self.assertFalse(hasattr(unpickled_response, '_request'))
self.assertFalse(hasattr(unpickled_response, 'template_name'))
self.assertFalse(hasattr(unpickled_response, 'context_data'))
self.assertFalse(hasattr(unpickled_response, '_post_render_callbacks'))
class CustomURLConfTest(TestCase):
urls = 'regressiontests.templates.urls'
@ -203,6 +276,41 @@ class CustomURLConfTest(TestCase):
def test_custom_urlconf(self):
response = self.client.get('/template_response_view/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, 'This is where you can find the snark: /snark/')
self.assertContains(response, 'This is where you can find the snark: /snark/')
class CacheMiddlewareTest(TestCase):
urls = 'regressiontests.templates.alternate_urls'
def setUp(self):
self.old_MIDDLEWARE_CLASSES = settings.MIDDLEWARE_CLASSES
self.CACHE_MIDDLEWARE_SECONDS = settings.CACHE_MIDDLEWARE_SECONDS
settings.CACHE_MIDDLEWARE_SECONDS = 2.0
settings.MIDDLEWARE_CLASSES = list(settings.MIDDLEWARE_CLASSES) + [
'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.cache.UpdateCacheMiddleware',
]
def tearDown(self):
settings.MIDDLEWARE_CLASSES = self.old_MIDDLEWARE_CLASSES
settings.CACHE_MIDDLEWARE_SECONDS = self.CACHE_MIDDLEWARE_SECONDS
def test_middleware_caching(self):
response = self.client.get('/template_response_view/')
self.assertEqual(response.status_code, 200)
time.sleep(1.0)
response2 = self.client.get('/template_response_view/')
self.assertEqual(response2.status_code, 200)
self.assertEqual(response.content, response2.content)
time.sleep(2.0)
# Let the cache expire and test again
response2 = self.client.get('/template_response_view/')
self.assertEqual(response2.status_code, 200)
self.assertNotEqual(response.content, response2.content)

View File

@ -1 +1,2 @@
{% load url from future %}This is where you can find the snark: {% url "snark" %}
{% load url from future %}This is where you can find the snark: {% url "snark" %}
{% now "U.u" %}