瀏覽代碼

Fixed #13110 -- Added support for multiple enclosures in Atom feeds.

The ``item_enclosures`` hook returns a list of ``Enclosure`` objects which is
then used by the feed builder. If the feed is a RSS feed, an exception is
raised as RSS feeds don't allow multiple enclosures per feed item.

The ``item_enclosures`` hook defaults to an empty list or, if the
``item_enclosure_url`` hook is defined, to a list with a single ``Enclosure``
built from the ``item_enclosure_url``, ``item_enclosure_length``, and
``item_enclosure_mime_type`` hooks.
Unai Zalakain 9 年之前
父節點
當前提交
aac2a2d2ae

+ 13 - 9
django/contrib/syndication/views.py

@@ -64,6 +64,17 @@ class Feed(object):
                 'item_link() method in your Feed class.' % item.__class__.__name__
             )
 
+    def item_enclosures(self, item):
+        enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
+        if enc_url:
+            enc = feedgenerator.Enclosure(
+                url=smart_text(enc_url),
+                length=smart_text(self.__get_dynamic_attr('item_enclosure_length', item)),
+                mime_type=smart_text(self.__get_dynamic_attr('item_enclosure_mime_type', item)),
+            )
+            return [enc]
+        return []
+
     def __get_dynamic_attr(self, attname, obj, default=None):
         try:
             attr = getattr(self, attname)
@@ -171,14 +182,7 @@ class Feed(object):
                 self.__get_dynamic_attr('item_link', item),
                 request.is_secure(),
             )
-            enc = None
-            enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
-            if enc_url:
-                enc = feedgenerator.Enclosure(
-                    url=smart_text(enc_url),
-                    length=smart_text(self.__get_dynamic_attr('item_enclosure_length', item)),
-                    mime_type=smart_text(self.__get_dynamic_attr('item_enclosure_mime_type', item))
-                )
+            enclosures = self.__get_dynamic_attr('item_enclosures', item)
             author_name = self.__get_dynamic_attr('item_author_name', item)
             if author_name is not None:
                 author_email = self.__get_dynamic_attr('item_author_email', item)
@@ -203,7 +207,7 @@ class Feed(object):
                 unique_id=self.__get_dynamic_attr('item_guid', item, link),
                 unique_id_is_permalink=self.__get_dynamic_attr(
                     'item_guid_is_permalink', item),
-                enclosure=enc,
+                enclosures=enclosures,
                 pubdate=pubdate,
                 updateddate=updateddate,
                 author_name=author_name,

+ 37 - 15
django/utils/feedgenerator.py

@@ -118,11 +118,13 @@ class SyndicationFeed(object):
     def add_item(self, title, link, description, author_email=None,
             author_name=None, author_link=None, pubdate=None, comments=None,
             unique_id=None, unique_id_is_permalink=None, enclosure=None,
-            categories=(), item_copyright=None, ttl=None, updateddate=None, **kwargs):
+            categories=(), item_copyright=None, ttl=None, updateddate=None,
+            enclosures=None, **kwargs):
         """
         Adds an item to the feed. All args are expected to be Python Unicode
         objects except pubdate and updateddate, which are datetime.datetime
-        objects, and enclosure, which is an instance of the Enclosure class.
+        objects, and enclosures, which is an iterable of instances of the
+        Enclosure class.
         """
         to_unicode = lambda s: force_text(s, strings_only=True)
         if categories:
@@ -130,6 +132,16 @@ class SyndicationFeed(object):
         if ttl is not None:
             # Force ints to unicode
             ttl = force_text(ttl)
