Skip to content

Commit

Permalink
added builtin support for multiple stylesheets
Browse files Browse the repository at this point in the history
  • Loading branch information
bmispelon committed May 15, 2024
1 parent 57a1ee3 commit 647fb20
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 44 deletions.
2 changes: 1 addition & 1 deletion django/contrib/syndication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def get_feed(self, obj, request):
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),
stylesheet=self._get_dynamic_attr("stylesheet", obj),
stylesheets=self._get_dynamic_attr("stylesheets", obj),
**self.feed_extra_kwargs(obj),
)

Expand Down
20 changes: 12 additions & 8 deletions django/utils/feedgenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,24 @@ def __init__(
feed_copyright=None,
feed_guid=None,
ttl=None,
stylesheet=None,
stylesheets=None,
**kwargs,
):
def to_str(s):
return str(s) if s is not None else s

def to_stylesheet(s):
if s is None:
return None
if isinstance(s, Stylesheet):
return s
return Stylesheet(s)
return s if isinstance(s, Stylesheet) else Stylesheet(s)

categories = categories and [str(c) for c in categories]

if stylesheets is None:
pass
elif isinstance(stylesheets, (list, tuple)):
stylesheets = [to_stylesheet(s) for s in stylesheets]
else:
stylesheets = [to_stylesheet(stylesheets)]

self.feed = {
"title": to_str(title),
"link": iri_to_uri(link),
Expand All @@ -151,7 +155,7 @@ def to_stylesheet(s):
"feed_copyright": to_str(feed_copyright),
"id": feed_guid or link,
"ttl": to_str(ttl),
"stylesheet": to_stylesheet(stylesheet),
"stylesheets": stylesheets,
**kwargs,
}
self.items = []
Expand Down Expand Up @@ -314,7 +318,7 @@ def write_items(self, handler):
handler.endElement("item")

def add_stylesheets(self, handler):
if (stylesheet := self.feed["stylesheet"]) is not None:
for stylesheet in self.feed["stylesheets"] or []:
handler.processingInstruction("xml-stylesheet", stylesheet)

def add_root_elements(self, handler):
Expand Down
62 changes: 44 additions & 18 deletions docs/ref/contrib/syndication.txt
Original file line number Diff line number Diff line change
Expand Up @@ -596,23 +596,29 @@ This example illustrates all possible attributes and methods for a

ttl = 600 # Hard-coded Time To Live.

# STYLESHEET -- One of the following three is optional. The framework
# looks for them in this order.
# STYLESHEETS -- Optional. To set, provide one of the following three.
# The framework looks for them in this order.

def stylesheet(self, obj):
def stylesheets(self, obj):
"""
Takes the object returned by get_object() and returns the feed's
stylesheet (an URL string or a Stylesheet instance).
stylesheets (an URL string or a Stylesheet instance).
"""

def stylesheet(self):
def stylesheets(self):
"""
Returns the feed's stylesheet (an URL string or a Stylesheet
instance).
"""

stylesheet = "/link/to/stylesheet.xsl"
stylesheet = feedgenerator.Stylesheet("/link/to/stylesheet.xsl")
# Hardcoded stylesheets (can be a single stylesheet or a list)
stylesheets = "/link/to/stylesheet.xsl"
stylesheets = feedgenerator.Stylesheet("/link/to/stylesheet.xsl")
stylesheets = ["/stylesheet1.xsl", "stylesheet2.xls"]
stylesheets = [
feedgenerator.Stylesheet("/stylesheet1.xsl"),
feedgenerator.Stylesheet("/stylesheet2.xsl"),
]

# ITEMS -- One of the following three is required. The framework looks
# for them in this order.
Expand Down Expand Up @@ -979,15 +985,21 @@ They share this interface:
* ``feed_copyright``
* ``feed_guid``
* ``ttl``
* ``stylesheet``
* ``stylesheets``

Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed`` for use with `custom feed generators`_.

All parameters should be strings, except ``categories``, which should be a
sequence of strings. Beware that some control characters
are `not allowed <https://www.w3.org/International/questions/qa-controls>`_
in XML documents. If your content has some of them, you might encounter a
All parameters should be strings, except for two:

