Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #15252 -- Added static template tag and CachedStaticFilesStorag…

…e to staticfiles contrib app.

Many thanks to Florian Apolloner and Jacob Kaplan-Moss for reviewing and eagle eyeing.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16594 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit 1d32bdd3c9586ff10d0799264105850fa7e3f512 1 parent e9a909e
Jannis Leidel authored August 11, 2011

Showing 33 changed files with 643 additions and 145 deletions. Show diff stats Hide diff stats

  1. 3  django/contrib/admin/helpers.py
  2. 13  django/contrib/admin/options.py
  3. 2  django/contrib/admin/templates/admin/auth/user/change_password.html
  4. 2  django/contrib/admin/templates/admin/base.html
  5. 2  django/contrib/admin/templates/admin/change_form.html
  6. 2  django/contrib/admin/templates/admin/change_list.html
  7. 2  django/contrib/admin/templates/admin/change_list_results.html
  8. 2  django/contrib/admin/templates/admin/edit_inline/stacked.html
  9. 2  django/contrib/admin/templates/admin/edit_inline/tabular.html
  10. 2  django/contrib/admin/templates/admin/index.html
  11. 2  django/contrib/admin/templates/admin/login.html
  12. 2  django/contrib/admin/templates/admin/search_form.html
  13. 2  django/contrib/admin/templatetags/admin_list.py
  14. 11  django/contrib/admin/templatetags/admin_static.py
  15. 34  django/contrib/admin/widgets.py
  16. 2  django/contrib/staticfiles/finders.py
  17. 84  django/contrib/staticfiles/management/commands/collectstatic.py
  18. 165  django/contrib/staticfiles/storage.py
  19. 13  django/contrib/staticfiles/templatetags/staticfiles.py
  20. 14  django/contrib/staticfiles/utils.py
  21. 44  docs/howto/static-files.txt
  22. 143  docs/ref/contrib/staticfiles.txt
  23. 17  docs/ref/templates/builtins.txt
  24. 23  docs/releases/1.4.txt
  25. 0  staticfiles/templatetags/__init__.py b/django/contrib/staticfiles/templatetags/__init__.py
  26. 1  tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css
  27. 1  tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css
  28. 0  tests/regressiontests/staticfiles_tests/project/documents/cached/other.css
  29. 2  tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css
  30. 1  tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css
  31. 1  tests/regressiontests/staticfiles_tests/project/documents/cached/url.css
  32. 1  tests/regressiontests/staticfiles_tests/project/site_media/static/testfile.txt
  33. 193  tests/regressiontests/staticfiles_tests/tests.py
3  django/contrib/admin/helpers.py
... ...
@@ -1,6 +1,7 @@
1 1
 from django import forms
2 2
 from django.contrib.admin.util import (flatten_fieldsets, lookup_field,
3 3
     display_for_field, label_for_field, help_text_for_field)
  4
+from django.contrib.admin.templatetags.admin_static import static
4 5
 from django.contrib.contenttypes.models import ContentType
5 6
 from django.core.exceptions import ObjectDoesNotExist
6 7
 from django.db.models.fields.related import ManyToManyRel
