From 97624b5aeb276b0d8c3e932e74caabbdf93d1624 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:44:54 -0500 Subject: [PATCH 01/15] feat: add rss feed for latest downloads --- downloads/urls.py | 1 + downloads/views.py | 101 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) 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..a3255425d 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -1,10 +1,22 @@ +import logging +from typing import Any + +import requests +from datetime import datetime + +from django.contrib.sites.shortcuts import get_current_site +from django.core.handlers.wsgi import WSGIRequest from django.db.models import Prefetch from django.urls import reverse 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 +import pytz from .models import OS, Release, ReleaseFile +logger = logging.getLogger(__name__) class DownloadLatestPython2(RedirectView): """ Redirect to latest Python 2 release """ @@ -147,3 +159,92 @@ 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 get_feed(self, obj: Any, request: WSGIRequest) -> Feed: + """Store the request object for later use.""" + self.request = request + return super().get_feed(obj, request) + + def items(self) -> list[dict[str, Any]]: + """Return the latest Python releases.""" + url = self.create_url("/api/v2/downloads/release/") + logger.info(f"Fetching releases from: {url}") + try: + return self._fetch_releases(url) + except requests.RequestException as e: + logger.error(f"Error fetching releases from API: {str(e)}") + except ValueError as e: + logger.error(f"Error parsing JSON from API response: {str(e)}") + except Exception as e: + logger.error(f"Unexpected error in items method: {str(e)}") + return [] + + @staticmethod + def _fetch_releases(url: str) -> list[dict[str, Any]]: + """Grabs the latest Python releases from API. + + + """ + response = requests.get(url, timeout=10) + response.raise_for_status() + data = response.json() + + sorted_releases = sorted(data, key=lambda x: x["release_date"], reverse=True) + return sorted_releases[:10] + + def item_title(self, item: dict[str, Any]) -> str: + """Return the release name as the item title.""" + return item.get("name", "Unknown Release") + + def item_description(self, item: dict[str, Any]) -> str: + """Return the release version and release date as the item description.""" + version = item.get("version", "Unknown") + release_date = item.get("release_date", "Unknown") + return f"Version: {version}, Release Date: {release_date}" + + def item_link(self, item: dict[str, Any]) -> str: + """Return the URL to the release page on python.org.""" + return reverse("downloads:download_release_detail", args=[item.get("slug", "")]) + + @staticmethod + def item_pubdate(item: dict[str, Any]) -> datetime: + """Return the release date as the item publication date.""" + try: + release_date = datetime.strptime( + item.get("release_date", ""), "%Y-%m-%dT%H:%M:%SZ" + ) + return pytz.utc.localize(release_date) + except ValueError: + logger.error( + f"Invalid release date format for item: {item.get('name', 'Unknown')}" + ) + return pytz.utc.localize(datetime.now()) + + @staticmethod + def item_guid(item: dict[str, Any]) -> str: + """Return the release URI as the item GUID.""" + return item.get("resource_uri", "") + + def create_url(self, path: str) -> str: + """Create a full URL using the current site domain.""" + current_site = get_current_site(self.request) + scheme = "https" if self.request.is_secure() else "http" + return f"{scheme}://{current_site.domain}{path}" From c3d1102f9ce7a456cd81be114761fc498b5e488e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:48:30 -0500 Subject: [PATCH 02/15] Update downloads/views.py --- downloads/views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index a3255425d..084e46b7d 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -199,10 +199,7 @@ def items(self) -> list[dict[str, Any]]: @staticmethod def _fetch_releases(url: str) -> list[dict[str, Any]]: - """Grabs the latest Python releases from API. - - - """ + """Grabs the latest Python releases from API.""" response = requests.get(url, timeout=10) response.raise_for_status() data = response.json() From 25211b588e24c855547e5a5bba94f99ef3fd7bdb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:51:43 -0500 Subject: [PATCH 03/15] fix: query DB for releas --- downloads/views.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index 084e46b7d..9364f5d00 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -183,19 +183,10 @@ def get_feed(self, obj: Any, request: WSGIRequest) -> Feed: self.request = request return super().get_feed(obj, request) - def items(self) -> list[dict[str, Any]]: + @staticmethod + def items() -> list[dict[str, Any]]: """Return the latest Python releases.""" - url = self.create_url("/api/v2/downloads/release/") - logger.info(f"Fetching releases from: {url}") - try: - return self._fetch_releases(url) - except requests.RequestException as e: - logger.error(f"Error fetching releases from API: {str(e)}") - except ValueError as e: - logger.error(f"Error parsing JSON from API response: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error in items method: {str(e)}") - return [] + return Release.objects.filter(is_published=True).order_by('-release_date')[:10] @staticmethod def _fetch_releases(url: str) -> list[dict[str, Any]]: From 44fde54bcd34086ea351c9c113d430577e59c1b6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:55:02 -0500 Subject: [PATCH 04/15] fix: query DB for releas --- downloads/views.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index 9364f5d00..bcdb31389 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -8,11 +8,11 @@ from django.core.handlers.wsgi import WSGIRequest 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 -import pytz from .models import OS, Release, ReleaseFile @@ -198,38 +198,26 @@ def _fetch_releases(url: str) -> list[dict[str, Any]]: sorted_releases = sorted(data, key=lambda x: x["release_date"], reverse=True) return sorted_releases[:10] - def item_title(self, item: dict[str, Any]) -> str: + def item_title(self, item: Release) -> str: """Return the release name as the item title.""" - return item.get("name", "Unknown Release") + return item.name - def item_description(self, item: dict[str, Any]) -> str: + def item_description(self, item): """Return the release version and release date as the item description.""" - version = item.get("version", "Unknown") - release_date = item.get("release_date", "Unknown") - return f"Version: {version}, Release Date: {release_date}" + return f"Version: {item.version}, Release Date: {item.release_date}" - def item_link(self, item: dict[str, Any]) -> str: + def item_link(self, item): """Return the URL to the release page on python.org.""" - return reverse("downloads:download_release_detail", args=[item.get("slug", "")]) + return reverse("downloads:download_release_detail", args=[item.slug]) - @staticmethod - def item_pubdate(item: dict[str, Any]) -> datetime: + def item_pubdate(self, item: Release) -> datetime: """Return the release date as the item publication date.""" - try: - release_date = datetime.strptime( - item.get("release_date", ""), "%Y-%m-%dT%H:%M:%SZ" - ) - return pytz.utc.localize(release_date) - except ValueError: - logger.error( - f"Invalid release date format for item: {item.get('name', 'Unknown')}" - ) - return pytz.utc.localize(datetime.now()) + return timezone.make_aware(item.release_date) if item.release_date else None @staticmethod - def item_guid(item: dict[str, Any]) -> str: - """Return the release URI as the item GUID.""" - return item.get("resource_uri", "") + def item_guid(item: Release) -> str: + """Return a unique ID for the item based on DB record.""" + return str(item.pk) def create_url(self, path: str) -> str: """Create a full URL using the current site domain.""" From eca7c5015d67f23ca088bcee616293f3b35298fa Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:56:07 -0500 Subject: [PATCH 05/15] chore: add missing types --- downloads/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index bcdb31389..9ef670623 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -202,11 +202,11 @@ def item_title(self, item: Release) -> str: """Return the release name as the item title.""" return item.name - def item_description(self, item): + 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_link(self, item): + def item_link(self, item: Release) -> str: """Return the URL to the release page on python.org.""" return reverse("downloads:download_release_detail", args=[item.slug]) From ac115f1d26ad84a8115ded8aed357e6c93d6f721 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 15:57:56 -0500 Subject: [PATCH 06/15] fix: update naive datetime, remove staticmethod --- downloads/views.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index 9ef670623..4a14040c2 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -210,12 +210,15 @@ def item_link(self, item: Release) -> str: """Return the URL to the release page on python.org.""" return reverse("downloads:download_release_detail", args=[item.slug]) - def item_pubdate(self, item: Release) -> datetime: + def item_pubdate(self, item: Release) -> datetime | None: """Return the release date as the item publication date.""" - return timezone.make_aware(item.release_date) if item.release_date else None + if item.release_date: + if timezone.is_naive(item.release_date): + return timezone.make_aware(item.release_date) + return item.release_date + return None - @staticmethod - def item_guid(item: Release) -> str: + def item_guid(self, item: Release) -> str: """Return a unique ID for the item based on DB record.""" return str(item.pk) From c2409fee789fd45b48a30a3bb1339f66221018b2 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 16:01:53 -0500 Subject: [PATCH 07/15] fix: remove staticmethod chore: apply formatting and ruuuuuff --- downloads/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index 4a14040c2..ccadcdb1a 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -176,17 +176,16 @@ class ReleaseFeed(Feed): @staticmethod def link() -> str: """Return the URL to the main downloads page.""" - return reverse('downloads:download') + return reverse("downloads:download") def get_feed(self, obj: Any, request: WSGIRequest) -> Feed: """Store the request object for later use.""" self.request = request return super().get_feed(obj, request) - @staticmethod - def items() -> list[dict[str, Any]]: + def items(self) -> list[dict[str, Any]]: """Return the latest Python releases.""" - return Release.objects.filter(is_published=True).order_by('-release_date')[:10] + return Release.objects.filter(is_published=True).order_by("-release_date")[:10] @staticmethod def _fetch_releases(url: str) -> list[dict[str, Any]]: From 6eb16d2d7c4c27bd4b17299528a9faf7d90be5fe Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 16 Sep 2024 16:04:37 -0500 Subject: [PATCH 08/15] chore: no logging needed after working --- downloads/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index ccadcdb1a..8cd92f6ae 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -1,4 +1,3 @@ -import logging from typing import Any import requests @@ -16,7 +15,6 @@ from .models import OS, Release, ReleaseFile -logger = logging.getLogger(__name__) class DownloadLatestPython2(RedirectView): """ Redirect to latest Python 2 release """ From da9c20db610e0e6daa8f94fda7ef158fff53190b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 11:59:22 -0500 Subject: [PATCH 09/15] tests: add them --- downloads/tests/test_views.py | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/downloads/tests/test_views.py b/downloads/tests/test_views.py index c585fe05c..e93555665 100644 --- a/downloads/tests/test_views.py +++ b/downloads/tests/test_views.py @@ -554,3 +554,46 @@ 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. + """ + + def setUp(self) -> None: + super().setUp() + self.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) From 075ef047b6463570bce1c85d4f87946a4d28e6ec Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 14:44:03 -0500 Subject: [PATCH 10/15] chore: address code reviews --- downloads/tests/test_views.py | 5 ++--- downloads/views.py | 16 ---------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/downloads/tests/test_views.py b/downloads/tests/test_views.py index e93555665..b559a2adc 100644 --- a/downloads/tests/test_views.py +++ b/downloads/tests/test_views.py @@ -561,9 +561,8 @@ class ReleaseFeedTests(BaseDownloadTests): Content is ensured via setUp in BaseDownloadTests. """ - def setUp(self) -> None: - super().setUp() - self.url = reverse("downloads:feed") + url = reverse("downloads:feed") + def test_endpoint_reachable(self) -> None: response = self.client.get(self.url) diff --git a/downloads/views.py b/downloads/views.py index 8cd92f6ae..b485f8fab 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -185,16 +185,6 @@ def items(self) -> list[dict[str, Any]]: """Return the latest Python releases.""" return Release.objects.filter(is_published=True).order_by("-release_date")[:10] - @staticmethod - def _fetch_releases(url: str) -> list[dict[str, Any]]: - """Grabs the latest Python releases from API.""" - response = requests.get(url, timeout=10) - response.raise_for_status() - data = response.json() - - sorted_releases = sorted(data, key=lambda x: x["release_date"], reverse=True) - return sorted_releases[:10] - def item_title(self, item: Release) -> str: """Return the release name as the item title.""" return item.name @@ -218,9 +208,3 @@ def item_pubdate(self, item: Release) -> datetime | None: def item_guid(self, item: Release) -> str: """Return a unique ID for the item based on DB record.""" return str(item.pk) - - def create_url(self, path: str) -> str: - """Create a full URL using the current site domain.""" - current_site = get_current_site(self.request) - scheme = "https" if self.request.is_secure() else "http" - return f"{scheme}://{current_site.domain}{path}" From a25ff25e7b498d3b270951faee13dbd80f1bb490 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 14:45:15 -0500 Subject: [PATCH 11/15] chore: remove unused imports --- downloads/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index b485f8fab..994c0e8c9 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -1,9 +1,7 @@ from typing import Any -import requests from datetime import datetime -from django.contrib.sites.shortcuts import get_current_site from django.core.handlers.wsgi import WSGIRequest from django.db.models import Prefetch from django.urls import reverse From 9746cb893e773aaa1f35be4930614fff01d8897f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 15:17:11 -0500 Subject: [PATCH 12/15] chore: remove unused code --- downloads/views.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index 994c0e8c9..bda49177a 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -2,7 +2,6 @@ from datetime import datetime -from django.core.handlers.wsgi import WSGIRequest from django.db.models import Prefetch from django.urls import reverse from django.utils import timezone @@ -174,11 +173,6 @@ def link() -> str: """Return the URL to the main downloads page.""" return reverse("downloads:download") - def get_feed(self, obj: Any, request: WSGIRequest) -> Feed: - """Store the request object for later use.""" - self.request = request - return super().get_feed(obj, request) - def items(self) -> list[dict[str, Any]]: """Return the latest Python releases.""" return Release.objects.filter(is_published=True).order_by("-release_date")[:10] From 41e28d554ae9e9699651729ec8fd31ee94c9c013 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 15:19:51 -0500 Subject: [PATCH 13/15] chore: remove unused code --- downloads/views.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/downloads/views.py b/downloads/views.py index bda49177a..42cdf3234 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -168,11 +168,6 @@ class ReleaseFeed(Feed): 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] @@ -185,10 +180,6 @@ 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_link(self, item: Release) -> str: - """Return the URL to the release page on python.org.""" - return reverse("downloads:download_release_detail", args=[item.slug]) - def item_pubdate(self, item: Release) -> datetime | None: """Return the release date as the item publication date.""" if item.release_date: From 4cdd90ac76ffefb23024ce7fa0f18c52d12275c4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 15:27:43 -0500 Subject: [PATCH 14/15] fix: purge cdn cache --- downloads/models.py | 1 + 1 file changed, 1 insertion(+) 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/') From 6999d2b39a79091c77f0e34390875fa5d5ccaa74 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Tue, 17 Sep 2024 15:53:05 -0500 Subject: [PATCH 15/15] revert: put the code back, john --- downloads/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/downloads/views.py b/downloads/views.py index 42cdf3234..92e851545 100644 --- a/downloads/views.py +++ b/downloads/views.py @@ -168,6 +168,11 @@ class ReleaseFeed(Feed): 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]