- ``categories`` should be a sequence of strings.
- ``stylesheets`` can be a single string, an instance of
:class:`~django.utils.feedgenerator.Stylesheets`, or a list of either
strings or ``Stylesheet`` instances.

Beware that some control characters are
`not allowed <https://www.w3.org/International/questions/qa-controls>`_ in
XML documents. If your content has some of them, you might encounter a
:exc:`ValueError` when producing the feed.

:meth:`.SyndicationFeed.add_item`
Expand Down Expand Up @@ -1121,7 +1133,7 @@ Feed stylesheets
.. versionadded:: 5.1

You can add styling to your RSS feed using a stylesheet (typically in the XSLT_
or CSS formats) by setting the ``stylesheet`` attribute on the feed class.
or CSS formats) by setting the ``stylesheets`` attribute on the feed class.

This can be a hardcoded URL::

Expand All @@ -1130,7 +1142,7 @@ This can be a hardcoded URL::

class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheet = "https://example.com/rss_stylesheet.xslt"
stylesheets = "https://example.com/rss_stylesheet.xslt"

You can also use Django's static files system::

Expand All @@ -1140,7 +1152,7 @@ You can also use Django's static files system::

class FeedWithStaticFileStylesheet(Feed):
... # author, etc.
stylesheet = static("rss_styles.xslt")
stylesheets = static("rss_styles.xslt")

Another option is to have a view in your project that renders the XSLT document.
You can then link it like so::
Expand All @@ -1151,7 +1163,7 @@ You can then link it like so::

class FeedWithStylesheetView(Feed):
... # author, etc.
stylesheet = reverse_lazy("your-custom-view-name")
stylesheets = reverse_lazy("your-custom-view-name")

Django will normally try to guess the MIME type of the given URL based on its
extension, but if that fails you can specify it using the
Expand All @@ -1163,7 +1175,7 @@ extension, but if that fails you can specify it using the

class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheet = Stylesheet("https://example.com/rss_stylesheet", mimetype="text/xsl")
stylesheets = Stylesheet("https://example.com/rss_stylesheet", mimetype="text/xsl")