@@ -75,7 +76,7 @@ def __init__(self, form, name=None, readonly_fields=(), fields=(), classes=(),
75 76
     def _media(self):
76 77
         if 'collapse' in self.classes:
77 78
             js = ['jquery.min.js', 'jquery.init.js', 'collapse.min.js']
78  
-            return forms.Media(js=['admin/js/%s' % url for url in js])
  79
+            return forms.Media(js=[static('admin/js/%s' % url) for url in js])
79 80
         return forms.Media()
80 81
     media = property(_media)
81 82
 
13  django/contrib/admin/options.py
@@ -6,6 +6,7 @@
6 6
 from django.contrib.contenttypes.models import ContentType
7 7
 from django.contrib.admin import widgets, helpers
8 8
 from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_format_dict
  9
+from django.contrib.admin.templatetags.admin_static import static
9 10
 from django.contrib import messages
10 11
 from django.views.decorators.csrf import csrf_protect
11 12
 from django.core.exceptions import PermissionDenied, ValidationError
@@ -350,7 +351,8 @@ def urls(self):
350 351
         return self.get_urls()
351 352
     urls = property(urls)
352 353
 
353  
-    def _media(self):
  354
+    @property
  355
+    def media(self):
354 356
         js = [
355 357
             'core.js',
356 358
             'admin/RelatedObjectLookups.js',
@@ -363,8 +365,7 @@ def _media(self):
363 365
             js.extend(['urlify.js', 'prepopulate.min.js'])
364 366
         if self.opts.get_ordered_objects():
365 367
             js.extend(['getElementsBySelector.js', 'dom-drag.js' , 'admin/ordering.js'])
366  
-        return forms.Media(js=['admin/js/%s' % url for url in js])
367  
-    media = property(_media)
  368
+        return forms.Media(js=[static('admin/js/%s' % url) for url in js])
368 369
 
369 370
     def has_add_permission(self, request):
370 371
         """
@@ -1322,14 +1323,14 @@ def __init__(self, parent_model, admin_site):
1322 1323
         if self.verbose_name_plural is None:
1323 1324
             self.verbose_name_plural = self.model._meta.verbose_name_plural
1324 1325
 
1325  
-    def _media(self):
  1326
+    @property
  1327
+    def media(self):
1326 1328
         js = ['jquery.min.js', 'jquery.init.js', 'inlines.min.js']
1327 1329
         if self.prepopulated_fields:
1328 1330
             js.extend(['urlify.js', 'prepopulate.min.js'])
1329 1331
         if self.filter_vertical or self.filter_horizontal:
1330 1332
             js.extend(['SelectBox.js', 'SelectFilter2.js'])
1331  
-        return forms.Media(js=['admin/js/%s' % url for url in js])
1332  
-    media = property(_media)
  1333
+        return forms.Media(js=[static('admin/js/%s' % url) for url in js])
1333 1334
 
1334 1335
     def get_formset(self, request, obj=None, **kwargs):
1335 1336
         """Returns a BaseInlineFormSet class for use in admin add/change views."""
2  django/contrib/admin/templates/admin/auth/user/change_password.html
... ...
@@ -1,5 +1,5 @@
1 1
 {% extends "admin/base_site.html" %}
2  
-{% load i18n static admin_modify %}
  2
+{% load i18n admin_static admin_modify %}
3 3
 {% load url from future %}
4 4
 {% block extrahead %}{{ block.super }}
5 5
 {% url 'admin:jsi18n' as jsi18nurl %}
2  django/contrib/admin/templates/admin/base.html
... ...
@@ -1,4 +1,4 @@
1  
-{% load static %}{% load url from future %}<!DOCTYPE html>
  1
+{% load admin_static %}{% load url from future %}<!DOCTYPE html>
2 2
 <html lang="{{ LANGUAGE_CODE|default:"en-us" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
3 3
 <head>
4 4
 <title>{% block title %}{% endblock %}</title>
2  django/contrib/admin/templates/admin/change_form.html
... ...
@@ -1,5 +1,5 @@
1 1
 {% extends "admin/base_site.html" %}
2  
-{% load i18n static admin_modify %}
  2
+{% load i18n admin_static admin_modify %}
3 3
 {% load url from future %}
4 4
 
5 5
 {% block extrahead %}{{ block.super }}
2  django/contrib/admin/templates/admin/change_list.html
... ...
@@ -1,5 +1,5 @@
1 1
 {% extends "admin/base_site.html" %}
2  
-{% load i18n static admin_list %}
  2
+{% load i18n admin_static admin_list %}
3 3
 {% load url from future %}
4 4
 {% block extrastyle %}
5 5
   {{ block.super }}
2  django/contrib/admin/templates/admin/change_list_results.html
... ...
@@ -1,4 +1,4 @@
1  
-{% load i18n static %}
  1
+{% load i18n admin_static %}
2 2
 {% if result_hidden_fields %}
3 3
 <div class="hiddenfields">{# DIV for HTML validation #}
4 4
 {% for item in result_hidden_fields %}{{ item }}{% endfor %}
2  django/contrib/admin/templates/admin/edit_inline/stacked.html
... ...
@@ -1,4 +1,4 @@
1  
-{% load i18n static %}
  1
+{% load i18n admin_static %}
2 2
 <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
3 3
   <h2>{{ inline_admin_formset.opts.verbose_name_plural|title }}</h2>
4 4
 {{ inline_admin_formset.formset.management_form }}
2  django/contrib/admin/templates/admin/edit_inline/tabular.html
... ...
@@ -1,4 +1,4 @@
1  
-{% load i18n static admin_modify %}
  1
+{% load i18n admin_static admin_modify %}
2 2
 <div class="inline-group" id="{{ inline_admin_formset.formset.prefix }}-group">
3 3
   <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
4 4
 {{ inline_admin_formset.formset.management_form }}
2  django/contrib/admin/templates/admin/index.html
... ...
@@ -1,5 +1,5 @@
1 1
 {% extends "admin/base_site.html" %}
2  
-{% load i18n static %}
  2
+{% load i18n admin_static %}
3 3
 
4 4
 {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/dashboard.css" %}" />{% endblock %}
5 5
 
2  django/contrib/admin/templates/admin/login.html
... ...
@@ -1,5 +1,5 @@
1 1
 {% extends "admin/base_site.html" %}
2  
-{% load i18n static %}
  2
+{% load i18n admin_static %}
3 3
 
4 4
 {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}" />{% endblock %}
5 5
 
2  django/contrib/admin/templates/admin/search_form.html
... ...
@@ -1,4 +1,4 @@
1  
-{% load i18n static %}
  1
+{% load i18n admin_static %}
2 2
 {% if cl.search_fields %}
3 3
 <div id="toolbar"><form id="changelist-search" action="" method="get">
4 4
 <div><!-- DIV needed for valid HTML -->
2  django/contrib/admin/templatetags/admin_list.py
@@ -3,9 +3,9 @@
3 3
 from django.contrib.admin.util import lookup_field, display_for_field, label_for_field
4 4
 from django.contrib.admin.views.main import (ALL_VAR, EMPTY_CHANGELIST_VALUE,
5 5
     ORDER_VAR, PAGE_VAR, SEARCH_VAR)
  6
+from django.contrib.admin.templatetags.admin_static import static
6 7
 from django.core.exceptions import ObjectDoesNotExist
7 8
 from django.db import models
8  
-from django.templatetags.static import static
9 9
 from django.utils import formats
10 10
 from django.utils.html import escape, conditional_escape
11 11
 from django.utils.safestring import mark_safe
11  django/contrib/admin/templatetags/admin_static.py
... ...
@@ -0,0 +1,11 @@
  1
+from django.conf import settings
  2
+from django.template import Library
  3
+
  4
+register = Library()
  5
+
  6
+if 'django.contrib.staticfiles' in settings.INSTALLED_APPS:
  7
+    from django.contrib.staticfiles.templatetags.staticfiles import static
  8
+else:
  9
+    from django.templatetags.static import static
  10
+
  11
+static = register.simple_tag(static)
34  django/contrib/admin/widgets.py
@@ -4,16 +4,17 @@
4 4
 
5 5
 import copy
6 6
 from django import forms
  7
+from django.contrib.admin.templatetags.admin_static import static
7 8
 from django.core.urlresolvers import reverse
8 9
 from django.forms.widgets import RadioFieldRenderer
9 10
 from django.forms.util import flatatt
10  
-from django.templatetags.static import static
11 11
 from django.utils.html import escape
12 12
 from django.utils.text import Truncator
13 13
 from django.utils.translation import ugettext as _
14 14
 from django.utils.safestring import mark_safe
15 15
 from django.utils.encoding import force_unicode
16 16
 
  17
+
17 18
 class FilteredSelectMultiple(forms.SelectMultiple):
18 19
     """
19 20
     A SelectMultiple with a JavaScript filter interface.
@@ -21,9 +22,10 @@ class FilteredSelectMultiple(forms.SelectMultiple):
21 22
     Note that the resulting JavaScript assumes that the jsi18n
22 23
     catalog has been loaded in the page
23 24
     """
24  
-    class Media:
25  
-        js = ["admin/js/%s" % path
26  
-              for path in ["core.js", "SelectBox.js", "SelectFilter2.js"]]
  25
+    @property
  26
+    def media(self):
  27
+        js = ["core.js", "SelectBox.js", "SelectFilter2.js"]
  28
+        return forms.Media(js=[static("admin/js/%s" % path) for path in js])
27 29
 
28 30
     def __init__(self, verbose_name, is_stacked, attrs=None, choices=()):
29 31
         self.verbose_name = verbose_name
@@ -31,9 +33,11 @@ def __init__(self, verbose_name, is_stacked, attrs=None, choices=()):
31 33
         super(FilteredSelectMultiple, self).__init__(attrs, choices)
32 34
 
33 35
     def render(self, name, value, attrs=None, choices=()):
34  
-        if attrs is None: attrs = {}
  36
+        if attrs is None:
  37
+            attrs = {}
35 38
         attrs['class'] = 'selectfilter'
36  
-        if self.is_stacked: attrs['class'] += 'stacked'
  39
+        if self.is_stacked:
  40
+            attrs['class'] += 'stacked'
37 41
         output = [super(FilteredSelectMultiple, self).render(name, value, attrs, choices)]
38 42
         output.append(u'<script type="text/javascript">addEvent(window, "load", function(e) {')
39 43
         # TODO: "id_" is hard-coded here. This should instead use the correct
@@ -43,15 +47,21 @@ def render(self, name, value, attrs=None, choices=()):
43 47
         return mark_safe(u''.join(output))
44 48
 
45 49
 class AdminDateWidget(forms.DateInput):
46  
-    class Media:
47  
-        js = ["admin/js/calendar.js", "admin/js/admin/DateTimeShortcuts.js"]
  50
+
  51
+    @property
  52
+    def media(self):
  53
+        js = ["calendar.js", "admin/DateTimeShortcuts.js"]
  54
+        return forms.Media(js=[static("admin/js/%s" % path) for path in js])
48 55
 
49 56
     def __init__(self, attrs={}, format=None):
50 57
         super(AdminDateWidget, self).__init__(attrs={'class': 'vDateField', 'size': '10'}, format=format)
51 58
 
52 59
 class AdminTimeWidget(forms.TimeInput):
53  
-    class Media:
54  
-        js = ["admin/js/calendar.js", "admin/js/admin/DateTimeShortcuts.js"]
  60
+
  61
+    @property
  62
+    def media(self):
  63
+        js = ["calendar.js", "admin/DateTimeShortcuts.js"]
  64
+        return forms.Media(js=[static("admin/js/%s" % path) for path in js])
55 65
 
56 66
     def __init__(self, attrs={}, format=None):
57 67
         super(AdminTimeWidget, self).__init__(attrs={'class': 'vTimeField', 'size': '8'}, format=format)
@@ -232,9 +242,9 @@ def __deepcopy__(self, memo):
232 242
         memo[id(self)] = obj
233 243
         return obj
234 244
 
235  
-    def _media(self):
  245
+    @property
  246
+    def media(self):
236 247
         return self.widget.media
237  
-    media = property(_media)
238 248
 
239 249
     def render(self, name, value, *args, **kwargs):
240 250
         rel_to = self.rel.to
2  django/contrib/staticfiles/finders.py
@@ -28,7 +28,7 @@ def find(self, path, all=False):
28 28
         """
29 29
         raise NotImplementedError()
30 30
 
31  
-    def list(self, ignore_patterns=[]):
  31
+    def list(self, ignore_patterns):
32 32
         """
33 33
         Given an optional list of paths to ignore, this should return
34 34
         a two item iterable consisting of the relative path and storage
84  django/contrib/staticfiles/management/commands/collectstatic.py
@@ -4,12 +4,11 @@
4 4
 import sys
5 5
 from optparse import make_option
6 6
 
7  
-from django.conf import settings
8  
-from django.core.files.storage import FileSystemStorage, get_storage_class
  7
+from django.core.files.storage import FileSystemStorage
9 8
 from django.core.management.base import CommandError, NoArgsCommand
10 9
 from django.utils.encoding import smart_str, smart_unicode
11 10
 
12  
-from django.contrib.staticfiles import finders
  11
+from django.contrib.staticfiles import finders, storage
13 12
 
14 13
 
15 14
 class Command(NoArgsCommand):
@@ -18,32 +17,39 @@ class Command(NoArgsCommand):
18 17
     locations to the settings.STATIC_ROOT.
19 18
     """
20 19
     option_list = NoArgsCommand.option_list + (
21  
-        make_option('--noinput', action='store_false', dest='interactive',
22  
-            default=True, help="Do NOT prompt the user for input of any kind."),
  20
+        make_option('--noinput',
  21
+            action='store_false', dest='interactive', default=True,
  22
+            help="Do NOT prompt the user for input of any kind."),
  23
+        make_option('--no-post-process',
  24
+            action='store_false', dest='post_process', default=True,
  25
+            help="Do NOT post process collected files."),
23 26
         make_option('-i', '--ignore', action='append', default=[],
24 27
             dest='ignore_patterns', metavar='PATTERN',
25 28
             help="Ignore files or directories matching this glob-style "
26 29
                 "pattern. Use multiple times to ignore more."),
27  
-        make_option('-n', '--dry-run', action='store_true', dest='dry_run',
28  
-            default=False, help="Do everything except modify the filesystem."),
29  
-        make_option('-c', '--clear', action='store_true', dest='clear',
30  
-            default=False, help="Clear the existing files using the storage "
31  
-                "before trying to copy or link the original file."),
32  
-        make_option('-l', '--link', action='store_true', dest='link',
33  
-            default=False, help="Create a symbolic link to each file instead of copying."),
  30
+        make_option('-n', '--dry-run',
  31
+            action='store_true', dest='dry_run', default=False,
  32
+            help="Do everything except modify the filesystem."),
  33
+        make_option('-c', '--clear',
  34
+            action='store_true', dest='clear', default=False,
  35
+            help="Clear the existing files using the storage "
  36
+                 "before trying to copy or link the original file."),
  37
+        make_option('-l', '--link',
  38
+            action='store_true', dest='link', default=False,
  39
+            help="Create a symbolic link to each file instead of copying."),
34 40
         make_option('--no-default-ignore', action='store_false',
35 41
             dest='use_default_ignore_patterns', default=True,
36 42
             help="Don't ignore the common private glob-style patterns 'CVS', "
37 43
                 "'.*' and '*~'."),
38 44
     )
39  
-    help = "Collect static files from apps and other locations in a single location."
  45
+    help = "Collect static files in a single location."
40 46
 
41 47
     def __init__(self, *args, **kwargs):
42 48
         super(NoArgsCommand, self).__init__(*args, **kwargs)
43 49
         self.copied_files = []
44 50
         self.symlinked_files = []
45 51
         self.unmodified_files = []
46  
-        self.storage = get_storage_class(settings.STATICFILES_STORAGE)()
  52
+        self.storage = storage.staticfiles_storage
47 53
         try:
48 54
             self.storage.path('')
49 55
         except NotImplementedError:
@@ -64,6 +70,7 @@ def handle_noargs(self, **options):
64 70
         self.interactive = options['interactive']
65 71
         self.symlink = options['link']
66 72
         self.verbosity = int(options.get('verbosity', 1))
  73
+        self.post_process = options['post_process']
67 74
 
68 75
         if self.symlink:
69 76
             if sys.platform == 'win32':
@@ -104,9 +111,10 @@ def handle_noargs(self, **options):
104 111
 
105 112
         handler = {
106 113
             True: self.link_file,
107  
-            False: self.copy_file
  114
+            False: self.copy_file,
108 115
         }[self.symlink]
109 116
 
  117
+        found_files = []
110 118
         for finder in finders.get_finders():
111 119
             for path, storage in finder.list(self.ignore_patterns):
112 120
                 # Prefix the relative path if the source storage contains it
@@ -114,19 +122,35 @@ def handle_noargs(self, **options):
114 122
                     prefixed_path = os.path.join(storage.prefix, path)
115 123
                 else:
116 124
                     prefixed_path = path
  125
+                found_files.append(prefixed_path)
117 126
                 handler(path, prefixed_path, storage)
118 127
 
119  
-        actual_count = len(self.copied_files) + len(self.symlinked_files)
  128
+        # Here we check if the storage backend has a post_process
  129
+        # method and pass it the list of modified files.
  130
+        if self.post_process and hasattr(self.storage, 'post_process'):
  131
+            post_processed = self.storage.post_process(found_files, **options)
  132
+            for path in post_processed:
  133
+                self.log(u"Post-processed '%s'" % path, level=1)
  134
+        else:
  135
+            post_processed = []
  136
+
  137
+        modified_files = self.copied_files + self.symlinked_files
  138
+        actual_count = len(modified_files)
120 139
         unmodified_count = len(self.unmodified_files)
  140
+
121 141
         if self.verbosity >= 1:
122  
-            self.stdout.write(smart_str(u"\n%s static file%s %s %s%s.\n"
123  
-                              % (actual_count,
124  
-                                 actual_count != 1 and 's' or '',
125  
-                                 self.symlink and 'symlinked' or 'copied',
126  
-                                 destination_path and "to '%s'"
127  
-                                    % destination_path or '',
128  
-                                 unmodified_count and ' (%s unmodified)'
129  
-                                    % unmodified_count or '')))
  142
+            template = ("\n%(actual_count)s %(identifier)s %(action)s"
  143
+                        "%(destination)s%(unmodified)s.\n")
  144
+            summary = template % {
  145
+                'actual_count': actual_count,
  146
+                'identifier': 'static file' + (actual_count > 1 and 's' or ''),
  147
+                'action': self.symlink and 'symlinked' or 'copied',
  148
+                'destination': (destination_path and " to '%s'"
  149
+                                % destination_path or ''),
  150
+                'unmodified': (self.unmodified_files and ', %s unmodified'
  151
+                               % unmodified_count or ''),
  152
+            }
  153
+            self.stdout.write(smart_str(summary))
130 154
 
131 155
     def log(self, msg, level=2):
132 156
         """
@@ -146,7 +170,8 @@ def clear_dir(self, path):
146 170
         for f in files:
147 171
             fpath = os.path.join(path, f)
148 172
             if self.dry_run:
149  
-                self.log(u"Pretending to delete '%s'" % smart_unicode(fpath), level=1)
  173
+                self.log(u"Pretending to delete '%s'" %
  174
+                         smart_unicode(fpath), level=1)
150 175
             else:
151 176
                 self.log(u"Deleting '%s'" % smart_unicode(fpath), level=1)
152 177
                 self.storage.delete(fpath)
@@ -159,7 +184,8 @@ def delete_file(self, path, prefixed_path, source_storage):
159 184
         if self.storage.exists(prefixed_path):
160 185
             try:
161 186
                 # When was the target file modified last time?
162  
-                target_last_modified = self.storage.modified_time(prefixed_path)
  187
+                target_last_modified = \
  188
+                    self.storage.modified_time(prefixed_path)
163 189
             except (OSError, NotImplementedError):
164 190
                 # The storage doesn't support ``modified_time`` or failed
165 191
                 pass
@@ -177,8 +203,10 @@ def delete_file(self, path, prefixed_path, source_storage):
177 203
                         full_path = None
178 204
                     # Skip the file if the source file is younger
179 205
                     if target_last_modified >= source_last_modified:
180  
-                        if not ((self.symlink and full_path and not os.path.islink(full_path)) or
181  
-                                (not self.symlink and full_path and os.path.islink(full_path))):
  206
+                        if not ((self.symlink and full_path
  207
+                                 and not os.path.islink(full_path)) or
  208
+                                (not self.symlink and full_path
  209
+                                 and os.path.islink(full_path))):
182 210
                             if prefixed_path not in self.unmodified_files:
183 211
                                 self.unmodified_files.append(prefixed_path)
184 212
                             self.log(u"Skipping '%s' (not modified)" % path)
165  django/contrib/staticfiles/storage.py
... ...
@@ -1,10 +1,20 @@
  1
+import hashlib
1 2
 import os
  3
+import posixpath
  4
+import re
  5
+
2 6
 from django.conf import settings
  7
+from django.core.cache import (get_cache, InvalidCacheBackendError,
  8
+                               cache as default_cache)
3 9
 from django.core.exceptions import ImproperlyConfigured
4  
-from django.core.files.storage import FileSystemStorage
  10
+from django.core.files.base import ContentFile
  11
+from django.core.files.storage import FileSystemStorage, get_storage_class
  12
+from django.utils.encoding import force_unicode
  13
+from django.utils.functional import LazyObject
5 14
 from django.utils.importlib import import_module
  15
+from django.utils.datastructures import SortedDict
6 16
 
7  
-from django.contrib.staticfiles import utils
  17
+from django.contrib.staticfiles.utils import check_settings, matches_patterns
8 18
 
9 19
 
10 20
 class StaticFilesStorage(FileSystemStorage):
@@ -26,8 +36,148 @@ def __init__(self, location=None, base_url=None, *args, **kwargs):
26 36
         if base_url is None:
27 37
             raise ImproperlyConfigured("You're using the staticfiles app "
28 38
                 "without having set the STATIC_URL setting.")
29  
-        utils.check_settings()
30  
-        super(StaticFilesStorage, self).__init__(location, base_url, *args, **kwargs)
  39
+        check_settings()
  40
+        super(StaticFilesStorage, self).__init__(location, base_url,
  41
+                                                 *args, **kwargs)
  42
+
  43
+
  44
+class CachedFilesMixin(object):
  45
+    patterns = (
  46
+        ("*.css", (
  47
+            r"""(url\(['"]{0,1}\s*(.*?)["']{0,1}\))""",
  48
+            r"""(@import\s*["']\s*(.*?)["'])""",
  49
+        )),
  50
+    )
  51
+
  52
+    def __init__(self, *args, **kwargs):
  53
+        super(CachedFilesMixin, self).__init__(*args, **kwargs)
  54
+        try:
  55
+            self.cache = get_cache('staticfiles')
  56
+        except InvalidCacheBackendError:
  57
+            # Use the default backend
  58
+            self.cache = default_cache
  59
+        self._patterns = SortedDict()
  60
+        for extension, patterns in self.patterns:
  61
+            for pattern in patterns:
  62
+                compiled = re.compile(pattern)
  63
+                self._patterns.setdefault(extension, []).append(compiled)
  64
+
  65
+    def hashed_name(self, name, content=None):
  66
+        if content is None:
  67
+            if not self.exists(name):
  68
+                raise ValueError("The file '%s' could not be found with %r." %
  69
+                                 (name, self))
  70
+            try:
  71
+                content = self.open(name)
  72
+            except IOError:
  73
+                # Handle directory paths
  74
+                return name
  75
+        path, filename = os.path.split(name)
  76
+        root, ext = os.path.splitext(filename)
  77
+        # Get the MD5 hash of the file
  78
+        md5 = hashlib.md5()
  79
+        for chunk in content.chunks():
  80
+            md5.update(chunk)
  81
+        md5sum = md5.hexdigest()[:12]
  82
+        return os.path.join(path, u"%s.%s%s" % (root, md5sum, ext))
  83
+
  84
+    def cache_key(self, name):
  85
+        return u'staticfiles:cache:%s' % name
  86
+
  87
+    def url(self, name, force=False):
  88
+        """
  89
+        Returns the real URL in DEBUG mode.
  90
+        """
  91
+        if settings.DEBUG and not force:
  92
+            return super(CachedFilesMixin, self).url(name)
  93
+        cache_key = self.cache_key(name)
  94
+        hashed_name = self.cache.get(cache_key)
  95
+        if hashed_name is None:
  96
+            hashed_name = self.hashed_name(name)
  97
+        return super(CachedFilesMixin, self).url(hashed_name)
  98
+
  99
+    def url_converter(self, name):
  100
+        """
  101
+        Returns the custom URL converter for the given file name.
  102
+        """
  103
+        def converter(matchobj):
  104
+            """
  105
+            Converts the matched URL depending on the parent level (`..`)
  106
+            and returns the normalized and hashed URL using the url method
  107
+            of the storage.
  108
+            """
  109
+            matched, url = matchobj.groups()
  110
+            # Completely ignore http(s) prefixed URLs
  111
+            if url.startswith(('http', 'https')):
  112
+                return matched
  113
+            name_parts = name.split('/')
  114
+            # Using posix normpath here to remove duplicates
  115
+            result = url_parts = posixpath.normpath(url).split('/')
  116
+            level = url.count('..')
  117
+            if level:
  118
+                result = name_parts[:-level - 1] + url_parts[level:]
  119
+            elif name_parts[:-1]:
  120
+                result = name_parts[:-1] + url_parts[-1:]
  121
+            joined_result = '/'.join(result)
  122
+            hashed_url = self.url(joined_result, force=True)
  123
+            # Return the hashed and normalized version to the file
  124
+            return 'url("%s")' % hashed_url
  125
+        return converter
  126
+
  127
+    def post_process(self, paths, dry_run=False, **options):
  128
+        """
  129
+        Post process the given list of files (called from collectstatic).
  130
+        """
  131
+        processed_files = []
  132
+        # don't even dare to process the files if we're in dry run mode
  133
+        if dry_run:
  134
+            return processed_files
  135
+
  136
+        # delete cache of all handled paths
  137
+        self.cache.delete_many([self.cache_key(path) for path in paths])
  138
+
  139
+        # only try processing the files we have patterns for
  140
+        matches = lambda path: matches_patterns(path, self._patterns.keys())
  141
+        processing_paths = [path for path in paths if matches(path)]
  142
+
  143
+        # then sort the files by the directory level
  144
+        path_level = lambda name: len(name.split(os.sep))
  145
+        for name in sorted(paths, key=path_level, reverse=True):
  146
+
  147
+            # first get a hashed name for the given file
  148
+            hashed_name = self.hashed_name(name)
  149
+
  150
+            with self.open(name) as original_file:
  151
+                # then get the original's file content
  152
+                content = original_file.read()
  153
+
  154
+                # to apply each replacement pattern on the content
  155
+                if name in processing_paths:
  156
+                    converter = self.url_converter(name)
  157
+                    for patterns in self._patterns.values():
  158
+                        for pattern in patterns:
  159
+                            content = pattern.sub(converter, content)
  160
+
  161
+                # then save the processed result
  162
+                if self.exists(hashed_name):
  163
+                    self.delete(hashed_name)
  164
+
  165
+                saved_name = self._save(hashed_name, ContentFile(content))
  166
+                hashed_name = force_unicode(saved_name.replace('\\', '/'))
  167
+                processed_files.append(hashed_name)
  168
+
  169
+                # and then set the cache accordingly
  170
+                self.cache.set(self.cache_key(name), hashed_name)
  171
+
  172
+        return processed_files
  173
+
  174
+
  175
+class CachedStaticFilesStorage(CachedFilesMixin, StaticFilesStorage):
  176
+    """
  177
+    A static file system storage backend which also saves
  178
+    hashed copies of the files it saves.
  179
+    """
  180
+    pass
31 181
 
32 182
 
33 183
 class AppStaticStorage(FileSystemStorage):
@@ -47,3 +197,10 @@ def __init__(self, app, *args, **kwargs):
47 197
         mod_path = os.path.dirname(mod.__file__)
48 198
         location = os.path.join(mod_path, self.source_dir)
49 199
         super(AppStaticStorage, self).__init__(location, *args, **kwargs)
  200
+
  201
+
  202
+class ConfiguredStorage(LazyObject):
  203
+    def _setup(self):
  204
+        self._wrapped = get_storage_class(settings.STATICFILES_STORAGE)()
  205
+
  206
+staticfiles_storage = ConfiguredStorage()
13  django/contrib/staticfiles/templatetags/staticfiles.py
... ...
@@ -0,0 +1,13 @@
  1
+from django import template
  2
+from django.contrib.staticfiles.storage import staticfiles_storage
  3
+
  4
+register = template.Library()
  5
+
  6
+
  7
+@register.simple_tag
  8
+def static(path):
  9
+    """
  10
+    A template tag that returns the URL to a file
  11
+    using staticfiles' storage backend
  12
+    """
  13
+    return staticfiles_storage.url(path)
14  django/contrib/staticfiles/utils.py
@@ -3,30 +3,34 @@
3 3
 from django.conf import settings
4 4
 from django.core.exceptions import ImproperlyConfigured
5 5
 
6  
-def is_ignored(path, ignore_patterns=[]):
  6
+def matches_patterns(path, patterns=None):
7 7
     """
8 8
     Return True or False depending on whether the ``path`` should be
9 9
     ignored (if it matches any pattern in ``ignore_patterns``).
10 10
     """
11  
-    for pattern in ignore_patterns:
  11
+    if patterns is None:
  12
+        patterns = []
  13
+    for pattern in patterns:
12 14
         if fnmatch.fnmatchcase(path, pattern):
13 15
             return True
14 16
     return False
15 17
 
16  
-def get_files(storage, ignore_patterns=[], location=''):
  18
+def get_files(storage, ignore_patterns=None, location=''):
17 19
     """
18 20
     Recursively walk the storage directories yielding the paths
19 21
     of all files that should be copied.
20 22
     """
  23
+    if ignore_patterns is None:
  24
+        ignore_patterns = []
21 25
     directories, files = storage.listdir(location)
22 26
     for fn in files:
23  
-        if is_ignored(fn, ignore_patterns):
  27
+        if matches_patterns(fn, ignore_patterns):
24 28
             continue
25 29
         if location:
26 30
             fn = os.path.join(location, fn)
27 31
         yield fn
28 32
     for dir in directories:
29  
-        if is_ignored(dir, ignore_patterns):
  33
+        if matches_patterns(dir, ignore_patterns):
30 34
             continue
31 35
         if location:
32 36
             dir = os.path.join(location, dir)
44  docs/howto/static-files.txt
@@ -70,7 +70,7 @@ Basic usage
70 70
 
71 71
        <img src="{{ STATIC_URL }}images/hi.jpg" />
72 72
 
73  
-   See :ref:`staticfiles-in-templates` for more details, including an
  73
+   See :ref:`staticfiles-in-templates` for more details, **including** an
74 74
    alternate method using a template tag.
75 75
 
76 76
 Deploying static files in a nutshell
@@ -143,7 +143,7 @@ A far better way is to use the value of the :setting:`STATIC_URL` setting
143 143
 directly in your templates. This means that a switch of static files servers
144 144
 only requires changing that single value. Much better!
145 145
 
146  
-``staticfiles`` includes two built-in ways of getting at this setting in your
  146
+Django includes multiple built-in ways of using this setting in your
147 147
 templates: a context processor and a template tag.
148 148
 
149 149
 With a context processor
@@ -180,14 +180,19 @@ but in views written by hand you'll need to explicitly use ``RequestContext``
180 180
 To see how that works, and to read more details, check out
181 181
 :ref:`subclassing-context-requestcontext`.
182 182
 
  183
+Another option is the :ttag:`get_static_prefix` template tag that is part of
  184
+Django's core.
  185
+
183 186
 With a template tag
184 187
 -------------------
185 188
 
186  
-To easily link to static files Django ships with a :ttag:`static` template tag.
  189
+The more powerful tool is the :ttag:`static<staticfiles-static>` template
  190
+tag. It builds the URL for the given relative path by using the configured
  191
+:setting:`STATICFILES_STORAGE` storage.
187 192
 
188 193
 .. code-block:: html+django
189 194
 
190  
-    {% load static %}
  195
+    {% load staticfiles %}
191 196
     <img src="{% static "images/hi.jpg" %}" />
192 197
 
193 198
 It is also able to consume standard context variables, e.g. assuming a
@@ -195,30 +200,21 @@ It is also able to consume standard context variables, e.g. assuming a
195 200
 
196 201
 .. code-block:: html+django
197 202
 
198  
-    {% load static %}
  203
+    {% load staticfiles %}
199 204
     <link rel="stylesheet" href="{% static user_stylesheet %}" type="text/css" media="screen" />
200 205
 
201  
-Another option is the :ttag:`get_static_prefix` template tag. You can use
202  
-this if you're not using :class:`~django.template.RequestContext` (and
203  
-therefore not relying on the ``django.core.context_processors.static``
204  
-context processor), or if you need more control over exactly where and how
205  
-:setting:`STATIC_URL` is injected into the template. Here's an example:
206  
-
207  
-.. code-block:: html+django
208  
-
209  
-    {% load static %}
210  
-    <img src="{% get_static_prefix %}images/hi.jpg" />
211  
-
212  
-There's also a second form you can use to avoid extra processing if you need
213  
-the value multiple times:
214  
-
215  
-.. code-block:: html+django
  206
