Skip to content

Commit 879d9c5

Browse files
committed
Add EOL banner to EOL downloads
1 parent e94fa01 commit 879d9c5

File tree

5 files changed

+266
-2
lines changed

5 files changed

+266
-2
lines changed

downloads/templatetags/download_tags.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,75 @@
1+
import logging
2+
import re
3+
4+
import requests
15
from django import template
6+
from django.core.cache import cache
27

38
register = template.Library()
9+
logger = logging.getLogger(__name__)
10+
11+
RELEASE_CYCLE_URL = "https://peps.python.org/api/release-cycle.json"
12+
RELEASE_CYCLE_CACHE_KEY = "python_release_cycle"
13+
RELEASE_CYCLE_CACHE_TIMEOUT = 3600 # 1 hour
14+
15+
16+
def get_release_cycle_data() -> dict | None:
17+
"""Fetch and cache the Python release cycle data from PEPs API."""
18+
data = cache.get(RELEASE_CYCLE_CACHE_KEY)
19+
if data is not None:
20+
return data
21+
22+
try:
23+
response = requests.get(RELEASE_CYCLE_URL, timeout=5)
24+
response.raise_for_status()
25+
data = response.json()
26+
cache.set(RELEASE_CYCLE_CACHE_KEY, data, RELEASE_CYCLE_CACHE_TIMEOUT)
27+
return data
28+
except (requests.RequestException, ValueError) as e:
29+
logger.warning("Failed to fetch release cycle data: %s", e)
30+
return None
31+
32+
33+
@register.simple_tag
34+
def get_eol_info(release) -> dict:
35+
"""
36+
Check if a release's minor version is end-of-life.
37+
38+
Returns a dict with 'is_eol' boolean and 'eol_date' if available.
39+
Python 2 releases not found in the release cycle data, assumes EOL.
40+
"""
41+
result = {"is_eol": False, "eol_date": None}
42+
43+
version = release.get_version()
44+
if not version:
45+
return result
46+
47+
# Extract minor version (e.g. "3.9" from "3.9.14")
48+
match = re.match(r"^(\d+)\.(\d+)", version)
49+
if not match:
50+
return result
51+
52+
major = int(match.group(1))
53+
minor_version = f"{match.group(1)}.{match.group(2)}"
54+
55+
release_cycle = get_release_cycle_data()
56+
if release_cycle is None:
57+
# Can't determine EOL status, don't show warning
58+
return result
59+
60+
version_info = release_cycle.get(minor_version)
61+
62+
if version_info is None:
63+
# Python 2 releases not in the list are EOL
64+
if major <= 2:
65+
result["is_eol"] = True
66+
return result
67+
68+
if version_info.get("status") == "end-of-life":
69+
result["is_eol"] = True
70+
result["eol_date"] = version_info.get("end_of_life")
71+
72+
return result
473

574

