Skip to content

Commit

Permalink
Fixed #15012 -- Added post-rendering callbacks to TemplateResponse so…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
freakboy3742 committed Jan 24, 2011
1 parent 3d7afd5 commit 3f528e1
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 8 deletions.
8 changes: 7 additions & 1 deletion django/middleware/cache.py
Expand Up @@ -52,6 +52,7 @@
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
Expand Down Expand Up @@ -87,7 +88,12 @@ def process_response(self, request, response):
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):
Expand Down
48 changes: 47 additions & 1 deletion django/template/response.py
Expand Up @@ -19,12 +19,30 @@ def __init__(self, template, context=None, mimetype=None, status=None,
# 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)):
Expand Down Expand Up @@ -57,6 +75,16 @@ def rendered_content(self):
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.
Expand All @@ -66,6 +94,8 @@ def render(self):
"""
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)
Expand All @@ -81,7 +111,7 @@ def _get_content(self):
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

Expand All @@ -101,6 +131,20 @@ def __init__(self, request, template, context=None, mimetype=None,
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).
Expand All @@ -109,3 +153,5 @@ def resolve_context(self, context):
return context
else:
return RequestContext(self._request, context, current_app=self._current_app)


59 changes: 58 additions & 1 deletion docs/ref/template-response.txt
Expand Up @@ -55,7 +55,6 @@ Attributes

A boolean indicating whether the response content has been rendered.


Methods
-------

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
=================================================

Expand Down
25 changes: 24 additions & 1 deletion tests/regressiontests/generic_views/base.py
@@ -1,3 +1,4 @@
import time
import unittest

from django.core.exceptions import ImproperlyConfigured
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -197,6 +198,28 @@ def test_extra_template_params(self):
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()

Expand Down
@@ -1 +1,2 @@
<h1>About</h1>
<h1>About</h1>
{% now "U.u" %}
4 changes: 4 additions & 0 deletions tests/regressiontests/generic_views/urls.py
@@ -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

Expand All @@ -15,6 +16,9 @@
(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()),
Expand Down
4 changes: 3 additions & 1 deletion tests/regressiontests/templates/alternate_urls.py
@@ -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'),
Expand Down

0 comments on commit 3f528e1

Please sign in to comment.