+.. note::
216 207
 
217  
-    {% load static %}
218  
-    {% get_static_prefix as STATIC_PREFIX %}
  208
+    There is also a template tag named :ttag:`static` in Django's core set
  209
+    of :ref:`built in template tags<ref-templates-builtins-tags>` which has
  210
+    the same argument signature but only uses `urlparse.urljoin()`_ with the
  211
+    :setting:`STATIC_URL` setting and the given path. This has the
  212
+    disadvantage of not being able to easily switch the storage backend
  213
+    without changing the templates, so in doubt use the ``staticfiles``
  214
+    :ttag:`static<staticfiles-static>`
  215
+    template tag.
219 216
 
220  
-    <img src="{{ STATIC_PREFIX }}images/hi.jpg" />
221  
-    <img src="{{ STATIC_PREFIX }}images/hi2.jpg" />
  217
+.. _`urlparse.urljoin()`: http://docs.python.org/library/urlparse.html#urlparse.urljoin
222 218
 
223 219
 .. _staticfiles-development:
224 220
 
143  docs/ref/contrib/staticfiles.txt
@@ -68,7 +68,9 @@ in a ``'downloads'`` subdirectory of :setting:`STATIC_ROOT`.
68 68
 
69 69
 This would allow you to refer to the local file
70 70
 ``'/opt/webfiles/stats/polls_20101022.tar.gz'`` with
