From c4c27d8a04c9125cfbc5c3611557d8e5d3845b0d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 28 Jan 2010 13:46:18 +0000 Subject: [PATCH] Fixed #6188, #6304, #6618, #6969, #8758, #8989, #10334, #11069, #11973 and #12403 -- Modified the syndication framework to use class-based views. Thanks to Ben Firshman for his work on this patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@12338 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- AUTHORS | 1 + django/contrib/comments/feeds.py | 4 +- django/contrib/syndication/feeds.py | 171 +------- django/contrib/syndication/views.py | 201 ++++++++- django/utils/feedgenerator.py | 28 +- docs/internals/deprecation.txt | 4 + docs/ref/contrib/syndication.txt | 403 ++++++++---------- docs/releases/1.2.txt | 92 +++- tests/regressiontests/syndication/feeds.py | 160 +++++-- .../syndication/fixtures/feeddata.json | 10 +- tests/regressiontests/syndication/models.py | 19 +- .../templates/syndication/description.html | 1 + .../templates/syndication/title.html | 1 + tests/regressiontests/syndication/tests.py | 287 +++++++++++-- tests/regressiontests/syndication/urls.py | 28 +- tests/regressiontests/utils/feedgenerator.py | 63 +++ tests/regressiontests/utils/tests.py | 1 + 17 files changed, 987 insertions(+), 487 deletions(-) create mode 100644 tests/regressiontests/syndication/templates/syndication/description.html create mode 100644 tests/regressiontests/syndication/templates/syndication/title.html create mode 100644 tests/regressiontests/utils/feedgenerator.py diff --git a/AUTHORS b/AUTHORS index 56ab83d011..ae6a09069a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -166,6 +166,7 @@ answer newbie questions, and generally made Django that much better: Afonso Fernández Nogueira J. Pablo Fernandez Maciej Fijalkowski + Ben Firshman Matthew Flanagan Eric Floehr Eric Florenzano diff --git a/django/contrib/comments/feeds.py b/django/contrib/comments/feeds.py index 24b10d48da..e74ca2dfde 100644 --- a/django/contrib/comments/feeds.py +++ b/django/contrib/comments/feeds.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.contrib.syndication.feeds import Feed +from django.contrib.syndication.views import Feed from django.contrib.sites.models import Site from django.contrib import comments from django.utils.translation import ugettext as _ @@ -33,6 +33,6 @@ class LatestCommentFeed(Feed): params = [settings.COMMENTS_BANNED_USERS_GROUP] qs = qs.extra(where=where, params=params) return qs.order_by('-submit_date')[:40] - + def item_pubdate(self, item): return item.submit_date diff --git a/django/contrib/syndication/feeds.py b/django/contrib/syndication/feeds.py index e5e0877926..e4e99da7dd 100644 --- a/django/contrib/syndication/feeds.py +++ b/django/contrib/syndication/feeds.py @@ -1,78 +1,22 @@ -from datetime import datetime, timedelta +from django.contrib.syndication import views +from django.core.exceptions import ObjectDoesNotExist +import warnings -from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist -from django.template import loader, Template, TemplateDoesNotExist -from django.contrib.sites.models import Site, RequestSite -from django.utils import feedgenerator -from django.utils.tzinfo import FixedOffset -from django.utils.encoding import smart_unicode, iri_to_uri -from django.conf import settings -from django.template import RequestContext - -def add_domain(domain, url): - if not (url.startswith('http://') or url.startswith('https://')): - # 'url' must already be ASCII and URL-quoted, so no need for encoding - # conversions here. - url = iri_to_uri(u'http://%s%s' % (domain, url)) - return url - -class FeedDoesNotExist(ObjectDoesNotExist): - pass - -class Feed(object): - item_pubdate = None - item_enclosure_url = None - feed_type = feedgenerator.DefaultFeed - feed_url = None - title_template = None - description_template = None +# This is part of the deprecated API +from django.contrib.syndication.views import FeedDoesNotExist, add_domain +class Feed(views.Feed): + """Provided for backwards compatibility.""" def __init__(self, slug, request): + warnings.warn('The syndication feeds.Feed class is deprecated. Please ' + 'use the new class based view API.', + category=PendingDeprecationWarning) + self.slug = slug self.request = request - self.feed_url = self.feed_url or request.path - self.title_template_name = self.title_template or ('feeds/%s_title.html' % slug) - self.description_template_name = self.description_template or ('feeds/%s_description.html' % slug) - - def item_link(self, item): - try: - return item.get_absolute_url() - except AttributeError: - raise ImproperlyConfigured("Give your %s class a get_absolute_url() method, or define an item_link() method in your Feed class." % item.__class__.__name__) - - def __get_dynamic_attr(self, attname, obj, default=None): - try: - attr = getattr(self, attname) - except AttributeError: - return default - if callable(attr): - # Check func_code.co_argcount rather than try/excepting the - # function and catching the TypeError, because something inside - # the function may raise the TypeError. This technique is more - # accurate. - if hasattr(attr, 'func_code'): - argcount = attr.func_code.co_argcount - else: - argcount = attr.__call__.func_code.co_argcount - if argcount == 2: # one argument is 'self' - return attr(obj) - else: - return attr() - return attr - - def feed_extra_kwargs(self, obj): - """ - Returns an extra keyword arguments dictionary that is used when - initializing the feed generator. - """ - return {} - - def item_extra_kwargs(self, item): - """ - Returns an extra keyword arguments dictionary that is used with - the `add_item` call of the feed generator. - """ - return {} + self.feed_url = getattr(self, 'feed_url', None) or request.path + self.title_template = self.title_template or ('feeds/%s_title.html' % slug) + self.description_template = self.description_template or ('feeds/%s_description.html' % slug) def get_object(self, bits): return None @@ -86,94 +30,9 @@ class Feed(object): bits = url.split('/') else: bits = [] - try: obj = self.get_object(bits) except ObjectDoesNotExist: raise FeedDoesNotExist + return super(Feed, self).get_feed(obj, self.request) - if Site._meta.installed: - current_site = Site.objects.get_current() - else: - current_site = RequestSite(self.request) - - link = self.__get_dynamic_attr('link', obj) - link = add_domain(current_site.domain, link) - - feed = self.feed_type( - title = self.__get_dynamic_attr('title', obj), - subtitle = self.__get_dynamic_attr('subtitle', obj), - link = link, - description = self.__get_dynamic_attr('description', obj), - language = settings.LANGUAGE_CODE.decode(), - feed_url = add_domain(current_site.domain, - self.__get_dynamic_attr('feed_url', obj)), - author_name = self.__get_dynamic_attr('author_name', obj), - author_link = self.__get_dynamic_attr('author_link', obj), - author_email = self.__get_dynamic_attr('author_email', obj), - categories = self.__get_dynamic_attr('categories', obj), - feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), - feed_guid = self.__get_dynamic_attr('feed_guid', obj), - ttl = self.__get_dynamic_attr('ttl', obj), - **self.feed_extra_kwargs(obj) - ) - - try: - title_tmp = loader.get_template(self.title_template_name) - except TemplateDoesNotExist: - title_tmp = Template('{{ obj }}') - try: - description_tmp = loader.get_template(self.description_template_name) - except TemplateDoesNotExist: - description_tmp = Template('{{ obj }}') - - for item in self.__get_dynamic_attr('items', obj): - link = add_domain(current_site.domain, self.__get_dynamic_attr('item_link', item)) - enc = None - enc_url = self.__get_dynamic_attr('item_enclosure_url', item) - if enc_url: - enc = feedgenerator.Enclosure( - url = smart_unicode(enc_url), - length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), - mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', 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) - author_link = self.__get_dynamic_attr('item_author_link', item) - else: - author_email = author_link = None - - pubdate = self.__get_dynamic_attr('item_pubdate', item) - if pubdate and not pubdate.tzinfo: - now = datetime.now() - utcnow = datetime.utcnow() - - # Must always subtract smaller time from larger time here. - if utcnow > now: - sign = -1 - tzDifference = (utcnow - now) - else: - sign = 1 - tzDifference = (now - utcnow) - - # Round the timezone offset to the nearest half hour. - tzOffsetMinutes = sign * ((tzDifference.seconds / 60 + 15) / 30) * 30 - tzOffset = timedelta(minutes=tzOffsetMinutes) - pubdate = pubdate.replace(tzinfo=FixedOffset(tzOffset)) - - feed.add_item( - title = title_tmp.render(RequestContext(self.request, {'obj': item, 'site': current_site})), - link = link, - description = description_tmp.render(RequestContext(self.request, {'obj': item, 'site': current_site})), - unique_id = self.__get_dynamic_attr('item_guid', item, link), - enclosure = enc, - pubdate = pubdate, - author_name = author_name, - author_email = author_email, - author_link = author_link, - categories = self.__get_dynamic_attr('item_categories', item), - item_copyright = self.__get_dynamic_attr('item_copyright', item), - **self.item_extra_kwargs(item) - ) - return feed diff --git a/django/contrib/syndication/views.py b/django/contrib/syndication/views.py index d4220366be..559d062b57 100644 --- a/django/contrib/syndication/views.py +++ b/django/contrib/syndication/views.py @@ -1,7 +1,203 @@ -from django.contrib.syndication import feeds +import datetime +from django.conf import settings +from django.contrib.sites.models import Site, RequestSite +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.http import HttpResponse, Http404 +from django.template import loader, Template, TemplateDoesNotExist, RequestContext +from django.utils import feedgenerator, tzinfo +from django.utils.encoding import force_unicode, iri_to_uri, smart_unicode +from django.utils.html import escape + +def add_domain(domain, url): + if not (url.startswith('http://') + or url.startswith('https://') + or url.startswith('mailto:')): + # 'url' must already be ASCII and URL-quoted, so no need for encoding + # conversions here. + url = iri_to_uri(u'http://%s%s' % (domain, url)) + return url + +class FeedDoesNotExist(ObjectDoesNotExist): + pass + + +class Feed(object): + feed_type = feedgenerator.DefaultFeed + title_template = None + description_template = None + + def __call__(self, request, *args, **kwargs): + try: + obj = self.get_object(request, *args, **kwargs) + except ObjectDoesNotExist: + raise Http404('Feed object does not exist.') + feedgen = self.get_feed(obj, request) + response = HttpResponse(mimetype=feedgen.mime_type) + feedgen.write(response, 'utf-8') + return response + + def item_title(self, item): + # Titles should be double escaped by default (see #6533) + return escape(force_unicode(item)) + + def item_description(self, item): + return force_unicode(item) + + def item_link(self, item): + try: + return item.get_absolute_url() + except AttributeError: + raise ImproperlyConfigured('Give your %s class a get_absolute_url() method, or define an item_link() method in your Feed class.' % item.__class__.__name__) + + def __get_dynamic_attr(self, attname, obj, default=None): + try: + attr = getattr(self, attname) + except AttributeError: + return default + if callable(attr): + # Check func_code.co_argcount rather than try/excepting the + # function and catching the TypeError, because something inside + # the function may raise the TypeError. This technique is more + # accurate. + if hasattr(attr, 'func_code'): + argcount = attr.func_code.co_argcount + else: + argcount = attr.__call__.func_code.co_argcount + if argcount == 2: # one argument is 'self' + return attr(obj) + else: + return attr() + return attr + + def feed_extra_kwargs(self, obj): + """ + Returns an extra keyword arguments dictionary that is used when + initializing the feed generator. + """ + return {} + + def item_extra_kwargs(self, item): + """ + Returns an extra keyword arguments dictionary that is used with + the `add_item` call of the feed generator. + """ + return {} + + def get_object(self, request, *args, **kwargs): + return None + + def get_feed(self, obj, request): + """ + Returns a feedgenerator.DefaultFeed object, fully populated, for + this feed. Raises FeedDoesNotExist for invalid parameters. + """ + if Site._meta.installed: + current_site = Site.objects.get_current() + else: + current_site = RequestSite(request) + + link = self.__get_dynamic_attr('link', obj) + link = add_domain(current_site.domain, link) + + feed = self.feed_type( + title = self.__get_dynamic_attr('title', obj), + subtitle = self.__get_dynamic_attr('subtitle', obj), + link = link, + description = self.__get_dynamic_attr('description', obj), + language = settings.LANGUAGE_CODE.decode(), + feed_url = add_domain(current_site.domain, + self.__get_dynamic_attr('feed_url', obj) or request.path), + author_name = self.__get_dynamic_attr('author_name', obj), + author_link = self.__get_dynamic_attr('author_link', obj), + author_email = self.__get_dynamic_attr('author_email', obj), + categories = self.__get_dynamic_attr('categories', obj), + feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), + feed_guid = self.__get_dynamic_attr('feed_guid', obj), + ttl = self.__get_dynamic_attr('ttl', obj), + **self.feed_extra_kwargs(obj) + ) + + title_tmp = None + if self.title_template is not None: + try: + title_tmp = loader.get_template(self.title_template) + except TemplateDoesNotExist: + pass + + description_tmp = None + if self.description_template is not None: + try: + description_tmp = loader.get_template(self.description_template) + except TemplateDoesNotExist: + pass + + for item in self.__get_dynamic_attr('items', obj): + if title_tmp is not None: + title = title_tmp.render(RequestContext(request, {'obj': item, 'site': current_site})) + else: + title = self.__get_dynamic_attr('item_title', item) + if description_tmp is not None: + description = description_tmp.render(RequestContext(request, {'obj': item, 'site': current_site})) + else: + description = self.__get_dynamic_attr('item_description', item) + link = add_domain(current_site.domain, self.__get_dynamic_attr('item_link', item)) + enc = None + enc_url = self.__get_dynamic_attr('item_enclosure_url', item) + if enc_url: + enc = feedgenerator.Enclosure( + url = smart_unicode(enc_url), + length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), + mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', 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) + author_link = self.__get_dynamic_attr('item_author_link', item) + else: + author_email = author_link = None + + pubdate = self.__get_dynamic_attr('item_pubdate', item) + if pubdate and not pubdate.tzinfo: + now = datetime.datetime.now() + utcnow = datetime.datetime.utcnow() + + # Must always subtract smaller time from larger time here. + if utcnow > now: + sign = -1 + tzDifference = (utcnow - now) + else: + sign = 1 + tzDifference = (now - utcnow) + + # Round the timezone offset to the nearest half hour. + tzOffsetMinutes = sign * ((tzDifference.seconds / 60 + 15) / 30) * 30 + tzOffset = datetime.timedelta(minutes=tzOffsetMinutes) + pubdate = pubdate.replace(tzinfo=tzinfo.FixedOffset(tzOffset)) + + feed.add_item( + title = title, + link = link, + description = description, + unique_id = self.__get_dynamic_attr('item_guid', item, link), + enclosure = enc, + pubdate = pubdate, + author_name = author_name, + author_email = author_email, + author_link = author_link, + categories = self.__get_dynamic_attr('item_categories', item), + item_copyright = self.__get_dynamic_attr('item_copyright', item), + **self.item_extra_kwargs(item) + ) + return feed + def feed(request, url, feed_dict=None): + """Provided for backwards compatibility.""" + import warnings + warnings.warn('The syndication feed() view is deprecated. Please use the ' + 'new class based view API.', + category=PendingDeprecationWarning) + if not feed_dict: raise Http404("No feeds are registered.") @@ -17,9 +213,10 @@ def feed(request, url, feed_dict=None): try: feedgen = f(slug, request).get_feed(param) - except feeds.FeedDoesNotExist: + except FeedDoesNotExist: raise Http404("Invalid feed parameters. Slug %r is valid, but other parameters, or lack thereof, are not." % slug) response = HttpResponse(mimetype=feedgen.mime_type) feedgen.write(response, 'utf-8') return response + diff --git a/django/utils/feedgenerator.py b/django/utils/feedgenerator.py index c9445f932f..06008d36e8 100644 --- a/django/utils/feedgenerator.py +++ b/django/utils/feedgenerator.py @@ -19,8 +19,8 @@ For definitions of the different versions of RSS, see: http://diveintomark.org/archives/2004/02/04/incompatible-rss """ -import re import datetime +import urlparse from django.utils.xmlutils import SimplerXMLGenerator from django.utils.encoding import force_unicode, iri_to_uri @@ -46,12 +46,16 @@ def rfc3339_date(date): return date.strftime('%Y-%m-%dT%H:%M:%SZ') def get_tag_uri(url, date): - "Creates a TagURI. See http://diveintomark.org/archives/2004/05/28/howto-atom-id" - tag = re.sub('^http://', '', url) + """ + Creates a TagURI. + + See http://diveintomark.org/archives/2004/05/28/howto-atom-id + """ + url_split = urlparse.urlparse(url) + d = '' if date is not None: - tag = re.sub('/', ',%s:/' % date.strftime('%Y-%m-%d'), tag, 1) - tag = re.sub('#', '/', tag) - return u'tag:' + tag + d = ',%s' % date.strftime('%Y-%m-%d') + return u'tag:%s%s:%s/%s' % (url_split.hostname, d, url_split.path, url_split.fragment) class SyndicationFeed(object): "Base class for all syndication feeds. Subclasses should provide write()" @@ -61,6 +65,9 @@ class SyndicationFeed(object): to_unicode = lambda s: force_unicode(s, strings_only=True) if categories: categories = [force_unicode(c) for c in categories] + if ttl is not None: + # Force ints to unicode + ttl = force_unicode(ttl) self.feed = { 'title': to_unicode(title), 'link': iri_to_uri(link), @@ -91,6 +98,9 @@ class SyndicationFeed(object): to_unicode = lambda s: force_unicode(s, strings_only=True) if categories: categories = [to_unicode(c) for c in categories] + if ttl is not None: + # Force ints to unicode + ttl = force_unicode(ttl) item = { 'title': to_unicode(title), 'link': iri_to_uri(link), @@ -186,7 +196,8 @@ class RssFeed(SyndicationFeed): handler.endElement(u"rss") def rss_attributes(self): - return {u"version": self._version} + return {u"version": self._version, + u"xmlns:atom": u"http://www.w3.org/2005/Atom"} def write_items(self, handler): for item in self.items: @@ -198,6 +209,7 @@ class RssFeed(SyndicationFeed): handler.addQuickElement(u"title", self.feed['title']) handler.addQuickElement(u"link", self.feed['link']) handler.addQuickElement(u"description", self.feed['description']) + handler.addQuickElement(u"atom:link", None, {u"rel": u"self", u"href": self.feed['feed_url']}) if self.feed['language'] is not None: handler.addQuickElement(u"language", self.feed['language']) for cat in self.feed['categories']: @@ -235,7 +247,7 @@ class Rss201rev2Feed(RssFeed): elif item["author_email"]: handler.addQuickElement(u"author", item["author_email"]) elif item["author_name"]: - handler.addQuickElement(u"dc:creator", item["author_name"], {"xmlns:dc": u"http://purl.org/dc/elements/1.1/"}) + handler.addQuickElement(u"dc:creator", item["author_name"], {u"xmlns:dc": u"http://purl.org/dc/elements/1.1/"}) if item['pubdate'] is not None: handler.addQuickElement(u"pubDate", rfc2822_date(item['pubdate']).decode('utf-8')) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index c3aa55e9d8..f2ae31d4be 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -82,6 +82,10 @@ their deprecation, as per the :ref:`Django deprecation policy * The ability to use a function-based test runners will be removed, along with the ``django.test.simple.run_tests()`` test runner. + * The ``views.feed()`` view and ``feeds.Feed`` class in + ``django.contrib.syndication`` have been deprecated since the 1.2 + release. The class-based view ``views.Feed`` should be used instead. + * 2.0 * ``django.views.defaults.shortcut()``. This function has been moved to ``django.contrib.contenttypes.views.shortcut()`` as part of the diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index c27666303c..5c670c7834 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -8,14 +8,15 @@ The syndication feed framework :synopsis: A framework for generating syndication feeds, in RSS and Atom, quite easily. -Django comes with a high-level syndication-feed-generating framework that makes -creating RSS_ and Atom_ feeds easy. +Django comes with a high-level syndication-feed-generating framework +that makes creating RSS_ and Atom_ feeds easy. -To create any syndication feed, all you have to do is write a short Python -class. You can create as many feeds as you want. +To create any syndication feed, all you have to do is write a short +Python class. You can create as many feeds as you want. -Django also comes with a lower-level feed-generating API. Use this if you want -to generate feeds outside of a Web context, or in some other lower-level way. +Django also comes with a lower-level feed-generating API. Use this if +you want to generate feeds outside of a Web context, or in some other +lower-level way. .. _RSS: http://www.whatisrss.com/ .. _Atom: http://www.atomenabled.org/ @@ -23,74 +24,37 @@ to generate feeds outside of a Web context, or in some other lower-level way. The high-level framework ======================== +.. versionchanged:: 1.2 + The high-level feeds framework was refactored in Django 1.2. The + pre-1.2 interface still exists, but it has been deprecated, and + will be removed in Django 1.4. If you need to maintain an old-style + Django feed, please consult the Django 1.1 documentation. For + details on updating to use the new high-level feed framework, see + the :ref:`Django 1.2 release notes <1.2-updating-feeds>`. + Overview -------- -The high-level feed-generating framework is a view that's hooked to ``/feeds/`` -by default. Django uses the remainder of the URL (everything after ``/feeds/``) -to determine which feed to output. - -To create a feed, just write a :class:`~django.contrib.syndication.feeds.Feed` -class and point to it in your :ref:`URLconf `. - -Initialization --------------- - -To activate syndication feeds on your Django site, add this line to your -:ref:`URLconf `:: - - (r'^feeds/(?P.*)/$', 'django.contrib.syndication.views.feed', {'feed_dict': feeds}), - -This tells Django to use the RSS framework to handle all URLs starting with -:file:`"feeds/"`. (You can change that :file:`"feeds/"` prefix to fit your own -needs.) - -This URLconf line has an extra argument: ``{'feed_dict': feeds}``. Use this -extra argument to pass the syndication framework the feeds that should be -published under that URL. - -Specifically, :data:`feed_dict` should be a dictionary that maps a feed's slug -(short URL label) to its :class:`~django.contrib.syndication.feeds.Feed` class. - -You can define the ``feed_dict`` in the URLconf itself. Here's a full example -URLconf:: - - from django.conf.urls.defaults import * - from myproject.feeds import LatestEntries, LatestEntriesByCategory - - feeds = { - 'latest': LatestEntries, - 'categories': LatestEntriesByCategory, - } - - urlpatterns = patterns('', - # ... - (r'^feeds/(?P.*)/$', 'django.contrib.syndication.views.feed', - {'feed_dict': feeds}), - # ... - ) - -The above example registers two feeds: - - * The feed represented by ``LatestEntries`` will live at ``feeds/latest/``. - * The feed represented by ``LatestEntriesByCategory`` will live at - ``feeds/categories/``. - -Once that's set up, you just need to define the -:class:`~django.contrib.syndication.feeds.Feed` classes themselves. +The high-level feed-generating framework is supplied by the +:class:`~django.contrib.syndication.views.Feed` class. To create a +feed, write a :class:`~django.contrib.syndication.views.Feed` class +and point to an instance of it in your :ref:`URLconf +`. Feed classes ------------ -A :class:`~django.contrib.syndication.feeds.Feed` class is a simple Python class -that represents a syndication feed. A feed can be simple (e.g., a "site news" -feed, or a basic feed displaying the latest entries of a blog) or more complex -(e.g., a feed displaying all the blog entries in a particular category, where -the category is variable). +A :class:`~django.contrib.syndication.views.Feed` class is a Python +class that represents a syndication feed. A feed can be simple (e.g., +a "site news" feed, or a basic feed displaying the latest entries of a +blog) or more complex (e.g., a feed displaying all the blog entries in +a particular category, where the category is variable). -:class:`~django.contrib.syndication.feeds.Feed` classes must subclass -``django.contrib.syndication.feeds.Feed``. They can live anywhere in your -codebase. +Feed classes subclass :class:`django.contrib.syndication.views.Feed`. +They can live anywhere in your codebase. + +Instances of :class:`~django.contrib.syndication.views.Feed` classes +are views which can be used in your :ref:`URLconf `. A simple example ---------------- @@ -98,10 +62,10 @@ A simple example This simple example, taken from `chicagocrime.org`_, describes a feed of the latest five news items:: - from django.contrib.syndication.feeds import Feed + from django.contrib.syndication.views import Feed from chicagocrime.models import NewsItem - class LatestEntries(Feed): + class LatestEntriesFeed(Feed): title = "Chicagocrime.org site news" link = "/sitenews/" description = "Updates on changes and additions to chicagocrime.org." @@ -109,9 +73,27 @@ latest five news items:: def items(self): return NewsItem.objects.order_by('-pub_date')[:5] + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.description + +To connect a URL to this feed, put an instance of the Feed object in +your :ref:`URLconf `. For example:: + + from django.conf.urls.defaults import * + from myproject.feeds import LatestEntriesFeed + + urlpatterns = patterns('', + # ... + (r'^latest/feed/$', LatestEntriesFeed()), + # ... + ) + Note: -* The class subclasses ``django.contrib.syndication.feeds.Feed``. +* The Feed class subclasses :class:`django.contrib.syndication.views.Feed`. * :attr:`title`, :attr:`link` and :attr:`description` correspond to the standard RSS ````, ``<link>`` and ``<description>`` elements, @@ -129,17 +111,23 @@ Note: :attr:`subtitle` attribute instead of the :attr:`description` attribute. See `Publishing Atom and RSS feeds in tandem`_, later, for an example. -One thing's left to do. In an RSS feed, each ``<item>`` has a ``<title>``, +One thing is left to do. In an RSS feed, each ``<item>`` has a ``<title>``, ``<link>`` and ``<description>``. We need to tell the framework what data to put into those elements. - * To specify the contents of ``<title>`` and ``<description>``, create - :ref:`Django templates <topics-templates>` called - :file:`feeds/latest_title.html` and - :file:`feeds/latest_description.html`, where :attr:`latest` is the - :attr:`slug` specified in the URLconf for the given feed. Note the - ``.html`` extension is required. The RSS system renders that template for - each item, passing it two template context variables: + * For the contents of ``<title>`` and ``<description>``, Django tries + calling the methods :meth:`item_title()` and :meth:`item_description()` on + the :class:`~django.contrib.syndication.views.Feed` class. They are passed + a single parameter, :attr:`item`, which is the object itself. These are + optional; by default, the unicode representation of the object is used for + both. + + If you want to do any special formatting for either the title or + description, :ref:`Django templates <topics-templates>` can be used + instead. Their paths can be specified with the ``title_template`` and + ``description_template`` attributes on the + :class:`~django.contrib.syndication.views.Feed` class. The templates are + rendered for each item and are passed two template context variables: * ``{{ obj }}`` -- The current object (one of whichever objects you returned in :meth:`items()`). @@ -152,152 +140,102 @@ into those elements. :ref:`RequestSite section of the sites framework documentation <requestsite-objects>` for more. - If you don't create a template for either the title or description, the - framework will use the template ``"{{ obj }}"`` by default -- that is, the - normal string representation of the object. You can also change the names - of these two templates by specifying ``title_template`` and - ``description_template`` as attributes of your - :class:`~django.contrib.syndication.feeds.Feed` class. + See `a complex example`_ below that uses a description template. * To specify the contents of ``<link>``, you have two options. For each item - in :meth:`items()`, Django first tries calling a method - :meth:`item_link()` in the :class:`~django.contrib.syndication.feeds.Feed` - class, passing it a single parameter, :attr:`item`, which is the object - itself. If that method doesn't exist, Django tries executing a - ``get_absolute_url()`` method on that object. . Both - ``get_absolute_url()`` and :meth:`item_link()` should return the item's - URL as a normal Python string. As with ``get_absolute_url()``, the result - of :meth:`item_link()` will be included directly in the URL, so you are - responsible for doing all necessary URL quoting and conversion to ASCII - inside the method itself. - - * For the LatestEntries example above, we could have very simple feed - templates: - - * latest_title.html: - - .. code-block:: html+django - - {{ obj.title }} - - * latest_description.html: - - .. code-block:: html+django - - {{ obj.description }} + in :meth:`items()`, Django first tries calling the + :meth:`item_link()` method on the + :class:`~django.contrib.syndication.views.Feed` class. In a similar way to + the title and description, it is passed it a single parameter, + :attr:`item`. If that method doesn't exist, Django tries executing a + ``get_absolute_url()`` method on that object. Both + :meth:`get_absolute_url()` and :meth:`item_link()` should return the + item's URL as a normal Python string. As with ``get_absolute_url()``, the + result of :meth:`item_link()` will be included directly in the URL, so you + are responsible for doing all necessary URL quoting and conversion to + ASCII inside the method itself. .. _chicagocrime.org: http://www.chicagocrime.org/ A complex example ----------------- -The framework also supports more complex feeds, via parameters. +The framework also supports more complex feeds, via arguments. For example, `chicagocrime.org`_ offers an RSS feed of recent crimes for every police beat in Chicago. It'd be silly to create a separate -:class:`~django.contrib.syndication.feeds.Feed` class for each police beat; that +:class:`~django.contrib.syndication.views.Feed` class for each police beat; that would violate the :ref:`DRY principle <dry>` and would couple data to -programming logic. Instead, the syndication framework lets you make generic -feeds that output items based on information in the feed's URL. +programming logic. Instead, the syndication framework lets you access the +arguments passed from your :ref:`URLconf <topics-http-urls>` so feeds can output +items based on information in the feed's URL. On chicagocrime.org, the police-beat feeds are accessible via URLs like this: - * :file:`/rss/beats/0613/` -- Returns recent crimes for beat 0613. - * :file:`/rss/beats/1424/` -- Returns recent crimes for beat 1424. + * :file:`/beats/613/rss/` -- Returns recent crimes for beat 613. + * :file:`/beats/1424/rss/` -- Returns recent crimes for beat 1424. -The slug here is ``"beats"``. The syndication framework sees the extra URL bits -after the slug -- ``0613`` and ``1424`` -- and gives you a hook to tell it what -those URL bits mean, and how they should influence which items get published in -the feed. +These can be matched with a :ref:`URLconf <topics-http-urls>` line such as:: -An example makes this clear. Here's the code for these beat-specific feeds:: + (r'^beats/(?P<beat_id>\d+)/rss/$', BeatFeed()), - from django.contrib.syndication.feeds import FeedDoesNotExist - from django.core.exceptions import ObjectDoesNotExist +Like a view, the arguments in the URL are passed to the :meth:`get_object()` +method along with the request object. + +.. versionchanged:: 1.2 + Prior to version 1.2, ``get_object()`` only accepted a ``bits`` argument. + +Here's the code for these beat-specific feeds:: + + from django.contrib.syndication.views import FeedDoesNotExist + from django.shortcuts import get_object_or_404 class BeatFeed(Feed): - def get_object(self, bits): - # In case of "/rss/beats/0613/foo/bar/baz/", or other such clutter, - # check that bits has only one member. - if len(bits) != 1: - raise ObjectDoesNotExist - return Beat.objects.get(beat__exact=bits[0]) + description_template = 'feeds/beat_description.html' + + def get_object(self, request, beat_id): + return get_object_or_404(Beat, pk=beat_id) def title(self, obj): return "Chicagocrime.org: Crimes for beat %s" % obj.beat def link(self, obj): - if not obj: - raise FeedDoesNotExist return obj.get_absolute_url() def description(self, obj): return "Crimes recently reported in police beat %s" % obj.beat def items(self, obj): - return Crime.objects.filter(beat__id__exact=obj.id).order_by('-crime_date')[:30] + return Crime.objects.filter(beat=obj).order_by('-crime_date')[:30] -Here's the basic algorithm the RSS framework follows, given this class and a -request to the URL :file:`/rss/beats/0613/`: +To generate the feed's ``<title>``, ``<link>`` and ``<description>``, Django +uses the :meth:`title()`, :meth:`link()` and :meth:`description()` methods. In +the previous example, they were simple string class attributes, but this example +illustrates that they can be either strings *or* methods. For each of +:attr:`title`, :attr:`link` and :attr:`description`, Django follows this +algorithm: - * The framework gets the URL :file:`/rss/beats/0613/` and notices there's an - extra bit of URL after the slug. It splits that remaining string by the - slash character (``"/"``) and calls the - :class:`~django.contrib.syndication.feeds.Feed` class' - :meth:`get_object()` method, passing it the bits. In this case, bits is - ``['0613']``. For a request to :file:`/rss/beats/0613/foo/bar/`, bits - would be ``['0613', 'foo', 'bar']``. + * First, it tries to call a method, passing the ``obj`` argument, where + ``obj`` is the object returned by :meth:`get_object()`. - * :meth:`get_object()` is responsible for retrieving the given beat, from - the given ``bits``. In this case, it uses the Django database API to - retrieve the beat. Note that :meth:`get_object()` should raise - :exc:`django.core.exceptions.ObjectDoesNotExist` if given invalid - parameters. There's no ``try``/``except`` around the - ``Beat.objects.get()`` call, because it's not necessary; that function - raises :exc:`Beat.DoesNotExist` on failure, and :exc:`Beat.DoesNotExist` - is a subclass of :exc:`ObjectDoesNotExist`. Raising - :exc:`ObjectDoesNotExist` in :meth:`get_object()` tells Django to produce - a 404 error for that request. + * Failing that, it tries to call a method with no arguments. - .. versionadded:: 1.0 - :meth:`get_object()` can handle the :file:`/rss/beats/` url. + * Failing that, it uses the class attribute. - The :meth:`get_object()` method also has a chance to handle the - :file:`/rss/beats/` url. In this case, :data:`bits` will be an - empty list. In our example, ``len(bits) != 1`` and an - :exc:`ObjectDoesNotExist` exception will be raised, so - :file:`/rss/beats/` will generate a 404 page. But you can handle this case - however you like. For example, you could generate a combined feed for all - beats. +Also note that :meth:`items()` also follows the same algorithm -- first, it +tries :meth:`items(obj)`, then :meth:`items()`, then finally an :attr:`items` +class attribute (which should be a list). - * To generate the feed's ``<title>``, ``<link>`` and ``<description>``, - Django uses the :meth:`title()`, :meth:`link()` and :meth:`description()` - methods. In the previous example, they were simple string class - attributes, but this example illustrates that they can be either strings - *or* methods. For each of :attr:`title`, :attr:`link` and - :attr:`description`, Django follows this algorithm: +We are using a template for the item descriptions. It can be very simple: - * First, it tries to call a method, passing the ``obj`` argument, where - ``obj`` is the object returned by :meth:`get_object()`. +.. code-block:: html+django - * Failing that, it tries to call a method with no arguments. + {{ obj.description }} - * Failing that, it uses the class attribute. - - Inside the :meth:`link()` method, we handle the possibility that ``obj`` - might be ``None``, which can occur when the URL isn't fully specified. In - some cases, you might want to do something else in this case, which would - mean you'd need to check for ``obj`` existing in other methods as well. - (The :meth:`link()` method is called very early in the feed generation - process, so it's a good place to bail out early.) - - * Finally, note that :meth:`items()` in this example also takes the ``obj`` - argument. The algorithm for :attr:`items` is the same as described in the - previous step -- first, it tries :meth:`items(obj)`, then :meth:`items()`, - then finally an :attr:`items` class attribute (which should be a list). +However, you are free to add formatting as desired. The ``ExampleFeed`` class below gives full documentation on methods and -attributes of :class:`~django.contrib.syndication.feeds.Feed` classes. +attributes of :class:`~django.contrib.syndication.views.Feed` classes. Specifying the type of feed --------------------------- @@ -305,7 +243,7 @@ Specifying the type of feed By default, feeds produced in this framework use RSS 2.0. To change that, add a ``feed_type`` attribute to your -:class:`~django.contrib.syndication.feeds.Feed` class, like so:: +:class:`~django.contrib.syndication.views.Feed` class, like so:: from django.utils.feedgenerator import Atom1Feed @@ -353,13 +291,13 @@ Publishing Atom and RSS feeds in tandem Some developers like to make available both Atom *and* RSS versions of their feeds. That's easy to do with Django: Just create a subclass of your -:class:`~django.contrib.syndication.feeds.Feed` +:class:`~django.contrib.syndication.views.Feed` class and set the :attr:`feed_type` to something different. Then update your URLconf to add the extra versions. Here's a full example:: - from django.contrib.syndication.feeds import Feed + from django.contrib.syndication.views import Feed from chicagocrime.models import NewsItem from django.utils.feedgenerator import Atom1Feed @@ -381,7 +319,7 @@ Here's a full example:: a feed-level "description," but they *do* provide for a "subtitle." If you provide a :attr:`description` in your - :class:`~django.contrib.syndication.feeds.Feed` class, Django will *not* + :class:`~django.contrib.syndication.views.Feed` class, Django will *not* automatically put that into the :attr:`subtitle` element, because a subtitle and description are not necessarily the same thing. Instead, you should define a :attr:`subtitle` attribute. @@ -394,56 +332,50 @@ And the accompanying URLconf:: from django.conf.urls.defaults import * from myproject.feeds import RssSiteNewsFeed, AtomSiteNewsFeed - feeds = { - 'rss': RssSiteNewsFeed, - 'atom': AtomSiteNewsFeed, - } - urlpatterns = patterns('', # ... - (r'^feeds/(?P<url>.*)/$', 'django.contrib.syndication.views.feed', - {'feed_dict': feeds}), + (r'^sitenews/rss/$', RssSiteNewsFeed()), + (r'^sitenews/atom/$', AtomSiteNewsFeed()), # ... ) Feed class reference -------------------- -.. class:: django.contrib.syndication.feeds.Feed +.. class:: django.contrib.syndication.views.Feed This example illustrates all possible attributes and methods for a -:class:`~django.contrib.syndication.feeds.Feed` class:: +:class:`~django.contrib.syndication.views.Feed` class:: - from django.contrib.syndication.feeds import Feed + from django.contrib.syndication.views import Feed from django.utils import feedgenerator class ExampleFeed(Feed): # FEED TYPE -- Optional. This should be a class that subclasses - # django.utils.feedgenerator.SyndicationFeed. This designates which - # type of feed this should be: RSS 2.0, Atom 1.0, etc. - # If you don't specify feed_type, your feed will be RSS 2.0. - # This should be a class, not an instance of the class. + # django.utils.feedgenerator.SyndicationFeed. This designates + # which type of feed this should be: RSS 2.0, Atom 1.0, etc. If + # you don't specify feed_type, your feed will be RSS 2.0. This + # should be a class, not an instance of the class. feed_type = feedgenerator.Rss201rev2Feed - # TEMPLATE NAMES -- Optional. These should be strings representing - # names of Django templates that the system should use in rendering the - # title and description of your feed items. Both are optional. - # If you don't specify one, or either, Django will use the template - # 'feeds/SLUG_title.html' and 'feeds/SLUG_description.html', where SLUG - # is the slug you specify in the URL. + # TEMPLATE NAMES -- Optional. These should be strings + # representing names of Django templates that the system should + # use in rendering the title and description of your feed items. + # Both are optional. If a template is not specified, the + # item_title() or item_description() methods are used instead. title_template = None description_template = None - # TITLE -- One of the following three is required. The framework looks - # for them in this order. + # TITLE -- One of the following three is required. The framework + # looks for them in this order. def title(self, obj): """ - Takes the object returned by get_object() and returns the feed's - title as a normal Python string. + Takes the object returned by get_object() and returns the + feed's title as a normal Python string. """ def title(self): @@ -453,13 +385,13 @@ This example illustrates all possible attributes and methods for a title = 'foo' # Hard-coded title. - # LINK -- One of the following three is required. The framework looks - # for them in this order. + # LINK -- One of the following three is required. The framework + # looks for them in this order. def link(self, obj): """ - Takes the object returned by get_object() and returns the feed's - link as a normal Python string. + # Takes the object returned by get_object() and returns the feed's + # link as a normal Python string. """ def link(self): @@ -572,18 +504,18 @@ This example illustrates all possible attributes and methods for a # COPYRIGHT NOTICE -- One of the following three is optional. The # framework looks for them in this order. - def copyright(self, obj): + def feed_copyright(self, obj): """ Takes the object returned by get_object() and returns the feed's copyright notice as a normal Python string. """ - def copyright(self): + def feed_copyright(self): """ Returns the feed's copyright notice as a normal Python string. """ - copyright = 'Copyright (c) 2007, Sally Smith' # Hard-coded copyright notice. + feed_copyright = 'Copyright (c) 2007, Sally Smith' # Hard-coded copyright notice. # TTL -- One of the following three is optional. The framework looks # for them in this order. Ignored for Atom feeds. @@ -620,13 +552,44 @@ This example illustrates all possible attributes and methods for a # GET_OBJECT -- This is required for feeds that publish different data # for different URL parameters. (See "A complex example" above.) - def get_object(self, bits): + def get_object(self, request, *args, **kwargs): """ - Takes a list of strings gleaned from the URL and returns an object - represented by this feed. Raises + Takes the current request and the arguments from the URL, and + returns an object represented by this feed. Raises django.core.exceptions.ObjectDoesNotExist on error. """ + # ITEM TITLE AND DESCRIPTION -- If title_template or + # description_template are not defined, these are used instead. Both are + # optional, by default they will use the unicode representation of the + # item. + + def item_title(self, item): + """ + Takes an item, as returned by items(), and returns the item's + title as a normal Python string. + """ + + def item_title(self): + """ + Returns the title for every item in the feed. + """ + + item_title = 'Breaking News: Nothing Happening' # Hard-coded title. + + def item_description(self, item): + """ + Takes an item, as returned by items(), and returns the item's + description as a normal Python string. + """ + + def item_description(self): + """ + Returns the description for every item in the feed. + """ + + item_description = 'A description of the item.' # Hard-coded description. + # ITEM LINK -- One of these three is required. The framework looks for # them in this order. @@ -686,7 +649,7 @@ This example illustrates all possible attributes and methods for a item_author_email = 'test@example.com' # Hard-coded author e-mail. - # ITEM AUTHOR LINK --One of the following three is optional. The + # ITEM AUTHOR LINK -- One of the following three is optional. The # framework looks for them in this order. In each case, the URL should # include the "http://" and domain name. # diff --git a/docs/releases/1.2.txt b/docs/releases/1.2.txt index a7660ae922..f4826787c2 100644 --- a/docs/releases/1.2.txt +++ b/docs/releases/1.2.txt @@ -386,6 +386,87 @@ approach. Old style function-based test runners will still work, but should be updated to use the new :ref:`class-based runners <topics-testing-test_runner>`. +.. _1.2-updating-feeds: + +``Feed`` in ``django.contrib.syndication.feeds`` +------------------------------------------------ + +The :class:`django.contrib.syndication.feeds.Feed` class has been +replaced by the :class:`django.contrib.syndication.views.Feed` class. +The old ``feeds.Feed`` class is deprecated, and will be removed in +Django 1.4. + +The new class has an almost identical API, but allows instances to be +used as views. For example, consider the use of the old framework in +the following :ref:`URLconf <topics-http-urls>`:: + + from django.conf.urls.defaults import * + from myproject.feeds import LatestEntries, LatestEntriesByCategory + + feeds = { + 'latest': LatestEntries, + 'categories': LatestEntriesByCategory, + } + + urlpatterns = patterns('', + # ... + (r'^feeds/(?P<url>.*)/$', 'django.contrib.syndication.views.feed', + {'feed_dict': feeds}), + # ... + ) + +Using the new Feed class, these feeds can be deployed directly as views:: + + from django.conf.urls.defaults import * + from myproject.feeds import LatestEntries, LatestEntriesByCategory + + urlpatterns = patterns('', + # ... + (r'^feeds/latest/$', LatestEntries()), + (r'^feeds/categories/(?P<category_id>\d+)/$', LatestEntriesByCategory()), + # ... + ) + +If you currently use the ``feed()`` view, the ``LatestEntries`` class +would not need to be modified apart from subclassing the new +:class:`~django.contrib.syndication.views.Feed` class. + +However, ``LatestEntriesByCategory`` uses the ``get_object()`` method +with the ``bits`` argument to specify a specific category to show. In +the new :class:`~django.contrib.syndication.views.Feed` class, +``get_object()`` method takes a ``request`` and arguments from the +URL, so it would look like this:: + + from django.contrib.syndication.views import Feed + from django.shortcuts import get_object_or_404 + from myproject.models import Category + + class LatestEntriesByCategory(Feed): + def get_object(self, request, category_id): + return get_object_or_404(Category, id=category_id) + + # ... + +Additionally, the ``get_feed()`` method on ``Feed`` classes now take +different arguments, which may impact you if you use the ``Feed`` +classes directly. Instead of just taking an optional ``url`` argument, +it now takes two arguments: the object returned by its own +``get_object()`` method, and the current ``request`` object. + +To take into account ``Feed`` classes not being initialized for each +request, the ``__init__()`` method now takes no arguments by default. +Previously it would have taken the ``slug`` from the URL and the +``request`` object. + +In accordance with `RSS best practices`_, RSS feeds will now include +an ``atom:link`` element. You may need to update your tests to take +this into account. + +For more information, see the full :ref:`syndication framework +documentation <ref-contrib-syndication>`. + +.. _RSS best practices: http://www.rssboard.org/rss-profile + What's new in Django 1.2 ======================== @@ -556,7 +637,7 @@ Object-level permissions A foundation for specifying permissions at the per-object level has been added. Although there is no implementation of this in core, a custom authentication backend can provide this implementation and it will be used by -:class:`django.contrib.auth.models.User`. See the :ref:`authentication docs +:class:`django.contrib.auth.models.User`. See the :ref:`authentication docs <topics-auth>` for more information. Permissions for anonymous users @@ -568,3 +649,12 @@ User already did. This is useful for centralizing permission handling - apps can always delegate the question of whether something is allowed or not to the authorization/authentication backend. See the :ref:`authentication docs <topics-auth>` for more details. + +Syndication feeds as views +-------------------------- + +:ref:`Syndication feeds <ref-contrib-syndication>` can now be used directly as +views in your :ref:`URLconf <topics-http-urls>`. This means that you can +maintain complete control over the URL structure of your feeds. Like any other view, feeds views are passed a ``request`` object, so you can +do anything you would normally do with a view, like user based access control, +or making a feed a named URL. diff --git a/tests/regressiontests/syndication/feeds.py b/tests/regressiontests/syndication/feeds.py index 79837f9459..5563170d6f 100644 --- a/tests/regressiontests/syndication/feeds.py +++ b/tests/regressiontests/syndication/feeds.py @@ -1,66 +1,142 @@ +from django.contrib.syndication import feeds, views from django.core.exceptions import ObjectDoesNotExist -from django.contrib.syndication import feeds -from django.utils.feedgenerator import Atom1Feed -from django.utils import tzinfo +from django.utils import feedgenerator, tzinfo +from models import Article, Entry -class ComplexFeed(feeds.Feed): - def get_object(self, bits): - if len(bits) != 1: + +class ComplexFeed(views.Feed): + def get_object(self, request, foo=None): + if foo is not None: raise ObjectDoesNotExist return None -class TestRssFeed(feeds.Feed): - link = "/blog/" + +class TestRss2Feed(views.Feed): title = 'My blog' - + description = 'A more thorough description of my blog.' + link = '/blog/' + feed_guid = '/foo/bar/1234' + author_name = 'Sally Smith' + author_email = 'test@example.com' + author_link = 'http://www.example.com/' + categories = ('python', 'django') + feed_copyright = 'Copyright (c) 2007, Sally Smith' + ttl = 600 + def items(self): - from models import Entry return Entry.objects.all() - - def item_link(self, item): - return "/blog/%s/" % item.pk -class TestAtomFeed(TestRssFeed): - feed_type = Atom1Feed + def item_description(self, item): + return "Overridden description: %s" % item -class MyCustomAtom1Feed(Atom1Feed): + def item_pubdate(self, item): + return item.date + + item_author_name = 'Sally Smith' + item_author_email = 'test@example.com' + item_author_link = 'http://www.example.com/' + item_categories = ('python', 'testing') + item_copyright = 'Copyright (c) 2007, Sally Smith' + + +class TestRss091Feed(TestRss2Feed): + feed_type = feedgenerator.RssUserland091Feed + + +class TestAtomFeed(TestRss2Feed): + feed_type = feedgenerator.Atom1Feed + subtitle = TestRss2Feed.description + + +class ArticlesFeed(TestRss2Feed): """ - Test of a custom feed generator class. - """ - def root_attributes(self): - attrs = super(MyCustomAtom1Feed, self).root_attributes() - attrs[u'django'] = u'rocks' - return attrs - - def add_root_elements(self, handler): - super(MyCustomAtom1Feed, self).add_root_elements(handler) - handler.addQuickElement(u'spam', u'eggs') - - def item_attributes(self, item): - attrs = super(MyCustomAtom1Feed, self).item_attributes(item) - attrs[u'bacon'] = u'yum' - return attrs - - def add_item_elements(self, handler, item): - super(MyCustomAtom1Feed, self).add_item_elements(handler, item) - handler.addQuickElement(u'ministry', u'silly walks') - -class TestCustomFeed(TestAtomFeed): - feed_type = MyCustomAtom1Feed - + A feed to test no link being defined. Articles have no get_absolute_url() + method, and item_link() is not defined. + """ + def items(self): + return Article.objects.all() + + +class TestEnclosureFeed(TestRss2Feed): + pass + + +class TemplateFeed(TestRss2Feed): + """ + A feed to test defining item titles and descriptions with templates. + """ + title_template = 'syndication/title.html' + description_template = 'syndication/description.html' + + # Defining a template overrides any item_title definition + def item_title(self): + return "Not in a template" + + class NaiveDatesFeed(TestAtomFeed): """ A feed with naive (non-timezone-aware) dates. """ def item_pubdate(self, item): return item.date - + + class TZAwareDatesFeed(TestAtomFeed): """ A feed with timezone-aware dates. """ def item_pubdate(self, item): # Provide a weird offset so that the test can know it's getting this - # specific offset and not accidentally getting on from + # specific offset and not accidentally getting on from # settings.TIME_ZONE. - return item.date.replace(tzinfo=tzinfo.FixedOffset(42)) \ No newline at end of file + return item.date.replace(tzinfo=tzinfo.FixedOffset(42)) + + +class TestFeedUrlFeed(TestAtomFeed): + feed_url = 'http://example.com/customfeedurl/' + + +class MyCustomAtom1Feed(feedgenerator.Atom1Feed): + """ + Test of a custom feed generator class. + """ + def root_attributes(self): + attrs = super(MyCustomAtom1Feed, self).root_attributes() + attrs[u'django'] = u'rocks' + return attrs + + def add_root_elements(self, handler): + super(MyCustomAtom1Feed, self).add_root_elements(handler) + handler.addQuickElement(u'spam', u'eggs') + + def item_attributes(self, item): + attrs = super(MyCustomAtom1Feed, self).item_attributes(item) + attrs[u'bacon'] = u'yum' + return attrs + + def add_item_elements(self, handler, item): + super(MyCustomAtom1Feed, self).add_item_elements(handler, item) + handler.addQuickElement(u'ministry', u'silly walks') + + +class TestCustomFeed(TestAtomFeed): + feed_type = MyCustomAtom1Feed + + +class DeprecatedComplexFeed(feeds.Feed): + def get_object(self, bits): + if len(bits) != 1: + raise ObjectDoesNotExist + return None + + +class DeprecatedRssFeed(feeds.Feed): + link = "/blog/" + title = 'My blog' + + def items(self): + return Entry.objects.all() + + def item_link(self, item): + return "/blog/%s/" % item.pk + diff --git a/tests/regressiontests/syndication/fixtures/feeddata.json b/tests/regressiontests/syndication/fixtures/feeddata.json index 375ee16c41..4a5c02297a 100644 --- a/tests/regressiontests/syndication/fixtures/feeddata.json +++ b/tests/regressiontests/syndication/fixtures/feeddata.json @@ -30,5 +30,13 @@ "title": "A & B < C > D", "date": "2008-01-03 13:30:00" } + }, + { + "model": "syndication.article", + "pk": 1, + "fields": { + "title": "My first article", + "entry": "1" + } } -] \ No newline at end of file +] diff --git a/tests/regressiontests/syndication/models.py b/tests/regressiontests/syndication/models.py index 99e14ade8a..54230b9623 100644 --- a/tests/regressiontests/syndication/models.py +++ b/tests/regressiontests/syndication/models.py @@ -3,6 +3,21 @@ from django.db import models class Entry(models.Model): title = models.CharField(max_length=200) date = models.DateTimeField() - + + class Meta: + ordering = ('date',) + def __unicode__(self): - return self.title \ No newline at end of file + return self.title + + def get_absolute_url(self): + return "/blog/%s/" % self.pk + + +class Article(models.Model): + title = models.CharField(max_length=200) + entry = models.ForeignKey(Entry) + + def __unicode__(self): + return self.title + diff --git a/tests/regressiontests/syndication/templates/syndication/description.html b/tests/regressiontests/syndication/templates/syndication/description.html new file mode 100644 index 0000000000..85ec82c8df --- /dev/null +++ b/tests/regressiontests/syndication/templates/syndication/description.html @@ -0,0 +1 @@ +Description in your templates: {{ obj }} \ No newline at end of file diff --git a/tests/regressiontests/syndication/templates/syndication/title.html b/tests/regressiontests/syndication/templates/syndication/title.html new file mode 100644 index 0000000000..eb17969714 --- /dev/null +++ b/tests/regressiontests/syndication/templates/syndication/title.html @@ -0,0 +1 @@ +Title in your templates: {{ obj }} \ No newline at end of file diff --git a/tests/regressiontests/syndication/tests.py b/tests/regressiontests/syndication/tests.py index 816cb44675..8949bc9dc9 100644 --- a/tests/regressiontests/syndication/tests.py +++ b/tests/regressiontests/syndication/tests.py @@ -1,17 +1,17 @@ -# -*- coding: utf-8 -*- - import datetime -from xml.dom import minidom +from django.contrib.syndication import feeds, views +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase -from django.test.client import Client from django.utils import tzinfo from models import Entry +from xml.dom import minidom + try: set except NameError: from sets import Set as set -class SyndicationFeedTest(TestCase): +class FeedTestCase(TestCase): fixtures = ['feeddata.json'] def assertChildNodes(self, elem, expected): @@ -19,101 +19,300 @@ class SyndicationFeedTest(TestCase): expected = set(expected) self.assertEqual(actual, expected) - def test_rss_feed(self): - response = self.client.get('/syndication/feeds/rss/') + def assertChildNodeContent(self, elem, expected): + for k, v in expected.items(): + self.assertEqual( + elem.getElementsByTagName(k)[0].firstChild.wholeText, v) + + def assertCategories(self, elem, expected): + self.assertEqual(set(i.firstChild.wholeText for i in elem.childNodes if i.nodeName == 'category'), set(expected)); + +###################################### +# Feed view +###################################### + +class SyndicationFeedTest(FeedTestCase): + """ + Tests for the high-level syndication feed framework. + """ + + def test_rss2_feed(self): + """ + Test the structure and content of feeds generated by Rss201rev2Feed. + """ + response = self.client.get('/syndication/rss2/') doc = minidom.parseString(response.content) - + # Making sure there's only 1 `rss` element and that the correct # RSS version was specified. feed_elem = doc.getElementsByTagName('rss') self.assertEqual(len(feed_elem), 1) feed = feed_elem[0] self.assertEqual(feed.getAttribute('version'), '2.0') - + # Making sure there's only one `channel` element w/in the # `rss` element. chan_elem = feed.getElementsByTagName('channel') self.assertEqual(len(chan_elem), 1) chan = chan_elem[0] - self.assertChildNodes(chan, ['title', 'link', 'description', 'language', 'lastBuildDate', 'item']) - + self.assertChildNodes(chan, ['title', 'link', 'description', 'language', 'lastBuildDate', 'item', 'atom:link', 'ttl', 'copyright', 'category']) + self.assertChildNodeContent(chan, { + 'title': 'My blog', + 'description': 'A more thorough description of my blog.', + 'link': 'http://example.com/blog/', + 'language': 'en', + 'lastBuildDate': 'Thu, 03 Jan 2008 13:30:00 -0600', + #'atom:link': '', + 'ttl': '600', + 'copyright': 'Copyright (c) 2007, Sally Smith', + }) + self.assertCategories(chan, ['python', 'django']); + + # Ensure the content of the channel is correct + self.assertChildNodeContent(chan, { + 'title': 'My blog', + 'link': 'http://example.com/blog/', + }) + + # Check feed_url is passed + self.assertEqual( + chan.getElementsByTagName('atom:link')[0].getAttribute('href'), + 'http://example.com/syndication/rss2/' + ) + items = chan.getElementsByTagName('item') self.assertEqual(len(items), Entry.objects.count()) + self.assertChildNodeContent(items[0], { + 'title': 'My first entry', + 'description': 'Overridden description: My first entry', + 'link': 'http://example.com/blog/1/', + 'guid': 'http://example.com/blog/1/', + 'pubDate': 'Tue, 01 Jan 2008 12:30:00 -0600', + 'author': 'test@example.com (Sally Smith)', + }) + self.assertCategories(items[0], ['python', 'testing']); + for item in items: - self.assertChildNodes(item, ['title', 'link', 'description', 'guid']) - - def test_atom_feed(self): - response = self.client.get('/syndication/feeds/atom/') + self.assertChildNodes(item, ['title', 'link', 'description', 'guid', 'category', 'pubDate', 'author']) + + def test_rss091_feed(self): + """ + Test the structure and content of feeds generated by RssUserland091Feed. + """ + response = self.client.get('/syndication/rss091/') doc = minidom.parseString(response.content) - - feed = doc.firstChild + + # Making sure there's only 1 `rss` element and that the correct + # RSS version was specified. + feed_elem = doc.getElementsByTagName('rss') + self.assertEqual(len(feed_elem), 1) + feed = feed_elem[0] + self.assertEqual(feed.getAttribute('version'), '0.91') + + # Making sure there's only one `channel` element w/in the + # `rss` element. + chan_elem = feed.getElementsByTagName('channel') + self.assertEqual(len(chan_elem), 1) + chan = chan_elem[0] + self.assertChildNodes(chan, ['title', 'link', 'description', 'language', 'lastBuildDate', 'item', 'atom:link', 'ttl', 'copyright', 'category']) + + # Ensure the content of the channel is correct + self.assertChildNodeContent(chan, { + 'title': 'My blog', + 'link': 'http://example.com/blog/', + }) + self.assertCategories(chan, ['python', 'django']) + + # Check feed_url is passed + self.assertEqual( + chan.getElementsByTagName('atom:link')[0].getAttribute('href'), + 'http://example.com/syndication/rss091/' + ) + + items = chan.getElementsByTagName('item') + self.assertEqual(len(items), Entry.objects.count()) + self.assertChildNodeContent(items[0], { + 'title': 'My first entry', + 'description': 'Overridden description: My first entry', + 'link': 'http://example.com/blog/1/', + }) + for item in items: + self.assertChildNodes(item, ['title', 'link', 'description']) + self.assertCategories(item, []) + + def test_atom_feed(self): + """ + Test the structure and content of feeds generated by Atom1Feed. + """ + response = self.client.get('/syndication/atom/') + feed = minidom.parseString(response.content).firstChild + self.assertEqual(feed.nodeName, 'feed') - self.assertEqual(feed.getAttribute('xmlns'), 'http://www.w3.org/2005/Atom') - self.assertChildNodes(feed, ['title', 'link', 'id', 'updated', 'entry']) - + self.assertEqual(feed.getAttribute('xmlns'), 'http://www.w3.org/2005/Atom') + self.assertChildNodes(feed, ['title', 'subtitle', 'link', 'id', 'updated', 'entry', 'rights', 'category', 'author']) + for link in feed.getElementsByTagName('link'): + if link.getAttribute('rel') == 'self': + self.assertEqual(link.getAttribute('href'), 'http://example.com/syndication/atom/') + entries = feed.getElementsByTagName('entry') self.assertEqual(len(entries), Entry.objects.count()) for entry in entries: - self.assertChildNodes(entry, ['title', 'link', 'id', 'summary']) + self.assertChildNodes(entry, ['title', 'link', 'id', 'summary', 'category', 'updated', 'rights', 'author']) summary = entry.getElementsByTagName('summary')[0] self.assertEqual(summary.getAttribute('type'), 'html') - + def test_custom_feed_generator(self): - response = self.client.get('/syndication/feeds/custom/') - doc = minidom.parseString(response.content) - - feed = doc.firstChild + response = self.client.get('/syndication/custom/') + feed = minidom.parseString(response.content).firstChild + self.assertEqual(feed.nodeName, 'feed') self.assertEqual(feed.getAttribute('django'), 'rocks') - self.assertChildNodes(feed, ['title', 'link', 'id', 'updated', 'entry', 'spam']) - + self.assertChildNodes(feed, ['title', 'subtitle', 'link', 'id', 'updated', 'entry', 'spam', 'rights', 'category', 'author']) + entries = feed.getElementsByTagName('entry') 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']) + self.assertChildNodes(entry, ['title', 'link', 'id', 'summary', 'ministry', 'rights', 'author', 'updated', 'category']) summary = entry.getElementsByTagName('summary')[0] self.assertEqual(summary.getAttribute('type'), 'html') - - def test_complex_base_url(self): - """ - Tests that that the base url for a complex feed doesn't raise a 500 - exception. - """ - response = self.client.get('/syndication/feeds/complex/') - self.assertEquals(response.status_code, 404) def test_title_escaping(self): """ Tests that titles are escaped correctly in RSS feeds. """ - response = self.client.get('/syndication/feeds/rss/') + response = self.client.get('/syndication/rss2/') doc = minidom.parseString(response.content) for item in doc.getElementsByTagName('item'): link = item.getElementsByTagName('link')[0] if link.firstChild.wholeText == 'http://example.com/blog/4/': title = item.getElementsByTagName('title')[0] self.assertEquals(title.firstChild.wholeText, u'A & B < C > D') - + def test_naive_datetime_conversion(self): """ Test that datetimes are correctly converted to the local time zone. """ # Naive date times passed in get converted to the local time zone, so # check the recived zone offset against the local offset. - response = self.client.get('/syndication/feeds/naive-dates/') + response = self.client.get('/syndication/naive-dates/') doc = minidom.parseString(response.content) - updated = doc.getElementsByTagName('updated')[0].firstChild.wholeText + updated = doc.getElementsByTagName('updated')[0].firstChild.wholeText tz = tzinfo.LocalTimezone(datetime.datetime.now()) now = datetime.datetime.now(tz) self.assertEqual(updated[-6:], str(now)[-6:]) - + def test_aware_datetime_conversion(self): """ Test that datetimes with timezones don't get trodden on. """ - response = self.client.get('/syndication/feeds/aware-dates/') + 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') - \ No newline at end of file + + def test_feed_url(self): + """ + Test that the feed_url can be overridden. + """ + response = self.client.get('/syndication/feedurl/') + doc = minidom.parseString(response.content) + for link in doc.getElementsByTagName('link'): + if link.getAttribute('rel') == 'self': + self.assertEqual(link.getAttribute('href'), 'http://example.com/customfeedurl/') + + def test_item_link_error(self): + """ + Test that a ImproperlyConfigured is raised if no link could be found + for the item(s). + """ + self.assertRaises(ImproperlyConfigured, + self.client.get, + '/syndication/articles/') + + def test_template_feed(self): + """ + Test that the item title and description can be overridden with + templates. + """ + response = self.client.get('/syndication/template/') + doc = minidom.parseString(response.content) + feed = doc.getElementsByTagName('rss')[0] + chan = feed.getElementsByTagName('channel')[0] + items = chan.getElementsByTagName('item') + + self.assertChildNodeContent(items[0], { + 'title': 'Title in your templates: My first entry', + 'description': 'Description in your templates: My first entry', + 'link': 'http://example.com/blog/1/', + }) + + def test_add_domain(self): + """ + Test add_domain() prefixes domains onto the correct URLs. + """ + self.assertEqual( + views.add_domain('example.com', '/foo/?arg=value'), + 'http://example.com/foo/?arg=value' + ) + self.assertEqual( + views.add_domain('example.com', 'http://djangoproject.com/doc/'), + 'http://djangoproject.com/doc/' + ) + self.assertEqual( + views.add_domain('example.com', 'https://djangoproject.com/doc/'), + 'https://djangoproject.com/doc/' + ) + self.assertEqual( + views.add_domain('example.com', 'mailto:uhoh@djangoproject.com'), + 'mailto:uhoh@djangoproject.com' + ) + + +###################################### +# Deprecated feeds +###################################### + +class DeprecatedSyndicationFeedTest(FeedTestCase): + """ + Tests for the deprecated API (feed() view and the feed_dict etc). + """ + + def test_empty_feed_dict(self): + """ + Test that an empty feed_dict raises a 404. + """ + response = self.client.get('/syndication/depr-feeds-empty/aware-dates/') + self.assertEquals(response.status_code, 404) + + def test_nonexistent_slug(self): + """ + Test that a non-existent slug raises a 404. + """ + response = self.client.get('/syndication/depr-feeds/foobar/') + self.assertEquals(response.status_code, 404) + + def test_rss_feed(self): + """ + A simple test for Rss201rev2Feed feeds generated by the deprecated + system. + """ + response = self.client.get('/syndication/depr-feeds/rss/') + doc = minidom.parseString(response.content) + feed = doc.getElementsByTagName('rss')[0] + self.assertEqual(feed.getAttribute('version'), '2.0') + + chan = feed.getElementsByTagName('channel')[0] + self.assertChildNodes(chan, ['title', 'link', 'description', 'language', 'lastBuildDate', 'item', 'atom:link']) + + items = chan.getElementsByTagName('item') + self.assertEqual(len(items), Entry.objects.count()) + + def test_complex_base_url(self): + """ + Tests that the base url for a complex feed doesn't raise a 500 + exception. + """ + response = self.client.get('/syndication/depr-feeds/complex/') + self.assertEquals(response.status_code, 404) + diff --git a/tests/regressiontests/syndication/urls.py b/tests/regressiontests/syndication/urls.py index ec45026fc4..881fa48006 100644 --- a/tests/regressiontests/syndication/urls.py +++ b/tests/regressiontests/syndication/urls.py @@ -1,14 +1,24 @@ +from django.conf.urls.defaults import * + import feeds -from django.conf.urls.defaults import patterns feed_dict = { - 'complex': feeds.ComplexFeed, - 'rss': feeds.TestRssFeed, - 'atom': feeds.TestAtomFeed, - 'custom': feeds.TestCustomFeed, - 'naive-dates': feeds.NaiveDatesFeed, - 'aware-dates': feeds.TZAwareDatesFeed, + 'complex': feeds.DeprecatedComplexFeed, + 'rss': feeds.DeprecatedRssFeed, } -urlpatterns = patterns('', - (r'^feeds/(?P<url>.*)/$', 'django.contrib.syndication.views.feed', {'feed_dict': feed_dict}) + +urlpatterns = patterns('django.contrib.syndication.views', + (r'^complex/(?P<foo>.*)/$', feeds.ComplexFeed()), + (r'^rss2/$', feeds.TestRss2Feed()), + (r'^rss091/$', feeds.TestRss091Feed()), + (r'^atom/$', feeds.TestAtomFeed()), + (r'^custom/$', feeds.TestCustomFeed()), + (r'^naive-dates/$', feeds.NaiveDatesFeed()), + (r'^aware-dates/$', feeds.TZAwareDatesFeed()), + (r'^feedurl/$', feeds.TestFeedUrlFeed()), + (r'^articles/$', feeds.ArticlesFeed()), + (r'^template/$', feeds.TemplateFeed()), + + (r'^depr-feeds/(?P<url>.*)/$', 'feed', {'feed_dict': feed_dict}), + (r'^depr-feeds-empty/(?P<url>.*)/$', 'feed', {'feed_dict': None}), ) diff --git a/tests/regressiontests/utils/feedgenerator.py b/tests/regressiontests/utils/feedgenerator.py new file mode 100644 index 0000000000..5a10de2d1b --- /dev/null +++ b/tests/regressiontests/utils/feedgenerator.py @@ -0,0 +1,63 @@ +import datetime +from unittest import TestCase + +from django.utils import feedgenerator, tzinfo + +class FeedgeneratorTest(TestCase): + """ + Tests for the low-level syndication feed framework. + """ + + def test_get_tag_uri(self): + """ + Test get_tag_uri() correctly generates TagURIs. + """ + self.assertEqual( + feedgenerator.get_tag_uri('http://example.org/foo/bar#headline', datetime.date(2004, 10, 25)), + u'tag:example.org,2004-10-25:/foo/bar/headline') + + def test_get_tag_uri_with_port(self): + """ + Test that get_tag_uri() correctly generates TagURIs from URLs with port + numbers. + """ + self.assertEqual( + feedgenerator.get_tag_uri('http://www.example.org:8000/2008/11/14/django#headline', datetime.datetime(2008, 11, 14, 13, 37, 0)), + u'tag:www.example.org,2008-11-14:/2008/11/14/django/headline') + + def test_rfc2822_date(self): + """ + Test rfc2822_date() correctly formats datetime objects. + """ + self.assertEqual( + feedgenerator.rfc2822_date(datetime.datetime(2008, 11, 14, 13, 37, 0)), + "Fri, 14 Nov 2008 13:37:00 -0000" + ) + + def test_rfc2822_date_with_timezone(self): + """ + Test rfc2822_date() correctly formats datetime objects with tzinfo. + """ + self.assertEqual( + feedgenerator.rfc2822_date(datetime.datetime(2008, 11, 14, 13, 37, 0, tzinfo=tzinfo.FixedOffset(datetime.timedelta(minutes=60)))), + "Fri, 14 Nov 2008 13:37:00 +0100" + ) + + def test_rfc3339_date(self): + """ + Test rfc3339_date() correctly formats datetime objects. + """ + self.assertEqual( + feedgenerator.rfc3339_date(datetime.datetime(2008, 11, 14, 13, 37, 0)), + "2008-11-14T13:37:00Z" + ) + + def test_rfc3339_date_with_timezone(self): + """ + Test rfc3339_date() correctly formats datetime objects with tzinfo. + """ + self.assertEqual( + feedgenerator.rfc3339_date(datetime.datetime(2008, 11, 14, 13, 37, 0, tzinfo=tzinfo.FixedOffset(datetime.timedelta(minutes=120)))), + "2008-11-14T13:37:00+02:00" + ) + diff --git a/tests/regressiontests/utils/tests.py b/tests/regressiontests/utils/tests.py index daae84b6d5..56d41b9ae6 100644 --- a/tests/regressiontests/utils/tests.py +++ b/tests/regressiontests/utils/tests.py @@ -31,6 +31,7 @@ __test__ = { } from dateformat import * +from feedgenerator import * from termcolors import * class TestUtilsHtml(TestCase):