diff --git a/downloads/models.py b/downloads/models.py index 4a9c5781c..4576afb2f 100644 --- a/downloads/models.py +++ b/downloads/models.py @@ -272,6 +272,7 @@ def purge_fastly_download_pages(sender, instance, **kwargs): if instance.is_published: # Purge our common pages purge_url('/downloads/') + purge_url('/downloads/feed.rss') purge_url('/downloads/latest/python2/') purge_url('/downloads/latest/python3/') purge_url('/downloads/macos/') diff --git a/downloads/tests/test_views.py b/downloads/tests/test_views.py index c585fe05c..b559a2adc 100644 --- a/downloads/tests/test_views.py +++ b/downloads/tests/test_views.py @@ -554,3 +554,45 @@ def test_filter_release_file_delete_by_release(self): headers={"authorization": self.Authorization} ) self.assertEqual(response.status_code, 405) + +class ReleaseFeedTests(BaseDownloadTests): + """Tests for the downloads/feed.rss endpoint. + + Content is ensured via setUp in BaseDownloadTests. + """ + + url = reverse("downloads:feed") + + + def test_endpoint_reachable(self) -> None: + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_feed_content(self) -> None: + """Ensure feed content is as expected. + + Some things we want to check: + - Feed title, description, pubdate + - Feed items (releases) are in the correct order + - We get the expected number of releases (10) + """ + response = self.client.get(self.url) + content = response.content.decode() + + self.assertIn("Python 2.7.5", content) + self.assertIn("Python 3.10", content) + # Published but hidden show up in the API and thus the feed + self.assertIn("Python 0.0.0", content) + + # No unpublished releases + self.assertNotIn("Python 9.7.2", content) + + # Pre-releases are shown + self.assertIn("Python 3.9.90", content) + + def test_feed_item_count(self) -> None: + response = self.client.get(self.url) + content = response.content.decode() + + # In BaseDownloadTests, we create 5 releases, 4 of which are published, 1 of those published are hidden.. + self.assertEqual(content.count(""), 4) diff --git a/downloads/urls.py b/downloads/urls.py index d64f0a1ad..f553caeaa 100644 --- a/downloads/urls.py +++ b/downloads/urls.py @@ -9,4 +9,5 @@ path('release//', views.DownloadReleaseDetail.as_view(), name='download_release_detail'), path('/', views.DownloadOSList.as_view(), name='download_os_list'), path('', views.DownloadHome.as_view(), name='download'), + path("feed.rss", views.ReleaseFeed(), name="feed"), ] diff --git a/downloads/views.py b/downloads/views.py index 746845402..92e851545 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -1,7 +1,14 @@ +from typing import Any + +from datetime import datetime + from django.db.models import Prefetch from django.urls import reverse +from django.utils import timezone from django.views.generic import DetailView, TemplateView, ListView, RedirectView from django.http import Http404 +from django.contrib.syndication.views import Feed +from django.utils.feedgenerator import Rss201rev2Feed from .models import OS, Release, ReleaseFile @@ -147,3 +154,45 @@ def get_context_data(self, **kwargs): ) return context + + +class ReleaseFeed(Feed): + """Generate an RSS feed of the latest Python releases. + + .. note:: It may seem like these are unused methods, but the superclass uses them + using Django's Syndication framework. + Docs: https://docs.djangoproject.com/en/4.2/ref/contrib/syndication/ + """ + + feed_type = Rss201rev2Feed + title = "Python Releases" + description = "Latest Python releases from Python.org" + + @staticmethod + def link() -> str: + """Return the URL to the main downloads page.""" + return reverse("downloads:download") + + def items(self) -> list[dict[str, Any]]: + """Return the latest Python releases.""" + return Release.objects.filter(is_published=True).order_by("-release_date")[:10] + + def item_title(self, item: Release) -> str: + """Return the release name as the item title.""" + return item.name + + def item_description(self, item: Release) -> str: + """Return the release version and release date as the item description.""" + return f"Version: {item.version}, Release Date: {item.release_date}" + + def item_pubdate(self, item: Release) -> datetime | None: + """Return the release date as the item publication date.""" + if item.release_date: + if timezone.is_naive(item.release_date): + return timezone.make_aware(item.release_date) + return item.release_date + return None + + def item_guid(self, item: Release) -> str: + """Return a unique ID for the item based on DB record.""" + return str(item.pk)