71  
-``'/static/downloads/polls_20101022.tar.gz'`` in your templates, e.g.::
  71
+``'/static/downloads/polls_20101022.tar.gz'`` in your templates, e.g.:
  72
+
  73
+.. code-block:: html+django
72 74
 
73 75
     <a href="{{ STATIC_URL }}downloads/polls_20101022.tar.gz">
74 76
 
@@ -82,6 +84,11 @@ Default: ``'django.contrib.staticfiles.storage.StaticFilesStorage'``
82 84
 The file storage engine to use when collecting static files with the
83 85
 :djadmin:`collectstatic` management command.
84 86
 
  87
+.. versionadded:: 1.4
  88
+
  89
+A ready-to-use instance of the storage backend defined in this setting
  90
+can be found at ``django.contrib.staticfiles.storage.staticfiles_storage``.
  91
+
85 92
 For an example, see :ref:`staticfiles-from-cdn`.
86 93
 
87 94
 .. setting:: STATICFILES_FINDERS
@@ -141,6 +148,16 @@ Files are searched by using the :setting:`enabled finders
141 148
 :setting:`STATICFILES_DIRS` and in the ``'static'`` directory of apps
142 149
 specified by the :setting:`INSTALLED_APPS` setting.
143 150
 
  151
+.. versionadded:: 1.4
  152
+
  153
+The :djadmin:`collectstatic` management command calls the
  154
+:meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process`
  155
