Kaydet (Commit) aac2a2d2 authored tarafından Unai Zalakain's avatar Unai Zalakain Kaydeden (comit) Tim Graham

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.
üst 71ebcb85
...@@ -64,6 +64,17 @@ class Feed(object): ...@@ -64,6 +64,17 @@ class Feed(object):
'item_link() method in your Feed class.' % item.__class__.__name__ '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): def __get_dynamic_attr(self, attname, obj, default=None):
try: try:
attr = getattr(self, attname) attr = getattr(self, attname)
...@@ -171,14 +182,7 @@ class Feed(object): ...@@ -171,14 +182,7 @@ class Feed(object):
self.__get_dynamic_attr('item_link', item), self.__get_dynamic_attr('item_link', item),
request.is_secure(), request.is_secure(),
) )
enc = None enclosures = self.__get_dynamic_attr('item_enclosures', 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))
)
author_name = self.__get_dynamic_attr('item_author_name', item) author_name = self.__get_dynamic_attr('item_author_name', item)
if author_name is not None: if author_name is not None:
author_email = self.__get_dynamic_attr('item_author_email', item) author_email = self.__get_dynamic_attr('item_author_email', item)
...@@ -203,7 +207,7 @@ class Feed(object): ...@@ -203,7 +207,7 @@ class Feed(object):
unique_id=self.__get_dynamic_attr('item_guid', item, link), unique_id=self.__get_dynamic_attr('item_guid', item, link),
unique_id_is_permalink=self.__get_dynamic_attr( unique_id_is_permalink=self.__get_dynamic_attr(
'item_guid_is_permalink', item), 'item_guid_is_permalink', item),
enclosure=enc, enclosures=enclosures,
pubdate=pubdate, pubdate=pubdate,
updateddate=updateddate, updateddate=updateddate,
author_name=author_name, author_name=author_name,
......
...@@ -118,11 +118,13 @@ class SyndicationFeed(object): ...@@ -118,11 +118,13 @@ class SyndicationFeed(object):
def add_item(self, title, link, description, author_email=None, def add_item(self, title, link, description, author_email=None,
author_name=None, author_link=None, pubdate=None, comments=None, author_name=None, author_link=None, pubdate=None, comments=None,
unique_id=None, unique_id_is_permalink=None, enclosure=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 Adds an item to the feed. All args are expected to be Python Unicode
objects except pubdate and updateddate, which are datetime.datetime 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) to_unicode = lambda s: force_text(s, strings_only=True)
if categories: if categories:
...@@ -130,6 +132,16 @@ class SyndicationFeed(object): ...@@ -130,6 +132,16 @@ class SyndicationFeed(object):
if ttl is not None: if ttl is not None:
# Force ints to unicode # Force ints to unicode
ttl = force_text(ttl) 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 = { item = {
'title': to_unicode(title), 'title': to_unicode(title),
'link': iri_to_uri(link), 'link': iri_to_uri(link),
...@@ -142,7 +154,7 @@ class SyndicationFeed(object): ...@@ -142,7 +154,7 @@ class SyndicationFeed(object):
'comments': to_unicode(comments), 'comments': to_unicode(comments),
'unique_id': to_unicode(unique_id), 'unique_id': to_unicode(unique_id),
'unique_id_is_permalink': unique_id_is_permalink, 'unique_id_is_permalink': unique_id_is_permalink,
'enclosure': enclosure, 'enclosures': enclosures,
'categories': categories or (), 'categories': categories or (),
'item_copyright': to_unicode(item_copyright), 'item_copyright': to_unicode(item_copyright),
'ttl': ttl, 'ttl': ttl,
...@@ -317,10 +329,19 @@ class Rss201rev2Feed(RssFeed): ...@@ -317,10 +329,19 @@ class Rss201rev2Feed(RssFeed):
handler.addQuickElement("ttl", item['ttl']) handler.addQuickElement("ttl", item['ttl'])
# Enclosure. # Enclosure.
if item['enclosure'] is not None: if item['enclosures']:
handler.addQuickElement("enclosure", '', enclosures = list(item['enclosures'])
{"url": item['enclosure'].url, "length": item['enclosure'].length, if len(enclosures) > 1:
"type": item['enclosure'].mime_type}) 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. # Categories.
for cat in item['categories']: for cat in item['categories']:
...@@ -328,7 +349,7 @@ class Rss201rev2Feed(RssFeed): ...@@ -328,7 +349,7 @@ class Rss201rev2Feed(RssFeed):
class Atom1Feed(SyndicationFeed): 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' content_type = 'application/atom+xml; charset=utf-8'
ns = "http://www.w3.org/2005/Atom" ns = "http://www.w3.org/2005/Atom"
...@@ -405,13 +426,14 @@ class Atom1Feed(SyndicationFeed): ...@@ -405,13 +426,14 @@ class Atom1Feed(SyndicationFeed):
if item['description'] is not None: if item['description'] is not None:
handler.addQuickElement("summary", item['description'], {"type": "html"}) handler.addQuickElement("summary", item['description'], {"type": "html"})
# Enclosure. # Enclosures.
if item['enclosure'] is not None: for enclosure in item.get('enclosures') or []:
handler.addQuickElement("link", '', handler.addQuickElement('link', '', {
{"rel": "enclosure", 'rel': 'enclosure',
"href": item['enclosure'].url, 'href': enclosure.url,
"length": item['enclosure'].length, 'length': enclosure.length,
"type": item['enclosure'].mime_type}) 'type': enclosure.mime_type,
})
# Categories. # Categories.
for cat in item['categories']: for cat in item['categories']:
......
...@@ -94,6 +94,9 @@ details on these changes. ...@@ -94,6 +94,9 @@ details on these changes.
* The ``callable_obj`` keyword argument to * The ``callable_obj`` keyword argument to
``SimpleTestCase.assertRaisesMessage()`` will be removed. ``SimpleTestCase.assertRaisesMessage()`` will be removed.
* The ``enclosure`` keyword argument to ``SyndicationFeed.add_item()`` will be
removed.
.. _deprecation-removed-in-1.10: .. _deprecation-removed-in-1.10:
1.10 1.10
......
...@@ -298,10 +298,16 @@ Enclosures ...@@ -298,10 +298,16 @@ Enclosures
---------- ----------
To specify enclosures, such as those used in creating podcast feeds, use the 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 ``item_enclosure_mime_type`` hooks. See the ``ExampleFeed`` class below for
usage examples. usage examples.
.. versionchanged:: 1.9
Support for multiple enclosures per feed item was added through the
``item_enclosures`` hook.
Language Language
-------- --------
...@@ -742,8 +748,28 @@ This example illustrates all possible attributes and methods for a ...@@ -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_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 # 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): def item_enclosure_url(self, item):
""" """
...@@ -759,9 +785,10 @@ This example illustrates all possible attributes and methods for a ...@@ -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_url = "/foo/bar.mp3" # Hard-coded enclosure link.
# ITEM ENCLOSURE LENGTH -- One of these three is required if you're # ITEM ENCLOSURE LENGTH -- 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
# In each case, the returned value should be either an integer, or a # framework looks for them in this order. In each case, the returned
# string representation of the integer, in bytes. # value should be either an integer, or a string representation of the
# integer, in bytes.
def item_enclosure_length(self, item): def item_enclosure_length(self, item):
""" """
...@@ -777,7 +804,8 @@ This example illustrates all possible attributes and methods for a ...@@ -777,7 +804,8 @@ This example illustrates all possible attributes and methods for a
item_enclosure_length = 32000 # Hard-coded enclosure length. item_enclosure_length = 32000 # Hard-coded enclosure length.
# ITEM ENCLOSURE MIME TYPE -- One of these three is required if you're # 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): def item_enclosure_mime_type(self, item):
""" """
...@@ -941,6 +969,7 @@ They share this interface: ...@@ -941,6 +969,7 @@ They share this interface:
* ``comments`` * ``comments``
* ``unique_id`` * ``unique_id``
* ``enclosure`` * ``enclosure``
* ``enclosures``
* ``categories`` * ``categories``
* ``item_copyright`` * ``item_copyright``
* ``ttl`` * ``ttl``
...@@ -954,8 +983,15 @@ They share this interface: ...@@ -954,8 +983,15 @@ They share this interface:
* ``updateddate`` should be a Python :class:`~datetime.datetime` object. * ``updateddate`` should be a Python :class:`~datetime.datetime` object.
* ``enclosure`` should be an instance of * ``enclosure`` should be an instance of
:class:`django.utils.feedgenerator.Enclosure`. :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. * ``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` :meth:`.SyndicationFeed.write`
Outputs the feed in the given encoding to outfile, which is a file-like object. Outputs the feed in the given encoding to outfile, which is a file-like object.
......
...@@ -351,11 +351,18 @@ SyndicationFeed ...@@ -351,11 +351,18 @@ SyndicationFeed
All parameters should be Unicode objects, except ``categories``, which All parameters should be Unicode objects, except ``categories``, which
should be a sequence of Unicode objects. 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`` Adds an item to the feed. All args are expected to be Python ``unicode``
objects except ``pubdate`` and ``updateddate``, which are ``datetime.datetime`` 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() .. method:: num_items()
......
...@@ -303,7 +303,9 @@ Minor features ...@@ -303,7 +303,9 @@ Minor features
:mod:`django.contrib.syndication` :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 Cache
^^^^^ ^^^^^
...@@ -1265,6 +1267,10 @@ Miscellaneous ...@@ -1265,6 +1267,10 @@ Miscellaneous
:func:`~django.utils.safestring.mark_safe` when constructing the method's :func:`~django.utils.safestring.mark_safe` when constructing the method's
return value instead. 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: .. removed-features-1.9:
Features removed in 1.9 Features removed in 1.9
......
...@@ -88,8 +88,29 @@ class ArticlesFeed(TestRss2Feed): ...@@ -88,8 +88,29 @@ class ArticlesFeed(TestRss2Feed):
return Article.objects.all() return Article.objects.all()
class TestEnclosureFeed(TestRss2Feed): class TestSingleEnclosureRSSFeed(TestRss2Feed):
pass """
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): class TemplateFeed(TestRss2Feed):
...@@ -165,3 +186,28 @@ class MyCustomAtom1Feed(feedgenerator.Atom1Feed): ...@@ -165,3 +186,28 @@ class MyCustomAtom1Feed(feedgenerator.Atom1Feed):
class TestCustomFeed(TestAtomFeed): class TestCustomFeed(TestAtomFeed):
feed_type = MyCustomAtom1Feed 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'),
]
...@@ -9,7 +9,10 @@ from django.core.exceptions import ImproperlyConfigured ...@@ -9,7 +9,10 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.utils import requires_tz_support from django.test.utils import requires_tz_support
from django.utils import timezone 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 from .models import Article, Entry
...@@ -63,10 +66,6 @@ class FeedTestCase(TestCase): ...@@ -63,10 +66,6 @@ class FeedTestCase(TestCase):
set(expected) set(expected)
) )
######################################
# Feed view
######################################
@override_settings(ROOT_URLCONF='syndication_tests.urls') @override_settings(ROOT_URLCONF='syndication_tests.urls')
class SyndicationFeedTest(FeedTestCase): class SyndicationFeedTest(FeedTestCase):
...@@ -186,6 +185,22 @@ class SyndicationFeedTest(FeedTestCase): ...@@ -186,6 +185,22 @@ class SyndicationFeedTest(FeedTestCase):
item.getElementsByTagName('guid')[0].attributes.get( item.getElementsByTagName('guid')[0].attributes.get(
'isPermaLink').value, "true") '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): def test_rss091_feed(self):
""" """
Test the structure and content of feeds generated by RssUserland091Feed. Test the structure and content of feeds generated by RssUserland091Feed.
...@@ -284,6 +299,24 @@ class SyndicationFeedTest(FeedTestCase): ...@@ -284,6 +299,24 @@ class SyndicationFeedTest(FeedTestCase):
self.assertNotEqual(published, updated) 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): def test_latest_post_date(self):
""" """
Test that both the published and updated dates are Test that both the published and updated dates are
...@@ -493,3 +526,17 @@ class SyndicationFeedTest(FeedTestCase): ...@@ -493,3 +526,17 @@ class SyndicationFeedTest(FeedTestCase):
views.add_domain('example.com', '//example.com/foo/?arg=value'), views.add_domain('example.com', '//example.com/foo/?arg=value'),
'http://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'),
)
...@@ -19,4 +19,8 @@ urlpatterns = [ ...@@ -19,4 +19,8 @@ urlpatterns = [
url(r'^syndication/articles/$', feeds.ArticlesFeed()), url(r'^syndication/articles/$', feeds.ArticlesFeed()),
url(r'^syndication/template/$', feeds.TemplateFeed()), url(r'^syndication/template/$', feeds.TemplateFeed()),
url(r'^syndication/template_context/$', feeds.TemplateContextFeed()), 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()),
] ]
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment