diff --git a/base-requirements.txt b/base-requirements.txt index 680685cfc..d8b1295c9 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -42,10 +42,10 @@ django-waffle==2.2.1 djangorestframework==3.14.0 # 3.14.0 is first version that supports Django 4.1, 4.2 support hasnt been "released" django-filter==2.4.0 -django-ordered-model==3.4.3 +django-ordered-model==3.7.4 django-widget-tweaks==1.5.0 django-countries==7.2.1 -num2words==0.5.10 +num2words==0.5.13 django-polymorphic==3.1.0 # 3.1.0 is first version that supports Django 4.0, unsure if it fully supports 4.2 sorl-thumbnail==12.7.0 django-extensions==3.1.4 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) diff --git a/infra/cdn/main.tf b/infra/cdn/main.tf index c50888e3f..e7aa77108 100644 --- a/infra/cdn/main.tf +++ b/infra/cdn/main.tf @@ -4,7 +4,7 @@ resource "fastly_service_vcl" "python_org" { http3 = false stale_if_error = false stale_if_error_ttl = 43200 - activate = false + activate = true domain { name = var.domain diff --git a/infra/cdn/variables.tf b/infra/cdn/variables.tf index 5c1be4562..ec0a11a83 100644 --- a/infra/cdn/variables.tf +++ b/infra/cdn/variables.tf @@ -50,7 +50,7 @@ variable "activate_ngwaf_service" { variable "edge_security_dictionary" { type = string description = "The dictionary name for the Edge Security product." - default = "" + default = "Edge_Security" } variable "ngwaf_corp_name" { type = string