+method of the :setting:`STATICFILES_STORAGE` after each run and passes
  156
+a list of paths that have been found by the management command. It also
  157
+receives all command line options of :djadmin:`collectstatic`. This is used
  158
+by the :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage`
  159
+by default.
  160
+
144 161
 Some commonly used options are:
145 162
 
146 163
 .. django-admin-option:: --noinput
@@ -169,6 +186,13 @@ Some commonly used options are:
169 186
 
170 187
     Create a symbolic link to each file instead of copying.
171 188
 
  189
+.. django-admin-option:: --no-post-process
  190
+.. versionadded:: 1.4
  191
+
  192
+    Don't call the
  193
+    :meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process`
  194
+    method of the configured :setting:`STATICFILES_STORAGE` storage backend.
  195
+
172 196
 .. django-admin-option:: --no-default-ignore
173 197
 
174 198
     Don't ignore the common private glob-style patterns ``'CVS'``, ``'.*'``
@@ -237,7 +261,120 @@ Example usage::
237 261
 
238 262
     django-admin.py runserver --insecure
239 263
 
240  
-.. currentmodule:: None
  264
+Storages
  265
+========
  266
+
  267
+StaticFilesStorage
  268
+------------------
  269
+
  270
+.. class:: storage.StaticFilesStorage
  271