Similarly, if you'd like to use a different ``media`` attribute than ``screen``
(Django's default), you can use the
Expand All @@ -1175,6 +1187,20 @@ Similarly, if you'd like to use a different ``media`` attribute than ``screen``

class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheet = Stylesheet("https://example.com/rss_stylesheet.xslt", media="print")
stylesheets = Stylesheet("https://example.com/rss_stylesheet.xslt", media="print")

Finally, if you need support for multiple stylesheets you can pass a list of
stylesheets (either as strings or as ``Stylesheet`` objects as shown above)::

from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Stylesheet


class MultiStylesheetFeed(Feed):
... # author, etc.
stylesheets = [
"/stylesheet1.xsl",
Stylesheet("/stylesheet2.xsl"),
]

.. _xslt: https://developer.mozilla.org/en-US/docs/Web/XSLT/Transforming_XML_with_XSLT
33 changes: 20 additions & 13 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -336,21 +336,25 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004

.. versionadded:: 5.1

.. class:: Stylesheet
.. class:: Stylesheet(url, mimetype="", media="screen")

Represents an RSS stylesheet

.. method:: __init__(url, mimetype, media="screen")
.. attribute:: url

``url`` is the URL of the stylesheet. This argument is required.
Required argument. The URL where the stylesheet is located.

``mimetype`` is the MIME type of the stylesheet. This argument is
optional. If not provided, Django will attempt to guess it by using
Python's :py:func:`mimetypes.guess_type()`. Use ``mimetype=None`` if you don't
.. attribute:: mimetype

An optional string containing the MIME type of the stylesheet. If not
specified, Django will attempt to guess it by using Python's
:py:func:`mimetypes.guess_type()`. Use ``mimetype=None`` if you don't
want your stylesheet to have a MIME type specified.

``media`` will be used as the ``media`` attribute of the stylesheet.
Django uses ``media="screen"`` by default. Use ``media=None`` if you
.. attribute:: media

An optional string which will be used as the ``media`` attribute of
the stylesheet. Defaults to ``"screen"`. Use ``media=None`` if you
don't want your stylesheet to have a ``media`` attribute.

``SyndicationFeed``
Expand All @@ -361,21 +365,24 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004
Base class for all syndication feeds. Subclasses should provide
``write()``.

.. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, stylesheet=None, **kwargs)
.. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, stylesheets=None, **kwargs)

Initialize the feed with the given dictionary of metadata, which applies
to the entire feed.

Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed``.

All parameters should be strings, except ``categories``, which should
be a sequence of strings. The ``stylesheet`` argument can be a string
or an instance of :class:`Stylesheet`.
All parameters should be strings, except for two:

- ``categories`` should be a sequence of strings.
- ``stylesheets`` can be a single string, an instance of
:class:`Stylesheets`, or a list of either strings or ``Stylesheet``
instances.

.. versionchanged:: 5.1

The ``stylesheet`` argument was added.
The ``stylesheets`` argument was added.

.. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, categories=(), item_copyright=None, ttl=None, updateddate=None, enclosures=None, **kwargs)

Expand Down
9 changes: 8 additions & 1 deletion tests/syndication_tests/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,14 @@ def item_title(self, item):


class TestFeedWithStylesheet(TestRss2Feed):
stylesheet = "/stylesheet.xsl"
stylesheets = "/stylesheet.xsl"


class TestFeedWithMultipleStylesheets(TestRss2Feed):
stylesheets = [
"/stylesheet1.xsl",
feedgenerator.Stylesheet("/stylesheet2.xsl"),
]


class NaiveDatesFeed(TestAtomFeed):
Expand Down
26 changes: 23 additions & 3 deletions tests/syndication_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ def test_stylesheet_none(self):
title="test",
link="https://example.com",
description="test",
stylesheet=None,
stylesheets=None,
)
self.assertNotIn("xml-stylesheet", feed.writeString("utf-8"))

Expand Down Expand Up @@ -623,12 +623,18 @@ def test_stylesheet(self):
'href="/test.xsl" type="text/xml" media="screen"',
),
]
for stylesheet, expected in testdata:
# any accepted single argument should also be accepted when in a list/tuple
testdata = (
testdata
+ [([s], expected) for s, expected in testdata]
+ [((s,), expected) for s, expected in testdata]
)
for stylesheets, expected in testdata:
feed = Rss201rev2Feed(
title="test",
link="https://example.com",
description="test",
stylesheet=stylesheet,
stylesheets=stylesheets,
)
doc = feed.writeString("utf-8")
with self.subTest(expected=expected):
Expand All @@ -643,6 +649,20 @@ def test_stylesheet_instruction_is_at_the_top(self):
'href="/stylesheet.xsl" type="text/xsl" media="screen"',
)

def test_multiple_stylesheets(self):
response = self.client.get("/syndication/stylesheet/multi/")
doc = minidom.parseString(response.content)
self.assertEqual(doc.childNodes[0].nodeName, "xml-stylesheet")
self.assertEqual(
doc.childNodes[0].data,
'href="/stylesheet1.xsl" type="text/xsl" media="screen"',
)
self.assertEqual(doc.childNodes[1].nodeName, "xml-stylesheet")
self.assertEqual(
doc.childNodes[1].data,
'href="/stylesheet2.xsl" type="text/xsl" media="screen"',
)

@requires_tz_support
def test_feed_last_modified_time_naive_date(self):
"""
Expand Down
1 change: 1 addition & 0 deletions tests/syndication_tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
path("syndication/template/", feeds.TemplateFeed()),
path("syndication/template_context/", feeds.TemplateContextFeed()),
path("syndication/stylesheet/", feeds.TestFeedWithStylesheet()),
path("syndication/stylesheet/multi/", feeds.TestFeedWithMultipleStylesheets()),
path("syndication/rss2/single-enclosure/", feeds.TestSingleEnclosureRSSFeed()),
path("syndication/rss2/multiple-enclosure/", feeds.TestMultipleEnclosureRSSFeed()),
path("syndication/atom/single-enclosure/", feeds.TestSingleEnclosureAtomFeed()),
Expand Down

0 comments on commit 647fb20

Please sign in to comment.