From a269ea4fe0a9a7195f1bd8bf5d462f48c226d525 Mon Sep 17 00:00:00 2001 From: Matt Deacalion Stevens Date: Wed, 17 Jul 2013 20:20:20 +0100 Subject: [PATCH] Fixed #14656 -- Added Atom1Feed `published` element Some feed aggregators make use of the `published` element as well as the `updated` element (within the Atom standard -- http://bit.ly/2YySb). The standard allows for these two elements to be present in the same entry. `Atom1Feed` had implemented the `updated` element which was incorrectly taking the date from `pubdate`. --- AUTHORS | 1 + django/contrib/syndication/views.py | 12 +++- django/utils/feedgenerator.py | 34 +++++++---- docs/ref/contrib/syndication.txt | 24 ++++++++ docs/ref/utils.txt | 15 +++-- docs/releases/1.7.txt | 5 ++ tests/syndication/feeds.py | 20 +++++- tests/syndication/fixtures/feeddata.json | 21 +++++-- tests/syndication/models.py | 6 +- tests/syndication/tests.py | 78 +++++++++++++++++++++--- tests/syndication/urls.py | 1 + 11 files changed, 179 insertions(+), 38 deletions(-) diff --git a/AUTHORS b/AUTHORS index 99422f69262..640a31b7ba0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -204,6 +204,7 @@ answer newbie questions, and generally made Django that much better: Clint Ecker Nick Efford Marc Egli + Matt Deacalion Stevens eibaan@gmail.com David Eklund Julia Elman diff --git a/django/contrib/syndication/views.py b/django/contrib/syndication/views.py index 3bfba3ba081..cec204dc921 100644 --- a/django/contrib/syndication/views.py +++ b/django/contrib/syndication/views.py @@ -43,9 +43,9 @@ class Feed(object): raise Http404('Feed object does not exist.') feedgen = self.get_feed(obj, request) response = HttpResponse(content_type=feedgen.mime_type) - if hasattr(self, 'item_pubdate'): - # if item_pubdate is defined for the feed, set header so as - # ConditionalGetMiddleware is able to send 304 NOT MODIFIED + if hasattr(self, 'item_pubdate') or hasattr(self, 'item_updateddate'): + # if item_pubdate or item_updateddate is defined for the feed, set + # header so as ConditionalGetMiddleware is able to send 304 NOT MODIFIED response['Last-Modified'] = http_date( timegm(feedgen.latest_post_date().utctimetuple())) feedgen.write(response, 'utf-8') @@ -191,6 +191,11 @@ class Feed(object): ltz = tzinfo.LocalTimezone(pubdate) pubdate = pubdate.replace(tzinfo=ltz) + updateddate = self.__get_dynamic_attr('item_updateddate', item) + if updateddate and is_naive(updateddate): + ltz = tzinfo.LocalTimezone(updateddate) + updateddate = updateddate.replace(tzinfo=ltz) + feed.add_item( title = title, link = link, @@ -200,6 +205,7 @@ class Feed(object): 'item_guid_is_permalink', item), enclosure = enc, pubdate = pubdate, + updateddate = updateddate, author_name = author_name, author_email = author_email, author_link = author_link, diff --git a/django/utils/feedgenerator.py b/django/utils/feedgenerator.py index 7eba842a89b..de32b9f8cc2 100644 --- a/django/utils/feedgenerator.py +++ b/django/utils/feedgenerator.py @@ -114,11 +114,11 @@ 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, **kwargs): + categories=(), item_copyright=None, ttl=None, updateddate=None, **kwargs): """ Adds an item to the feed. All args are expected to be Python Unicode - objects except pubdate, which is a datetime.datetime object, and - enclosure, which is an instance of the Enclosure class. + objects except pubdate and updateddate, which are datetime.datetime + objects, and enclosure, which is an instance of the Enclosure class. """ to_unicode = lambda s: force_text(s, strings_only=True) if categories: @@ -134,6 +134,7 @@ class SyndicationFeed(object): 'author_name': to_unicode(author_name), 'author_link': iri_to_uri(author_link), 'pubdate': pubdate, + 'updateddate': updateddate, 'comments': to_unicode(comments), 'unique_id': to_unicode(unique_id), 'unique_id_is_permalink': unique_id_is_permalink, @@ -191,15 +192,20 @@ class SyndicationFeed(object): def latest_post_date(self): """ - Returns the latest item's pubdate. If none of them have a pubdate, - this returns the current date/time. + Returns the latest item's pubdate or updateddate. If no items + have either of these attributes this returns the current date/time. """ - updates = [i['pubdate'] for i in self.items if i['pubdate'] is not None] - if len(updates) > 0: - updates.sort() - return updates[-1] - else: - return datetime.datetime.now() + latest_date = None + date_keys = ('updateddate', 'pubdate') + + for item in self.items: + for date_key in date_keys: + item_date = item.get(date_key) + if item_date: + if latest_date is None or item_date > latest_date: + latest_date = item_date + + return latest_date or datetime.datetime.now() class Enclosure(object): "Represents an RSS enclosure" @@ -349,8 +355,12 @@ class Atom1Feed(SyndicationFeed): def add_item_elements(self, handler, item): handler.addQuickElement("title", item['title']) handler.addQuickElement("link", "", {"href": item['link'], "rel": "alternate"}) + if item['pubdate'] is not None: - handler.addQuickElement("updated", rfc3339_date(item['pubdate'])) + handler.addQuickElement('published', rfc3339_date(item['pubdate'])) + + if item['updateddate'] is not None: + handler.addQuickElement('updated', rfc3339_date(item['updateddate'])) # Author information. if item['author_name'] is not None: diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index 80a7afb35fb..9a0fb5830e3 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -815,6 +815,24 @@ This example illustrates all possible attributes and methods for a item_pubdate = datetime.datetime(2005, 5, 3) # Hard-coded pubdate. + # ITEM UPDATED -- It's optional to use one of these three. This is a + # hook that specifies how to get the updateddate for a given item. + # In each case, the method/attribute should return a Python + # datetime.datetime object. + + def item_updateddate(self, item): + """ + Takes an item, as returned by items(), and returns the item's + updateddate. + """ + + def item_updateddate(self): + """ + Returns the updateddated for every item in the feed. + """ + + item_updateddate = datetime.datetime(2005, 5, 3) # Hard-coded updateddate. + # ITEM CATEGORIES -- It's optional to use one of these three. This is # a hook that specifies how to get the list of categories for a given # item. In each case, the method/attribute should return an iterable @@ -928,16 +946,22 @@ They share this interface: * ``categories`` * ``item_copyright`` * ``ttl`` + * ``updateddate`` Extra keyword arguments will be stored for `custom feed generators`_. All parameters, if given, should be Unicode objects, except: * ``pubdate`` should be a Python :class:`~datetime.datetime` object. + * ``updateddate`` should be a Python :class:`~datetime.datetime` object. * ``enclosure`` should be an instance of :class:`django.utils.feedgenerator.Enclosure`. * ``categories`` should be a sequence of Unicode objects. + .. versionadded:: 1.7 + + The optional ``updateddate`` argument was added. + :meth:`.SyndicationFeed.write` Outputs the feed in the given encoding to outfile, which is a file-like object. diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 8e8cf14d6c0..6b614e0c833 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -342,11 +342,15 @@ 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, **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, **kwargs]) Adds an item to the feed. All args are expected to be Python ``unicode`` - objects except ``pubdate``, which is a ``datetime.datetime`` object, and - ``enclosure``, which is an instance of the ``Enclosure`` class. + objects except ``pubdate`` and ``updateddate``, which are ``datetime.datetime`` + objects, and ``enclosure``, which is an instance of the ``Enclosure`` class. + + .. versionadded:: 1.7 + + The optional ``updateddate`` argument was added. .. method:: num_items() @@ -380,8 +384,9 @@ SyndicationFeed .. method:: latest_post_date() - Returns the latest item's ``pubdate``. If none of them have a - ``pubdate``, this returns the current date/time. + Returns the latest ``pubdate`` or ``updateddate`` for all items in the + feed. If no items have either of these attributes this returns the + current date/time. Enclosure --------- diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index f5518284559..8c5a0fb585a 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -67,6 +67,11 @@ Minor features parameters that are passed to the ``dict`` constructor used to build the new context level. +* The :class:`~django.utils.feedgenerator.Atom1Feed` syndication feed's + ``updated`` element now utilizes `updateddate` instead of ``pubdate``, + allowing the ``published`` element to be included in the feed (which + relies on ``pubdate``). + Backwards incompatible changes in 1.7 ===================================== diff --git a/tests/syndication/feeds.py b/tests/syndication/feeds.py index 0956820bf02..1cd5c3d988e 100644 --- a/tests/syndication/feeds.py +++ b/tests/syndication/feeds.py @@ -33,7 +33,10 @@ class TestRss2Feed(views.Feed): return "Overridden description: %s" % item def item_pubdate(self, item): - return item.date + return item.published + + def item_updateddate(self, item): + return item.updated item_author_name = 'Sally Smith' item_author_email = 'test@example.com' @@ -72,6 +75,17 @@ class TestAtomFeed(TestRss2Feed): subtitle = TestRss2Feed.description +class TestLatestFeed(TestRss2Feed): + """ + A feed where the latest entry date is an `updated` element. + """ + feed_type = feedgenerator.Atom1Feed + subtitle = TestRss2Feed.description + + def items(self): + return Entry.objects.exclude(pk=5) + + class ArticlesFeed(TestRss2Feed): """ A feed to test no link being defined. Articles have no get_absolute_url() @@ -115,7 +129,7 @@ class NaiveDatesFeed(TestAtomFeed): A feed with naive (non-timezone-aware) dates. """ def item_pubdate(self, item): - return item.date + return item.published class TZAwareDatesFeed(TestAtomFeed): @@ -126,7 +140,7 @@ class TZAwareDatesFeed(TestAtomFeed): # Provide a weird offset so that the test can know it's getting this # specific offset and not accidentally getting on from # settings.TIME_ZONE. - return item.date.replace(tzinfo=tzinfo.FixedOffset(42)) + return item.published.replace(tzinfo=tzinfo.FixedOffset(42)) class TestFeedUrlFeed(TestAtomFeed): diff --git a/tests/syndication/fixtures/feeddata.json b/tests/syndication/fixtures/feeddata.json index 167115c9251..52e5028fcd6 100644 --- a/tests/syndication/fixtures/feeddata.json +++ b/tests/syndication/fixtures/feeddata.json @@ -4,7 +4,8 @@ "pk": 1, "fields": { "title": "My first entry", - "date": "1850-01-01 12:30:00" + "updated": "1850-01-01 12:30:00", + "published": "1066-09-25 20:15:00" } }, { @@ -12,7 +13,8 @@ "pk": 2, "fields": { "title": "My second entry", - "date": "2008-01-02 12:30:00" + "updated": "2008-01-02 12:30:00", + "published": "2006-03-17 18:00:00" } }, { @@ -20,7 +22,8 @@ "pk": 3, "fields": { "title": "My third entry", - "date": "2008-01-02 13:30:00" + "updated": "2008-01-02 13:30:00", + "published": "2005-06-14 10:45:00" } }, { @@ -28,7 +31,17 @@ "pk": 4, "fields": { "title": "A & B < C > D", - "date": "2008-01-03 13:30:00" + "updated": "2008-01-03 13:30:00", + "published": "2005-11-25 12:11:23" + } + }, + { + "model": "syndication.entry", + "pk": 5, + "fields": { + "title": "My last entry", + "updated": "2013-01-20 00:00:00", + "published": "2013-03-25 20:00:00" } }, { diff --git a/tests/syndication/models.py b/tests/syndication/models.py index 10b3fe3a0cf..166acd69ca9 100644 --- a/tests/syndication/models.py +++ b/tests/syndication/models.py @@ -5,10 +5,11 @@ from django.utils.encoding import python_2_unicode_compatible @python_2_unicode_compatible class Entry(models.Model): title = models.CharField(max_length=200) - date = models.DateTimeField() + updated = models.DateTimeField() + published = models.DateTimeField() class Meta: - ordering = ('date',) + ordering = ('updated',) def __str__(self): return self.title @@ -24,4 +25,3 @@ class Article(models.Model): def __str__(self): return self.title - diff --git a/tests/syndication/tests.py b/tests/syndication/tests.py index 1627f7182b5..d3b0058d539 100644 --- a/tests/syndication/tests.py +++ b/tests/syndication/tests.py @@ -58,7 +58,7 @@ class SyndicationFeedTest(FeedTestCase): chan = chan_elem[0] # Find the last build date - d = Entry.objects.latest('date').date + d = Entry.objects.latest('published').published ltz = tzinfo.LocalTimezone(d) last_build_date = rfc2822_date(d.replace(tzinfo=ltz)) @@ -88,7 +88,7 @@ class SyndicationFeedTest(FeedTestCase): ) # Find the pubdate of the first feed item - d = Entry.objects.get(pk=1).date + d = Entry.objects.get(pk=1).published ltz = tzinfo.LocalTimezone(d) pub_date = rfc2822_date(d.replace(tzinfo=ltz)) @@ -203,10 +203,61 @@ class SyndicationFeedTest(FeedTestCase): entries = feed.getElementsByTagName('entry') self.assertEqual(len(entries), Entry.objects.count()) for entry in entries: - self.assertChildNodes(entry, ['title', 'link', 'id', 'summary', 'category', 'updated', 'rights', 'author']) + self.assertChildNodes(entry, [ + 'title', + 'link', + 'id', + 'summary', + 'category', + 'updated', + 'published', + 'rights', + 'author', + ]) summary = entry.getElementsByTagName('summary')[0] self.assertEqual(summary.getAttribute('type'), 'html') + def test_atom_feed_published_and_updated_elements(self): + """ + Test that the published and updated elements are not + the same and now adhere to RFC 4287. + """ + response = self.client.get('/syndication/atom/') + feed = minidom.parseString(response.content).firstChild + entries = feed.getElementsByTagName('entry') + + published = entries[0].getElementsByTagName('published')[0].firstChild.wholeText + updated = entries[0].getElementsByTagName('updated')[0].firstChild.wholeText + + self.assertNotEqual(published, updated) + + def test_latest_post_date(self): + """ + Test that both the published and updated dates are + considered when determining the latest post date. + """ + # this feed has a `published` element with the latest date + response = self.client.get('/syndication/atom/') + feed = minidom.parseString(response.content).firstChild + updated = feed.getElementsByTagName('updated')[0].firstChild.wholeText + + d = Entry.objects.latest('published').published + ltz = tzinfo.LocalTimezone(d) + latest_published = rfc3339_date(d.replace(tzinfo=ltz)) + + self.assertEqual(updated, latest_published) + + # this feed has an `updated` element with the latest date + response = self.client.get('/syndication/latest/') + feed = minidom.parseString(response.content).firstChild + updated = feed.getElementsByTagName('updated')[0].firstChild.wholeText + + d = Entry.objects.exclude(pk=5).latest('updated').updated + ltz = tzinfo.LocalTimezone(d) + latest_updated = rfc3339_date(d.replace(tzinfo=ltz)) + + self.assertEqual(updated, latest_updated) + def test_custom_feed_generator(self): response = self.client.get('/syndication/custom/') feed = minidom.parseString(response.content).firstChild @@ -219,7 +270,18 @@ class SyndicationFeedTest(FeedTestCase): self.assertEqual(len(entries), Entry.objects.count()) for entry in entries: self.assertEqual(entry.getAttribute('bacon'), 'yum') - self.assertChildNodes(entry, ['title', 'link', 'id', 'summary', 'ministry', 'rights', 'author', 'updated', 'category']) + self.assertChildNodes(entry, [ + 'title', + 'link', + 'id', + 'summary', + 'ministry', + 'rights', + 'author', + 'updated', + 'published', + 'category', + ]) summary = entry.getElementsByTagName('summary')[0] self.assertEqual(summary.getAttribute('type'), 'html') @@ -245,7 +307,7 @@ class SyndicationFeedTest(FeedTestCase): doc = minidom.parseString(response.content) updated = doc.getElementsByTagName('updated')[0].firstChild.wholeText - d = Entry.objects.latest('date').date + d = Entry.objects.latest('published').published ltz = tzinfo.LocalTimezone(d) latest = rfc3339_date(d.replace(tzinfo=ltz)) @@ -257,12 +319,12 @@ class SyndicationFeedTest(FeedTestCase): """ response = self.client.get('/syndication/aware-dates/') doc = minidom.parseString(response.content) - updated = doc.getElementsByTagName('updated')[0].firstChild.wholeText - self.assertEqual(updated[-6:], '+00:42') + published = doc.getElementsByTagName('published')[0].firstChild.wholeText + self.assertEqual(published[-6:], '+00:42') def test_feed_last_modified_time(self): response = self.client.get('/syndication/naive-dates/') - self.assertEqual(response['Last-Modified'], 'Thu, 03 Jan 2008 19:30:00 GMT') + self.assertEqual(response['Last-Modified'], 'Tue, 26 Mar 2013 01:00:00 GMT') # No last-modified when feed has no item_pubdate response = self.client.get('/syndication/no_pubdate/') diff --git a/tests/syndication/urls.py b/tests/syndication/urls.py index 1dd7e92332c..06a75a4e68d 100644 --- a/tests/syndication/urls.py +++ b/tests/syndication/urls.py @@ -15,6 +15,7 @@ urlpatterns = patterns('django.contrib.syndication.views', (r'^syndication/rss091/$', feeds.TestRss091Feed()), (r'^syndication/no_pubdate/$', feeds.TestNoPubdateFeed()), (r'^syndication/atom/$', feeds.TestAtomFeed()), + (r'^syndication/latest/$', feeds.TestLatestFeed()), (r'^syndication/custom/$', feeds.TestCustomFeed()), (r'^syndication/naive-dates/$', feeds.NaiveDatesFeed()), (r'^syndication/aware-dates/$', feeds.TZAwareDatesFeed()),