+
  272
+    A subclass of the :class:`~django.core.files.storage.FileSystemStorage`
  273
+    storage backend that uses the :setting:`STATIC_ROOT` setting as the base
  274
+    file system location and the :setting:`STATIC_URL` setting respectively
  275
+    as the base URL.
  276
+
  277
+    .. method:: post_process(paths, **options)
  278
+
  279
+    .. versionadded:: 1.4
  280
+
  281
+    This method is called by the :djadmin:`collectstatic` management command
  282
+    after each run and gets passed the paths of found files, as well as the
  283
+    command line options.
  284
+
  285
+    The :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage`
  286
+    uses this behind the scenes to replace the paths with their hashed
  287
+    counterparts and update the cache appropriately.
  288
+
  289
+CachedStaticFilesStorage
  290
+------------------------
  291
+
  292
+.. class:: storage.CachedStaticFilesStorage
  293
+
  294
+    .. versionadded:: 1.4
  295
+
  296
+    A subclass of the :class:`~django.contrib.staticfiles.storage.StaticFilesStorage`
  297
+    storage backend which caches the files it saves by appending the MD5 hash
  298
+    of the file's content to the filename. For example, the file
  299
+    ``css/styles.css`` would also be saved as ``css/styles.55e7cbb9ba48.css``.
  300
+
  301
+    The purpose of this storage is to keep serving the old files in case some
  302
+    pages still refer to those files, e.g. because they are cached by you or
  303
+    a 3rd party proxy server. Additionally, it's very helpful if you want to
  304
+    apply `far future Expires headers`_ to the deployed files to speed up the
  305
+    load time for subsequent page visits.
  306
+
  307
+    The storage backend automatically replaces the paths found in the saved
  308
+    files matching other saved files with the path of the cached copy (using
  309
+    the :meth:`~django.contrib.staticfiles.storage.StaticFilesStorage.post_process`
  310
+    method). The regular expressions used to find those paths
  311
+    (``django.contrib.staticfiles.storage.CachedStaticFilesStorage.cached_patterns``)
  312
+    by default cover the `@import`_ rule and `url()`_ statement of `Cascading
  313
