diff --git a/apps/downloads/models.py b/apps/downloads/models.py index 4b215cebf..a8feb85f8 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.template.loader import render_to_string from django.urls import reverse @@ -332,6 +332,31 @@ def update_download_supernav_and_boxes(sender, instance, **kwargs): update_homepage_download_box() +def _update_boxes_for_release_file(instance): + """Update supernav and download boxes if the file's release is published.""" + if instance.release_id and instance.release.is_published: + update_supernav() + update_download_landing_sources_box() + update_homepage_download_box() + purge_url("/box/supernav-python-downloads/") + purge_url("/box/homepage-downloads/") + purge_url("/box/download-sources/") + + +@receiver(post_save, sender="downloads.ReleaseFile") +def update_boxes_on_release_file_save(sender, instance, **kwargs): + """Refresh supernav when a release file is added or changed.""" + if kwargs.get("raw", False): + return + _update_boxes_for_release_file(instance) + + +@receiver(post_delete, sender="downloads.ReleaseFile") +def update_boxes_on_release_file_delete(sender, instance, **kwargs): + """Refresh supernav when a release file is deleted.""" + _update_boxes_for_release_file(instance) + + class ReleaseFile(ContentManageable, NameSlugModel): """Individual files in a release. diff --git a/apps/downloads/tests/test_models.py b/apps/downloads/tests/test_models.py index 3a45e1d39..a409b51f0 100644 --- a/apps/downloads/tests/test_models.py +++ b/apps/downloads/tests/test_models.py @@ -1,4 +1,5 @@ import datetime as dt +from unittest.mock import patch from apps.downloads.models import Release, ReleaseFile from apps.downloads.tests.base import BaseDownloadTests @@ -232,3 +233,59 @@ def test_update_supernav_skips_os_without_files(self): # Android (no files) should not be present self.assertNotIn("android", content.lower()) + + @patch("apps.downloads.models.update_supernav") + @patch("apps.downloads.models.update_download_landing_sources_box") + @patch("apps.downloads.models.update_homepage_download_box") + def test_release_file_save_triggers_box_updates(self, mock_home, mock_sources, mock_supernav): + """Saving a ReleaseFile on a published release should update boxes.""" + mock_supernav.reset_mock() + mock_sources.reset_mock() + mock_home.reset_mock() + + ReleaseFile.objects.create( + os=self.windows, + release=self.python_3, + name="Windows installer", + url="/ftp/python/3.10.19/python-3.10.19.exe", + download_button=True, + ) + + mock_supernav.assert_called() + mock_sources.assert_called() + mock_home.assert_called() + + @patch("apps.downloads.models.update_supernav") + @patch("apps.downloads.models.update_download_landing_sources_box") + @patch("apps.downloads.models.update_homepage_download_box") + def test_release_file_save_skips_unpublished_release(self, mock_home, mock_sources, mock_supernav): + """Saving a ReleaseFile on a draft release should not update boxes.""" + mock_supernav.reset_mock() + mock_sources.reset_mock() + mock_home.reset_mock() + + ReleaseFile.objects.create( + os=self.windows, + release=self.draft_release, + name="Windows installer draft", + url="/ftp/python/9.7.2/python-9.7.2.exe", + ) + + mock_supernav.assert_not_called() + mock_sources.assert_not_called() + mock_home.assert_not_called() + + @patch("apps.downloads.models.update_supernav") + @patch("apps.downloads.models.update_download_landing_sources_box") + @patch("apps.downloads.models.update_homepage_download_box") + def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources, mock_supernav): + """Deleting a ReleaseFile on a published release should update boxes.""" + mock_supernav.reset_mock() + mock_sources.reset_mock() + mock_home.reset_mock() + + self.release_275_windows_32bit.delete() + + mock_supernav.assert_called() + mock_sources.assert_called() + mock_home.assert_called()