675
@register.filter
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import unittest.mock as mock
2+
3+
import requests
4+
from django.core.cache import cache
5+
from django.test import TestCase, override_settings
6+
from django.urls import reverse
7+
8+
from ..templatetags.download_tags import get_eol_info, get_release_cycle_data
9+
from .base import BaseDownloadTests
10+
11+
MOCK_RELEASE_CYCLE = {
12+
"2.7": {"status": "end-of-life", "end_of_life": "2020-01-01"},
13+
"3.8": {"status": "end-of-life", "end_of_life": "2024-10-07"},
14+
"3.10": {"status": "security", "end_of_life": "2026-10-04"},
15+
}
16+
17+
18+
TEST_CACHES = {
19+
"default": {
20+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
21+
"LOCATION": "test-cache",
22+
}
23+
}
24+
25+
26+
@override_settings(CACHES=TEST_CACHES)
27+
class GetEOLInfoTests(BaseDownloadTests):
28+
def setUp(self):
29+
super().setUp()
30+
cache.clear()
31+
32+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
33+
def test_eol_status(self, mock_get_data):
34+
"""Test get_eol_info returns correct EOL status."""
35+
# Arrange
36+
mock_get_data.return_value = MOCK_RELEASE_CYCLE
37+
tests = [
38+
(self.release_275, True, "2020-01-01"), # EOL
39+
(self.python_3_8_20, True, "2024-10-07"), # EOL
40+
(self.python_3_10_18, False, None), # security
41+
]
42+
43+
for release, expected_is_eol, expected_eol_date in tests:
44+
with self.subTest(release=release.name):
45+
# Act
46+
result = get_eol_info(release)
47+
48+
# Assert
49+
self.assertEqual(result["is_eol"], expected_is_eol)
50+
self.assertEqual(result["eol_date"], expected_eol_date)
51+
52+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
53+
def test_eol_status_api_failure(self, mock_get_data):
54+
"""Test that API failure results in not showing EOL warning."""
55+
# Arrange
56+
mock_get_data.return_value = None
57+
58+
# Act
59+
result = get_eol_info(self.python_3_8_20)
60+
61+
# Assert
62+
self.assertFalse(result["is_eol"])
63+
self.assertIsNone(result["eol_date"])
64+
65+
66+
@override_settings(CACHES=TEST_CACHES)
67+
class GetReleaseCycleDataTests(TestCase):
68+
def setUp(self):
69+
cache.clear()
70+
71+
@mock.patch("downloads.templatetags.download_tags.requests.get")
72+
def test_successful_fetch(self, mock_get):
73+
"""Test successful API fetch."""
74+
# Arrange
75+
mock_response = mock.Mock()
76+
mock_response.json.return_value = MOCK_RELEASE_CYCLE
77+
mock_response.raise_for_status = mock.Mock()
78+
mock_get.return_value = mock_response
79+
80+
# Act
81+
result = get_release_cycle_data()
82+
83+
# Assert
84+
self.assertEqual(result, MOCK_RELEASE_CYCLE)
85+
mock_get.assert_called_once()
86+
87+
@mock.patch("downloads.templatetags.download_tags.requests.get")
88+
def test_caches_result(self, mock_get):
89+
"""Test that the result is cached."""
90+
# Arrange
91+
mock_response = mock.Mock()
92+
mock_response.json.return_value = MOCK_RELEASE_CYCLE
93+
mock_response.raise_for_status = mock.Mock()
94+
mock_get.return_value = mock_response
95+
96+
# Act
97+
result1 = get_release_cycle_data()
98+
result2 = get_release_cycle_data()
99+
100+
# Assert
101+
self.assertEqual(result1, result2)
102+
mock_get.assert_called_once()
103+
104+
@mock.patch("downloads.templatetags.download_tags.requests.get")
105+
def test_request_exception_returns_none(self, mock_get):
106+
"""Test that request exceptions return None."""
107+
# Arrange
108+
mock_get.side_effect = requests.RequestException("Connection error")
109+
110+
# Act
111+
result = get_release_cycle_data()
112+
113+
# Assert
114+
self.assertIsNone(result)
115+
116+
@mock.patch("downloads.templatetags.download_tags.requests.get")
117+
def test_json_decode_error_returns_none(self, mock_get):
118+
"""Test that JSON decode errors return None."""
119+
# Arrange
120+
mock_response = mock.Mock()
121+
mock_response.raise_for_status = mock.Mock()
122+
mock_response.json.side_effect = ValueError("Invalid JSON")
123+
mock_get.return_value = mock_response
124+
125+
# Act
126+
result = get_release_cycle_data()
127+
128+
# Assert
129+
self.assertIsNone(result)
130+
131+
132+
@override_settings(CACHES=TEST_CACHES)
133+
class EOLBannerViewTests(BaseDownloadTests):
134+
135+
def setUp(self):
136+
super().setUp()
137+
cache.clear()
138+
139+
@mock.patch("downloads.templatetags.download_tags.get_release_cycle_data")
140+
def test_eol_banner_visibility(self, mock_get_data):
141+
"""Test EOL banner is shown or hidden correctly."""
142+
# Arrange
143+
tests = [
144+
("python_3_8_20", MOCK_RELEASE_CYCLE, True),
145+
("release_275", MOCK_RELEASE_CYCLE, True),
146+
(
147+
"python_3",
148+
{"3.10": {"status": "security", "end_of_life": "2026-10"}},
149+
False,
150+
),
151+
("python_3_8_20", None, False),
152+
]
153+
154+
for release_attr, mock_data, expect_banner in tests:
155+
with self.subTest(release=release_attr):
156+
mock_get_data.return_value = mock_data
157+
release = getattr(self, release_attr)
158+
url = reverse(
159+
"download:download_release_detail",
160+
kwargs={"release_slug": release.slug},
161+
)
162+
163+
# Act
164+
response = self.client.get(url)
165+
166+
# Assert
167+
self.assertEqual(response.status_code, 200)
168+
if expect_banner:
169+
self.assertContains(response, "level-error")
170+
self.assertContains(response, "no longer supported")
171+
else:
172+
self.assertNotContains(response, "level-error")

static/sass/style.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2652,7 +2652,12 @@ p.quote-by-organization {
26522652
background-color: #ecd4d7;
26532653
border: 2px solid #b55863; }
26542654
.level-error span {
2655-
color: #b55863; }
2655+
color: #853b44;
2656+
font-weight: bold; }
2657+
.level-error a {
2658+
color: #2b5b84; }
2659+
.level-error a:hover, .level-error a:focus {
2660+
color: #1e415e; }
26562661

26572662
/* Yeah! It worked correctly */
26582663
.level-success {

static/sass/style.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1766,7 +1766,14 @@ $colors: $blue, $psf, $yellow, $green, $purple, $red;
17661766
background-color: lighten( $red, 35% );
17671767
border: 2px solid $red;
17681768

1769-
span { color: $red; }
1769+
span {
1770+
color: darken( $red, 15% );
1771+
font-weight: bold;
1772+
}
1773+
a {
1774+
color: darken( $blue, 10% );
1775+
&:hover, &:focus { color: darken( $blue, 20% ); }
1776+
}
17701777
}
17711778

17721779
/* Yeah! It worked correctly */

templates/downloads/release_detail.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{% load has_sigstore_materials from download_tags %}
66
{% load has_sbom from download_tags %}
77
{% load sort_windows from download_tags %}
8+
{% load get_eol_info from download_tags %}
89

910
{% block body_attributes %}class="python downloads"{% endblock %}
1011

@@ -26,6 +27,16 @@
2627
<h1 class="page-title">{{ release.name }}</h1>
2728
</header>
2829

30+
{% get_eol_info release as eol_info %}
31+
{% if eol_info.is_eol %}
32+
<div class="user-feedback level-error">
33+
<span>Warning:</span>
34+
Python {{ release.get_version|default:release.name }} reached end-of-life{% if eol_info.eol_date %} on {{ eol_info.eol_date }}{% endif %}.
35+
It is no longer supported and does not receive security updates.
36+
We recommend upgrading to the <a href="{% url 'downloads:download_latest_python3' %}">latest Python release</a>.
37+
</div>
38+
{% endif %}
39+
2940
{% if latest_in_series %}
3041
<p><strong>Note:</strong> {{ release.name }} has been superseded by <a href="{{ latest_in_series.get_absolute_url }}">{{ latest_in_series.name }}</a>.</p>
3142
{% endif %}

0 commit comments

Comments
 (0)