+    Style Sheets`_. For example, the ``'css/styles.css'`` file with the
  314
+    content
  315
+
  316
+    .. code-block:: css+django
  317
+
  318
+        @import url("../admin/css/base.css");
  319
+
  320
+    would be replaced by calling the
  321
+    :meth:`~django.core.files.storage.Storage.url`
  322
+    method of the ``CachedStaticFilesStorage`` storage backend, ultimatively
  323
+    saving a ``'css/styles.55e7cbb9ba48.css'`` file with the following
  324
+    content:
  325
+
  326
+    .. code-block:: css+django
  327
+
  328
+        @import url("/static/admin/css/base.27e20196a850.css");
  329
+
  330
+    To enable the ``CachedStaticFilesStorage`` you have to make sure the
  331
+    following requirements are met:
  332
+
  333
+    * the :setting:`STATICFILES_STORAGE` setting is set to
  334
+      ``'django.contrib.staticfiles.storage.CachedStaticFilesStorage'``
  335
+    * the :setting:`DEBUG` setting is set to ``False``
  336
+    * you use the ``staticfiles`` :ttag:`static<staticfiles-static>` template
  337
+      tag to refer to your static files in your templates
  338
+    * you've collected all your static files by using the
  339
+      :djadmin:`collectstatic` management command
  340
+
  341
+    Since creating the MD5 hash can be a performance burden to your website
  342
+    during runtime, ``staticfiles`` will automatically try to cache the
  343
+    hashed name for each file path using Django's :doc:`caching
  344
