55from django .test import TestCase , override_settings
66from django .urls import reverse
77
8- from ..templatetags .download_tags import get_eol_info , get_python_releases_data
8+ from ..templatetags .download_tags import (
9+ get_eol_info ,
10+ get_release_cycle_data ,
11+ render_active_releases ,
12+ )
913from .base import BaseDownloadTests
1014
11- MOCK_PYTHON_RELEASE = {
12- "metadata" : {
13- "2.7" : {"status" : "end-of-life" , "end_of_life" : "2020-01-01" },
14- "3.8" : {"status" : "end-of-life" , "end_of_life" : "2024-10-07" },
15- "3.10" : {"status" : "security" , "end_of_life" : "2026-10-04" },
16- }
15+ MOCK_RELEASE_CYCLE = {
16+ "2.7" : {"status" : "end-of-life" , "end_of_life" : "2020-01-01" , "pep" : 373 },
17+ "3.8" : {"status" : "end-of-life" , "end_of_life" : "2024-10-07" , "pep" : 569 },
18+ "3.9" : {"status" : "end-of-life" , "end_of_life" : "2025-10-31" , "pep" : 596 },
19+ "3.10" : {"status" : "security" , "end_of_life" : "2026-10-04" , "pep" : 619 },
20+ "3.14" : {"status" : "bugfix" , "first_release" : "2025-10-07" , "end_of_life" : "2030-10" , "pep" : 745 },
21+ "3.15" : {"status" : "feature" , "first_release" : "2026-10-01" , "end_of_life" : "2031-10" , "pep" : 790 },
1722}
1823
1924
@@ -31,11 +36,11 @@ def setUp(self):
3136 super ().setUp ()
3237 cache .clear ()
3338
34- @mock .patch ("downloads.templatetags.download_tags.get_python_releases_data " )
39+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data " )
3540 def test_eol_status (self , mock_get_data ):
3641 """Test get_eol_info returns correct EOL status."""
3742 # Arrange
38- mock_get_data .return_value = MOCK_PYTHON_RELEASE
43+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
3944 tests = [
4045 (self .release_275 , True , "2020-01-01" ), # EOL
4146 (self .python_3_8_20 , True , "2024-10-07" ), # EOL
@@ -51,7 +56,7 @@ def test_eol_status(self, mock_get_data):
5156 self .assertEqual (result ["is_eol" ], expected_is_eol )
5257 self .assertEqual (result ["eol_date" ], expected_eol_date )
5358
54- @mock .patch ("downloads.templatetags.download_tags.get_python_releases_data " )
59+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data " )
5560 def test_eol_status_api_failure (self , mock_get_data ):
5661 """Test that API failure results in not showing EOL warning."""
5762 # Arrange
@@ -75,29 +80,29 @@ def test_successful_fetch(self, mock_get):
7580 """Test successful API fetch."""
7681 # Arrange
7782 mock_response = mock .Mock ()
78- mock_response .json .return_value = MOCK_PYTHON_RELEASE
83+ mock_response .json .return_value = MOCK_RELEASE_CYCLE
7984 mock_response .raise_for_status = mock .Mock ()
8085 mock_get .return_value = mock_response
8186
8287 # Act
83- result = get_python_releases_data ()
88+ result = get_release_cycle_data ()
8489
8590 # Assert
86- self .assertEqual (result , MOCK_PYTHON_RELEASE )
91+ self .assertEqual (result , MOCK_RELEASE_CYCLE )
8792 mock_get .assert_called_once ()
8893
8994 @mock .patch ("downloads.templatetags.download_tags.requests.get" )
9095 def test_caches_result (self , mock_get ):
9196 """Test that the result is cached."""
9297 # Arrange
9398 mock_response = mock .Mock ()
94- mock_response .json .return_value = MOCK_PYTHON_RELEASE
99+ mock_response .json .return_value = MOCK_RELEASE_CYCLE
95100 mock_response .raise_for_status = mock .Mock ()
96101 mock_get .return_value = mock_response
97102
98103 # Act
99- result1 = get_python_releases_data ()
100- result2 = get_python_releases_data ()
104+ result1 = get_release_cycle_data ()
105+ result2 = get_release_cycle_data ()
101106
102107 # Assert
103108 self .assertEqual (result1 , result2 )
@@ -110,7 +115,7 @@ def test_request_exception_returns_none(self, mock_get):
110115 mock_get .side_effect = requests .RequestException ("Connection error" )
111116
112117 # Act
113- result = get_python_releases_data ()
118+ result = get_release_cycle_data ()
114119
115120 # Assert
116121 self .assertIsNone (result )
@@ -125,7 +130,7 @@ def test_json_decode_error_returns_none(self, mock_get):
125130 mock_get .return_value = mock_response
126131
127132 # Act
128- result = get_python_releases_data ()
133+ result = get_release_cycle_data ()
129134
130135 # Assert
131136 self .assertIsNone (result )
@@ -138,14 +143,14 @@ def setUp(self):
138143 super ().setUp ()
139144 cache .clear ()
140145
141- @mock .patch ("downloads.templatetags.download_tags.get_python_releases_data " )
146+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data " )
142147 def test_eol_banner_visibility (self , mock_get_data ):
143148 """Test EOL banner is shown or hidden correctly."""
144149 # Arrange
145150 tests = [
146- ("release_275" , MOCK_PYTHON_RELEASE , True ),
147- ("python_3_8_20" , MOCK_PYTHON_RELEASE , True ),
148- ("python_3_10_18" , MOCK_PYTHON_RELEASE , False ),
151+ ("release_275" , MOCK_RELEASE_CYCLE , True ),
152+ ("python_3_8_20" , MOCK_RELEASE_CYCLE , True ),
153+ ("python_3_10_18" , MOCK_RELEASE_CYCLE , False ),
149154 ("python_3_8_20" , None , False ),
150155 ]
151156
@@ -168,3 +173,77 @@ def test_eol_banner_visibility(self, mock_get_data):
168173 self .assertContains (response , "no longer supported" )
169174 else :
170175 self .assertNotContains (response , "level-error" )
176+
177+
178+ @override_settings (CACHES = TEST_CACHES )
179+ class RenderActiveReleasesTests (BaseDownloadTests ):
180+ def setUp (self ):
181+ super ().setUp ()
182+ cache .clear ()
183+
184+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
185+ def test_versions_sorted_descending (self , mock_get_data ):
186+ """Test that versions are sorted in descending order."""
187+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
188+
189+ result = render_active_releases ()
190+
191+ versions = [r ["version" ] for r in result ["releases" ]]
192+ # 3.15, 3.14, 3.10, 3.9 (first EOL); 3.8 and 2.7 skipped (older EOL)
193+ self .assertEqual (versions , ["3.15" , "3.14" , "3.10" , "3.9" ])
194+
195+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
196+ def test_feature_status_becomes_prerelease (self , mock_get_data ):
197+ """Test that 'feature' status is converted to 'pre-release'."""
198+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
199+
200+ result = render_active_releases ()
201+
202+ prerelease = result ["releases" ][0 ]
203+ self .assertEqual (prerelease ["version" ], "3.15" )
204+ self .assertEqual (prerelease ["status" ], "pre-release" )
205+
206+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
207+ def test_feature_first_release_shows_planned (self , mock_get_data ):
208+ """Test that feature releases show (planned) in first_release."""
209+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
210+
211+ result = render_active_releases ()
212+
213+ prerelease = result ["releases" ][0 ]
214+ self .assertEqual (prerelease ["first_release" ], "2026-10-01 (planned)" )
215+
216+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
217+ def test_only_one_eol_release_included (self , mock_get_data ):
218+ """Test that only the most recent EOL release is included."""
219+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
220+
221+ result = render_active_releases ()
222+
223+ versions = [r ["version" ] for r in result ["releases" ]]
224+ # 3.9 is included (most recent EOL), 3.8 and 2.7 are not
225+ self .assertIn ("3.9" , versions )
226+ self .assertNotIn ("3.8" , versions )
227+ self .assertNotIn ("2.7" , versions )
228+
229+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
230+ def test_eol_status_includes_last_release_link (self , mock_get_data ):
231+ """Test that EOL status includes last release link."""
232+ mock_get_data .return_value = MOCK_RELEASE_CYCLE
233+
234+ result = render_active_releases ()
235+
236+ eol_release = next (r for r in result ["releases" ] if r ["version" ] == "3.9" )
237+ status = str (eol_release ["status" ])
238+ self .assertIn ("end-of-life" , status )
239+ self .assertIn ("last release was" , status )
240+ self .assertIn ("<a href=" , status )
241+
242+ @mock .patch ("downloads.templatetags.download_tags.get_release_cycle_data" )
243+ def test_api_failure_returns_empty_releases (self , mock_get_data ):
244+ """Test that API failure returns empty releases list."""
245+ mock_get_data .return_value = None
246+
247+ result = render_active_releases ()
248+
249+ self .assertEqual (result ["releases" ], [])
0 commit comments