+        if enclosure is None:
+            enclosures = [] if enclosures is None else enclosures
+        else:
+            warnings.warn(
+                "The enclosure keyword argument is deprecated, "
+                "use enclosures instead.",
+                RemovedInDjango20Warning,
+                stacklevel=2,
+            )
+            enclosures = [enclosure]
         item = {
             'title': to_unicode(title),
             'link': iri_to_uri(link),
@@ -142,7 +154,7 @@ class SyndicationFeed(object):
             'comments': to_unicode(comments),
             'unique_id': to_unicode(unique_id),
             'unique_id_is_permalink': unique_id_is_permalink,
-            'enclosure': enclosure,
+            'enclosures': enclosures,
             'categories': categories or (),
             'item_copyright': to_unicode(item_copyright),
             'ttl': ttl,
@@ -317,10 +329,19 @@ class Rss201rev2Feed(RssFeed):
             handler.addQuickElement("ttl", item['ttl'])
 
         # Enclosure.
-        if item['enclosure'] is not None:
-            handler.addQuickElement("enclosure", '',
-                {"url": item['enclosure'].url, "length": item['enclosure'].length,
-                    "type": item['enclosure'].mime_type})
+        if item['enclosures']:
+            enclosures = list(item['enclosures'])
+            if len(enclosures) > 1:
+                raise ValueError(
+                    "RSS feed items may only have one enclosure, see "
+                    "http://www.rssboard.org/rss-profile#element-channel-item-enclosure"
+                )
+            enclosure = enclosures[0]
+            handler.addQuickElement('enclosure', '', {
+                'url': enclosure.url,
+                'length': enclosure.length,
+                'type': enclosure.mime_type,
+            })
 
         # Categories.
         for cat in item['categories']:
@@ -328,7 +349,7 @@ class Rss201rev2Feed(RssFeed):
 
 
 class Atom1Feed(SyndicationFeed):
-    # Spec: http://atompub.org/2005/07/11/draft-ietf-atompub-format-10.html
+    # Spec: https://tools.ietf.org/html/rfc4287
     content_type = 'application/atom+xml; charset=utf-8'
     ns = "http://www.w3.org/2005/Atom"
 
@@ -405,13 +426,14 @@ class Atom1Feed(SyndicationFeed):
         if item['description'] is not None:
             handler.addQuickElement("summary", item['description'], {"type": "html"})
 
-        # Enclosure.
-        if item['enclosure'] is not None:
-            handler.addQuickElement("link", '',
-                {"rel": "enclosure",
-                 "href": item['enclosure'].url,
-                 "length": item['enclosure'].length,
-                 "type": item['enclosure'].mime_type})
+        # Enclosures.
+        for enclosure in item.get('enclosures') or []:
+            handler.addQuickElement('link', '', {
+                'rel': 'enclosure',
+                'href': enclosure.url,
+                'length': enclosure.length,
+                'type': enclosure.mime_type,
+            })
 
         # Categories.
         for cat in item['categories']:

+ 3 - 0
docs/internals/deprecation.txt

@@ -94,6 +94,9 @@ details on these changes.
 * The ``callable_obj`` keyword argument to
   ``SimpleTestCase.assertRaisesMessage()`` will be removed.
 
+* The ``enclosure`` keyword argument to ``SyndicationFeed.add_item()`` will be
+  removed.
+
 .. _deprecation-removed-in-1.10:
 
 1.10

+ 42 - 6
docs/ref/contrib/syndication.txt

@@ -298,10 +298,16 @@ Enclosures
 ----------
 
 To specify enclosures, such as those used in creating podcast feeds, use the
-``item_enclosure_url``, ``item_enclosure_length`` and
+``item_enclosures`` hook or, alternatively and if you only have a single
+enclosure per item, the ``item_enclosure_url``, ``item_enclosure_length``, and
 ``item_enclosure_mime_type`` hooks. See the ``ExampleFeed`` class below for
 usage examples.
 
+.. versionchanged:: 1.9
+
+    Support for multiple enclosures per feed item was added through the
+    ``item_enclosures`` hook.
+
 Language
 --------
 
@@ -742,8 +748,28 @@ This example illustrates all possible attributes and methods for a
 
         item_author_link = 'http://www.example.com/' # Hard-coded author URL.
 
+        # ITEM ENCLOSURES -- One of the following three is optional. The
+        # framework looks for them in this order. If one of them is defined,
+        # ``item_enclosure_url``, ``item_enclosure_length``, and
+        # ``item_enclosure_mime_type`` will have no effect.
+
+        def item_enclosures(self, item):
+            """
+            Takes an item, as returned by items(), and returns a list of
+            ``django.utils.feedgenerator.Enclosure`` objects.
+            """
+
+        def item_enclosure_url(self):
+            """
+            Returns the ``django.utils.feedgenerator.Enclosure`` list for every
+            item in the feed.
+            """
+
+        item_enclosures = []  # Hard-coded enclosure list
+
         # ITEM ENCLOSURE URL -- One of these three is required if you're
-        # publishing enclosures. The framework looks for them in this order.
+        # publishing enclosures and you're not using ``item_enclosures``. The
+        # framework looks for them in this order.
 
         def item_enclosure_url(self, item):
             """
@@ -759,9 +785,10 @@ This example illustrates all possible attributes and methods for a
         item_enclosure_url = "/foo/bar.mp3" # Hard-coded enclosure link.
 
         # ITEM ENCLOSURE LENGTH -- One of these three is required if you're
-        # publishing enclosures. The framework looks for them in this order.
-        # In each case, the returned value should be either an integer, or a
-        # string representation of the integer, in bytes.
+        # publishing enclosures and you're not using ``item_enclosures``. The
+        # framework looks for them in this order. In each case, the returned
+        # value should be either an integer, or a string representation of the
+        # integer, in bytes.
 
         def item_enclosure_length(self, item):
             """
@@ -777,7 +804,8 @@ This example illustrates all possible attributes and methods for a
         item_enclosure_length = 32000 # Hard-coded enclosure length.
 
         # ITEM ENCLOSURE MIME TYPE -- One of these three is required if you're
-        # publishing enclosures. The framework looks for them in this order.
+        # publishing enclosures and you're not using ``item_enclosures``. The
+        # framework looks for them in this order.
 
         def item_enclosure_mime_type(self, item):
             """
@@ -941,6 +969,7 @@ They share this interface:
     * ``comments``
     * ``unique_id``
     * ``enclosure``
+    * ``enclosures``
     * ``categories``
     * ``item_copyright``
     * ``ttl``
@@ -954,8 +983,15 @@ They share this interface:
     * ``updateddate`` should be a Python  :class:`~datetime.datetime` object.
     * ``enclosure`` should be an instance of
       :class:`django.utils.feedgenerator.Enclosure`.
+    * ``enclosures`` should be a list of
+      :class:`django.utils.feedgenerator.Enclosure` instances.
     * ``categories`` should be a sequence of Unicode objects.
 
+    .. deprecated:: 1.9
+
+        The ``enclosure`` keyword argument is deprecated in favor of the
+        ``enclosures`` keyword argument.
+
 :meth:`.SyndicationFeed.write`
     Outputs the feed in the given encoding to outfile, which is a file-like object.
 

+ 9 - 2
docs/ref/utils.txt

@@ -351,11 +351,18 @@ SyndicationFeed
         All parameters should be Unicode objects, except ``categories``, which
         should be a sequence of Unicode objects.
 
-    .. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, enclosure=None, categories=(), item_copyright=None, ttl=None, updateddate=None, **kwargs)
+    .. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, enclosure=None, categories=(), item_copyright=None, ttl=None, updateddate=None, enclosures=None, **kwargs)
 
         Adds an item to the feed. All args are expected to be Python ``unicode``
         objects except ``pubdate`` and ``updateddate``, which are ``datetime.datetime``
-        objects, and ``enclosure``, which is an instance of the ``Enclosure`` class.
+        objects, ``enclosure``, which is an ``Enclosure`` instance, and
+        ``enclosures``, which is a list of ``Enclosure`` instances.
+
+        .. deprecated:: 1.9
+
+            The ``enclosure`` keyword argument is deprecated in favor of the
+            new ``enclosures`` keyword argument which accepts a list of
+            ``Enclosure`` objects.
 
     .. method:: num_items()
 

+ 7 - 1
docs/releases/1.9.txt

@@ -303,7 +303,9 @@ Minor features
 :mod:`django.contrib.syndication`
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-* ...
+* Support for multiple enclosures per feed item has been added. If multiple
+  enclosures are defined on a RSS feed, an exception is raised as RSS feeds,
+  unlike Atom feeds, do not support multiple enclosures per feed item.
 
 Cache
 ^^^^^
@@ -1265,6 +1267,10 @@ Miscellaneous
   :func:`~django.utils.safestring.mark_safe` when constructing the method's
   return value instead.
 
+* The ``enclosure`` keyword argument to ``SyndicationFeed.add_item()`` is
+  deprecated. Use the new ``enclosures`` argument which accepts a list of
+  ``Enclosure`` objects instead of a single one.
+
 .. removed-features-1.9:
 
 Features removed in 1.9

+ 48 - 2
tests/syndication_tests/feeds.py

@@ -88,8 +88,29 @@ class ArticlesFeed(TestRss2Feed):
         return Article.objects.all()
 
 
-class TestEnclosureFeed(TestRss2Feed):
-    pass
+class TestSingleEnclosureRSSFeed(TestRss2Feed):
+    """
+    A feed to test that RSS feeds work with a single enclosure.
+    """
+    def item_enclosure_url(self, item):
+        return 'http://example.com'
+
+    def item_enclosure_size(self, item):
+        return 0
+
+    def item_mime_type(self, item):
+        return 'image/png'
+
+
+class TestMultipleEnclosureRSSFeed(TestRss2Feed):
+    """
+    A feed to test that RSS feeds raise an exception with multiple enclosures.
+    """
+    def item_enclosures(self, item):
+        return [
+            feedgenerator.Enclosure('http://example.com/hello.png', 0, 'image/png'),
+            feedgenerator.Enclosure('http://example.com/goodbye.png', 0, 'image/png'),
+        ]
 
 
 class TemplateFeed(TestRss2Feed):
@@ -165,3 +186,28 @@ class MyCustomAtom1Feed(feedgenerator.Atom1Feed):
 
 class TestCustomFeed(TestAtomFeed):
     feed_type = MyCustomAtom1Feed
+
+
+class TestSingleEnclosureAtomFeed(TestAtomFeed):
+    """
+    A feed to test that Atom feeds work with a single enclosure.
+    """
+    def item_enclosure_url(self, item):
+        return 'http://example.com'
+
+    def item_enclosure_size(self, item):
+        return 0
+
+    def item_mime_type(self, item):
+        return 'image/png'
+
+
+class TestMultipleEnclosureAtomFeed(TestAtomFeed):
+    """
+    A feed to test that Atom feeds work with multiple enclosures.
+    """
+    def item_enclosures(self, item):
+        return [
+            feedgenerator.Enclosure('http://example.com/hello.png', 0, 'image/png'),
+            feedgenerator.Enclosure('http://example.com/goodbye.png', 0, 'image/png'),
+        ]

+ 52 - 5
tests/syndication_tests/tests.py

@@ -9,7 +9,10 @@ from django.core.exceptions import ImproperlyConfigured
 from django.test import TestCase, override_settings
 from django.test.utils import requires_tz_support
 from django.utils import timezone
-from django.utils.feedgenerator import rfc2822_date, rfc3339_date
+from django.utils.deprecation import RemovedInDjango20Warning
+from django.utils.feedgenerator import (
+    Enclosure, SyndicationFeed, rfc2822_date, rfc3339_date,
+)
 
 from .models import Article, Entry
 
@@ -63,10 +66,6 @@ class FeedTestCase(TestCase):
             set(expected)
         )
 
-######################################
-# Feed view
-######################################
-
 
 @override_settings(ROOT_URLCONF='syndication_tests.urls')
 class SyndicationFeedTest(FeedTestCase):
@@ -186,6 +185,22 @@ class SyndicationFeedTest(FeedTestCase):
                 item.getElementsByTagName('guid')[0].attributes.get(
                     'isPermaLink').value, "true")
 
+    def test_rss2_single_enclosure(self):
+        response = self.client.get('/syndication/rss2/single-enclosure/')
+        doc = minidom.parseString(response.content)
+        chan = doc.getElementsByTagName('rss')[0].getElementsByTagName('channel')[0]
+        items = chan.getElementsByTagName('item')
+        for item in items:
+            enclosures = item.getElementsByTagName('enclosure')
+            self.assertEqual(len(enclosures), 1)
+
+    def test_rss2_multiple_enclosures(self):
+        with self.assertRaisesMessage(ValueError, (
+            "RSS feed items may only have one enclosure, see "
+            "http://www.rssboard.org/rss-profile#element-channel-item-enclosure"
+        )):
+            self.client.get('/syndication/rss2/multiple-enclosure/')
+
     def test_rss091_feed(self):
         """
         Test the structure and content of feeds generated by RssUserland091Feed.
@@ -284,6 +299,24 @@ class SyndicationFeedTest(FeedTestCase):
 
         self.assertNotEqual(published, updated)
 
+    def test_atom_single_enclosure(self):
+        response = self.client.get('/syndication/rss2/single-enclosure/')
+        feed = minidom.parseString(response.content).firstChild
+        items = feed.getElementsByTagName('entry')
+        for item in items:
+            links = item.getElementsByTagName('link')
+            links = [link for link in links if link.getAttribute('rel') == 'enclosure']
+            self.assertEqual(len(links), 1)
+
+    def test_atom_multiple_enclosures(self):
+        response = self.client.get('/syndication/rss2/single-enclosure/')
+        feed = minidom.parseString(response.content).firstChild
+        items = feed.getElementsByTagName('entry')
+        for item in items:
+            links = item.getElementsByTagName('link')
+            links = [link for link in links if link.getAttribute('rel') == 'enclosure']
+            self.assertEqual(len(links), 2)
+
     def test_latest_post_date(self):
         """
         Test that both the published and updated dates are
@@ -493,3 +526,17 @@ class SyndicationFeedTest(FeedTestCase):
             views.add_domain('example.com', '//example.com/foo/?arg=value'),
             'http://example.com/foo/?arg=value'
         )
+
+
+class FeedgeneratorTestCase(TestCase):
+    def test_add_item_warns_when_enclosure_kwarg_is_used(self):
+        feed = SyndicationFeed(title='Example', link='http://example.com', description='Foo')
+        with self.assertRaisesMessage(RemovedInDjango20Warning, (
+            'The enclosure keyword argument is deprecated, use enclosures instead.'
+        )):
+            feed.add_item(
+                title='Example Item',
+                link='https://example.com/item',
+                description='bar',
+                enclosure=Enclosure('http://example.com/favicon.ico', 0, 'image/png'),
+            )

+ 4 - 0
tests/syndication_tests/urls.py

@@ -19,4 +19,8 @@ urlpatterns = [
     url(r'^syndication/articles/$', feeds.ArticlesFeed()),
     url(r'^syndication/template/$', feeds.TemplateFeed()),
     url(r'^syndication/template_context/$', feeds.TemplateContextFeed()),
+    url(r'^syndication/rss2/single-enclosure/$', feeds.TestSingleEnclosureRSSFeed()),
+    url(r'^syndication/rss2/multiple-enclosure/$', feeds.TestMultipleEnclosureRSSFeed()),
+    url(r'^syndication/atom/single-enclosure/$', feeds.TestSingleEnclosureAtomFeed()),
+    url(r'^syndication/atom/multiple-enclosure/$', feeds.TestMultipleEnclosureAtomFeed()),
 ]