+    framework</topics/cache>`. If you want to override certain options of the
  345
+    cache backend the storage uses, simply specify a custom entry in the
  346
+    :setting:`CACHES` setting named ``'staticfiles'``. It falls back to using
  347
+    the ``'default'`` cache backend.
  348
+
  349
+.. _`far future Expires headers`: http://developer.yahoo.com/performance/rules.html#expires
  350
+.. _`@import`: http://www.w3.org/TR/CSS2/cascade.html#at-import
  351
+.. _`url()`: http://www.w3.org/TR/CSS2/syndata.html#uri
  352
+.. _`Cascading Style Sheets`: http://www.w3.org/Style/CSS/
  353
+
  354
+.. currentmodule:: django.contrib.staticfiles.templatetags.staticfiles
  355
+
  356
+Template tags
  357
+=============
  358
+
  359
+static
  360
+------
  361
+
  362
+.. templatetag:: staticfiles-static
  363
+
  364
+.. versionadded:: 1.4
  365
+
  366
+Uses the configued :setting:`STATICFILES_STORAGE` storage to create the
  367
+full URL for the given relative path, e.g.:
  368
+
  369
+.. code-block:: html+django
  370
+
  371
+    {% load static from staticfiles %}
  372
+    <img src="{% static "css/base.css" %}" />
  373
+
  374
+The previous example is equal to calling the ``url`` method of an instance of
  375
+:setting:`STATICFILES_STORAGE` with ``"css/base.css"``. This is especially
  376
+useful when using a non-local storage backend to deploy files as documented
  377
+in :ref:`staticfiles-from-cdn`.
241 378
 
242 379
 Other Helpers
243 380
 =============
@@ -251,7 +388,7 @@ files:
251 388
       with :class:`~django.template.RequestContext` contexts.
252 389
 
253 390
     - The builtin template tag :ttag:`static` which takes a path and
254  
-      joins it with the the static prefix :setting:`STATIC_URL`.
  391
+      urljoins it with the static prefix :setting:`STATIC_URL`.
255 392
 
256 393
     - The builtin template tag :ttag:`get_static_prefix` which populates a
257 394
       template variable with the static prefix :setting:`STATIC_URL` to be
17  docs/ref/templates/builtins.txt
@@ -2353,9 +2353,9 @@ static
2353 2353
 
2354 2354
 .. highlight:: html+django
2355 2355
 
2356  
-To link to static files Django ships with a :ttag:`static` template tag. You
2357  
-can use this regardless if you're using :class:`~django.template.RequestContext`
2358  
-or not.
  2356
+To link to static files that are saved in :setting:`STATIC_ROOT` Django ships
  2357
+with a :ttag:`static` template tag. You can use this regardless if you're
  2358
+using :class:`~django.template.RequestContext` or not.
2359 2359
 
2360 2360
 .. code-block:: html+django
2361 2361
 
@@ -2370,6 +2370,17 @@ It is also able to consume standard context variables, e.g. assuming a
2370 2370
     {% load static %}
2371 2371
     <link rel="stylesheet" href="{% static user_stylesheet %}" type="text/css" media="screen" />
2372 2372
 
  2373
+.. note::
  2374
+
  2375
+    The :mod:`staticfiles<django.contrib.staticfiles>` contrib app also ships
  2376
+    with a :ttag:`static template tag<staticfiles-static>` which uses
  2377
+    ``staticfiles'`` :setting:`STATICFILES_STORAGE` to build the URL of the
  2378
+    given path. Use that instead if you have an advanced use case such as
  2379
+    :ref:`using a cloud service to serve static files<staticfiles-from-cdn>`::
  2380
+
  2381
+        {% load static from staticfiles %}
  2382
+        <img src="{% static "images/hi.jpg" %}" />
  2383
+
2373 2384
 .. templatetag:: get_static_prefix
2374 2385
 
2375 2386
 get_static_prefix
23  docs/releases/1.4.txt
@@ -212,6 +212,29 @@ Additionally, it's now possible to define translatable URL patterns using
212 212
 :ref:`url-internationalization` for more information about the language prefix
213 213
 and how to internationalize URL patterns.
214 214
 
  215
+``static`` template tag
  216
+~~~~~~~~~~~~~~~~~~~~~~~
  217
+
  218
+The :mod:`staticfiles<django.contrib.staticfiles>` contrib app has now a new
  219
+:ttag:`static template tag<staticfiles-static>` to refer to files saved with
  220
+the :setting:`STATICFILES_STORAGE` storage backend. It'll use the storage
  221
+``url`` method and therefore supports advanced features such as
  222
+:ref:`serving files from a cloud service<staticfiles-from-cdn>`.
  223
+
  224
+``CachedStaticFilesStorage`` storage backend
  225
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  226
+
  227
+Additional to the `static template tag`_ the
  228
+:mod:`staticfiles<django.contrib.staticfiles>` contrib app now has a
  229
+:class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage` which
  230
+caches the files it saves (when running the :djadmin:`collectstatic`
  231
+management command) by appending the MD5 hash of the file's content to the
  232
+filename. For example, the file ``css/styles.css`` would also be saved as
  233
+``css/styles.55e7cbb9ba48.css``
  234
+
  235
+See the :class:`~django.contrib.staticfiles.storage.CachedStaticFilesStorage`
  236
+docs for more information.
  237
+
215 238
 Minor features
216 239
 ~~~~~~~~~~~~~~
217 240
 
0  staticfiles/templatetags/__init__.py b/django/contrib/staticfiles/templatetags/__init__.py
No changes.
1  tests/regressiontests/staticfiles_tests/project/documents/cached/absolute.css
... ...
@@ -0,0 +1 @@
  1
+@import url("/static/cached/styles.css");
1  tests/regressiontests/staticfiles_tests/project/documents/cached/denorm.css
... ...
@@ -0,0 +1 @@
  1
+@import url("..//cached///styles.css");
0  tests/regressiontests/staticfiles_tests/project/documents/cached/other.css
No changes.
2  tests/regressiontests/staticfiles_tests/project/documents/cached/relative.css
... ...
@@ -0,0 +1,2 @@
  1
+@import url("../cached/styles.css");
  2
+@import url("absolute.css");
1  tests/regressiontests/staticfiles_tests/project/documents/cached/styles.css
... ...
@@ -0,0 +1 @@
  1
+@import url("cached/other.css");
1  tests/regressiontests/staticfiles_tests/project/documents/cached/url.css
... ...
@@ -0,0 +1 @@