diff --git a/apps/agreements/tests/test_link_privacy.py b/apps/agreements/tests/test_link_privacy.py
index 1c348f5ef..5f1d18739 100644
--- a/apps/agreements/tests/test_link_privacy.py
+++ b/apps/agreements/tests/test_link_privacy.py
@@ -53,7 +53,6 @@ def test_token_pages_do_not_load_tracking_scripts_in_any_signing_state(self) ->
self.assertEqual(response.status_code, 410 if state == "used" else 400 if state == "invalid" else 200)
scripts = ScriptSources(response.content.decode())
self.assertNotIn("analytics.python.org", scripts.hosts)
- self.assertNotIn("media.ethicalads.io", scripts.hosts)
def test_token_download_and_terms_errors_do_not_load_trackers(self) -> None:
officer = make_officer()
@@ -70,7 +69,6 @@ def test_token_download_and_terms_errors_do_not_load_trackers(self) -> None:
self.assertEqual(response.status_code, 404)
scripts = ScriptSources(response.content.decode())
self.assertNotIn("analytics.python.org", scripts.hosts)
- self.assertNotIn("media.ethicalads.io", scripts.hosts)
def test_public_pages_retain_analytics(self) -> None:
terms = make_terms()
diff --git a/apps/sponsors/notifications.py b/apps/sponsors/notifications.py
index 52921cf0d..2168fb0bf 100644
--- a/apps/sponsors/notifications.py
+++ b/apps/sponsors/notifications.py
@@ -4,6 +4,7 @@
from django.contrib.admin.models import ADDITION, CHANGE, LogEntry
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
+from django.core.cache.utils import make_template_fragment_key
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
@@ -222,7 +223,7 @@ class RefreshSponsorshipsCache:
def notify(self, *args, **kwargs):
"""Delete the cached sponsors list to force a refresh."""
# clean up cached used by "sponsors/partials/sponsors-list.html"
- cache.delete("CACHED_SPONSORS_LIST")
+ cache.delete(make_template_fragment_key("SPONSORS_PAGE_LIST"))
class AssetCloseToDueDateNotificationToSponsors(BaseEmailSponsorshipNotification):
diff --git a/apps/sponsors/templates/sponsors/partials/sponsors-list.html b/apps/sponsors/templates/sponsors/partials/sponsors-list.html
index 4a45a7687..e2ec5785d 100644
--- a/apps/sponsors/templates/sponsors/partials/sponsors-list.html
+++ b/apps/sponsors/templates/sponsors/partials/sponsors-list.html
@@ -47,7 +47,7 @@
{% elif logo_place == "sponsors" %}
{% comment %}cache for 1 day{% endcomment %}
-{% cache 86400 CACHED_SPONSORS_LIST %}
+{% cache 86400 SPONSORS_PAGE_LIST %}
{% for package, placement_info in sponsorships_by_package.items %}
{% if placement_info.sponsorships %}
@@ -59,12 +59,12 @@ {{ pl
{% for sponsorship in placement_info.sponsorships %}
{% endfor %}
@@ -74,5 +74,5 @@
{{ pl
{% endif %}
{% endfor %}
-{% endcache CACHED_SPONSORS_LIST %}
+{% endcache SPONSORS_PAGE_LIST %}
{% endif %}
diff --git a/apps/sponsors/templatetags/sponsors.py b/apps/sponsors/templatetags/sponsors.py
index f751d709e..f442f23ec 100644
--- a/apps/sponsors/templatetags/sponsors.py
+++ b/apps/sponsors/templatetags/sponsors.py
@@ -5,6 +5,9 @@
from django import template
from django.utils.safestring import mark_safe
+from sorl.thumbnail import default as thumbnail_default
+from sorl.thumbnail import get_thumbnail
+from sorl.thumbnail.images import ImageFile
from apps.sponsors.models import Sponsorship, SponsorshipPackage, TieredBenefitConfiguration
from apps.sponsors.models.enums import LogoPlacementChoices, PublisherChoices
@@ -91,16 +94,39 @@ def benefit_name_for_display(benefit, package):
return benefit.name_for_display(package=package)
-@register.filter
-def ideal_size(image, ideal_dimension):
- """Scale an image width to fit within the given ideal dimension area."""
+@register.simple_tag
+def sponsor_logo(image, ideal_dimension):
+ """Size a logo so every sponsor in a tier gets the same visual area, with 1x and 2x PNG renditions.
+
+ The source size comes from sorl's key-value store: reading ``image.width`` would download
+ the full original from S3 on every render, while sorl records the size once per image.
+ Returns ``None`` when no file is associated with the field.
+ """
+ if not image:
+ return None
ideal_dimension = int(ideal_dimension)
try:
- w, h = image.width, image.height
- except (FileNotFoundError, ValueError):
- # FileNotFoundError: local dev doesn't have all images if DB is a copy from prod environment.
- # ValueError: no file is associated with the field.
- # Size as a square logo would be instead of erroring.
- w, h = ideal_dimension, ideal_dimension
-
- return int(w * math.sqrt((100 * ideal_dimension) / (w * h)))
+ source_width, source_height = thumbnail_default.kvstore.get_or_set(ImageFile(image)).size
+ except FileNotFoundError:
+ # local dev doesn't have all images if DB is a copy from prod environment;
+ # size it as a square logo would be instead of erroring.
+ width = int(math.sqrt(100 * ideal_dimension))
+ return {"src": image.url, "srcset": f"{image.url} {width}w", "width": width, "height": width}
+
+ # Equal area per logo, but no wider than the tier's grid column (ideal_dimension px).
+ width = min(int(math.sqrt(100 * ideal_dimension * source_width / source_height)), ideal_dimension)
+ # Never upscale: past the original's size a bigger file adds bytes, not detail.
+ one_x, two_x = (get_thumbnail(image, str(width * scale), format="PNG", upscale=False) for scale in (1, 2))
+ if source_width > width:
+ # sorl can land 1px off the requested width; drawing at the rendition's exact size
+ # keeps 1x screens on the 1x file instead of fetching 2x.
+ width, height = one_x.width, one_x.height
+ else:
+ height = round(width * source_height / source_width)
+ renditions = {im.width: im.url for im in (one_x, two_x)}
+ return {
+ "src": one_x.url,
+ "srcset": ", ".join(f"{url} {im_width}w" for im_width, url in renditions.items()),
+ "width": width,
+ "height": height,
+ }
diff --git a/apps/sponsors/tests/test_notifications.py b/apps/sponsors/tests/test_notifications.py
index 8cbe1d79a..c138e4fe7 100644
--- a/apps/sponsors/tests/test_notifications.py
+++ b/apps/sponsors/tests/test_notifications.py
@@ -5,6 +5,9 @@
from django.contrib.admin.models import ADDITION, CHANGE, LogEntry
from django.contrib.contenttypes.models import ContentType
from django.core import mail
+from django.core.cache import cache
+from django.core.cache.utils import make_template_fragment_key
+from django.template import Context, Template
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase
from django.utils import timezone
@@ -489,3 +492,14 @@ def test_create_log_entry_for_cloned_resource(self):
self.assertEqual(str(self.package), log_entry.object_repr)
self.assertEqual(log_entry.action_flag, ADDITION)
self.assertEqual(log_entry.change_message, "Cloned from 2022 sponsorship application config")
+
+
+class RefreshSponsorshipsCacheTests(TestCase):
+ def test_clears_cached_sponsors_page_fragment(self):
+ Template('{% load sponsors %}{% list_sponsors "sponsors" %}').render(Context())
+ key = make_template_fragment_key("SPONSORS_PAGE_LIST")
+ self.assertIsNotNone(cache.get(key))
+
+ notifications.RefreshSponsorshipsCache().notify()
+
+ self.assertIsNone(cache.get(key))
diff --git a/apps/sponsors/tests/test_templatetags.py b/apps/sponsors/tests/test_templatetags.py
index ea01ef0fc..0b861cd63 100644
--- a/apps/sponsors/tests/test_templatetags.py
+++ b/apps/sponsors/tests/test_templatetags.py
@@ -1,8 +1,14 @@
+import io
import string
+import tempfile
from unittest.mock import patch
-from django.test import TestCase
+from django.core.files.storage import FileSystemStorage
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.template import Context, Template
+from django.test import TestCase, override_settings
from model_bakery import baker
+from PIL import Image
from apps.sponsors.models import Sponsor, SponsorshipBenefit, TieredBenefitConfiguration
from apps.sponsors.templatetags.sponsors import (
@@ -10,8 +16,8 @@
benefit_quantity_for_package,
escape_markdown,
full_sponsorship,
- ideal_size,
list_sponsors,
+ sponsor_logo,
)
@@ -93,26 +99,15 @@ def test_display_name_for_display_from_benefit(self, mocked_name_for_display):
mocked_name_for_display.assert_called_once_with(package=package)
-class IdealSizeFilterTests(TestCase):
- def test_scales_width_to_fit_ideal_area(self):
- class Image:
- width = 400
- height = 200
-
- # int(400 * sqrt(20000 / 80000)) = int(400 * 0.5) = 200
- self.assertEqual(ideal_size(Image(), 200), 200)
-
- def test_no_file_associated_is_sized_as_square(self):
- logo = Sponsor(web_logo="").web_logo
-
- # int(250 * sqrt(25000 / 62500)) = 158, same as a square logo
- self.assertEqual(ideal_size(logo, 250), 158)
+class SponsorLogoFallbackTests(TestCase):
+ def test_no_file_associated_renders_no_logo(self):
+ self.assertIsNone(sponsor_logo(Sponsor(web_logo="").web_logo, 250))
def test_file_missing_from_storage_is_sized_as_square(self):
- logo = Sponsor(web_logo="sponsor_web_logos/does-not-exist.png").web_logo
+ logo = sponsor_logo(Sponsor(web_logo="sponsor_web_logos/does-not-exist.png").web_logo, 300)
- # int(300 * sqrt(30000 / 90000)) = 173, same as a square logo
- self.assertEqual(ideal_size(logo, 300), 173)
+ # int(sqrt(100 * 300)) = 173, same as a square logo
+ self.assertEqual((logo["width"], logo["height"]), (173, 173))
class EscapePandocMarkdownTests(TestCase):
@@ -139,3 +134,76 @@ def test_letters_digits_and_spaces_are_untouched(self):
def test_non_string_input_is_coerced(self):
self.assertEqual(escape_markdown(42), "42")
+
+
+class SponsorLogoTagTests(TestCase):
+ def setUp(self):
+ media_root = tempfile.TemporaryDirectory()
+ self.addCleanup(media_root.cleanup)
+ settings_override = override_settings(MEDIA_ROOT=media_root.name)
+ settings_override.enable()
+ self.addCleanup(settings_override.disable)
+
+ def make_logo(self, size, **sponsor_attrs):
+ buf = io.BytesIO()
+ Image.new("RGB", size, "red").save(buf, "PNG")
+ sponsor = baker.make(
+ "sponsors.Sponsor", web_logo=SimpleUploadedFile("logo.png", buf.getvalue()), **sponsor_attrs
+ )
+ return sponsor.web_logo
+
+ def srcset_widths(self, logo):
+ return [int(candidate.split()[1].removesuffix("w")) for candidate in logo["srcset"].split(", ")]
+
+ def test_logo_gets_equal_area_size_and_true_2x_rendition(self):
+ logo = sponsor_logo(self.make_logo((600, 300)), "350")
+
+ self.assertEqual((logo["width"], logo["height"]), (264, 132))
+ self.assertEqual(self.srcset_widths(logo), [264, 528])
+ for candidate in logo["srcset"].split(", "):
+ url, descriptor = candidate.split()
+ with Image.open(FileSystemStorage().path(url.removeprefix("/media/"))) as rendition:
+ self.assertEqual(f"{rendition.width}w", descriptor)
+ self.assertEqual(rendition.format, "PNG")
+
+ def test_very_wide_logo_is_capped_at_column_width(self):
+ logo = sponsor_logo(self.make_logo((800, 200)), "350")
+
+ self.assertEqual((logo["width"], logo["height"]), (350, 88))
+ self.assertEqual(self.srcset_widths(logo), [350, 700])
+
+ def test_display_size_matches_1x_rendition_when_resize_rounds(self):
+ # 1500x600 at 300 asks sorl for 273px wide; sorl rounds via the height and returns 272.
+ logo = sponsor_logo(self.make_logo((1500, 600)), "300")
+
+ self.assertEqual((logo["width"], logo["height"]), (272, 109))
+ self.assertEqual(self.srcset_widths(logo)[0], logo["width"])
+
+ def test_small_logo_is_not_upscaled_past_its_original(self):
+ logo = sponsor_logo(self.make_logo((300, 300)), "350")
+
+ self.assertEqual((logo["width"], logo["height"]), (187, 187))
+ self.assertEqual(self.srcset_widths(logo), [187, 300])
+
+ def test_repeat_render_does_not_read_the_original(self):
+ image = self.make_logo((800, 200))
+ first = sponsor_logo(image, "350")
+
+ with patch.object(FileSystemStorage, "open", side_effect=AssertionError("read original")):
+ self.assertEqual(sponsor_logo(image, "350"), first)
+
+ def test_sponsors_page_links_only_logos_with_a_landing_page(self):
+ package = baker.make("sponsors.SponsorshipPackage", logo_dimension=350)
+ for name, url in (("Linked", "https://linked.example/"), ("Unlinked", None)):
+ sponsor = self.make_logo((600, 300), name=name, landing_page_url=url).instance
+ sponsorship = baker.make_recipe(
+ "apps.sponsors.tests.finalized_sponsorship", sponsor=sponsor, package=package
+ )
+ baker.make_recipe("apps.sponsors.tests.logo_at_sponsors_feature", sponsor_benefit__sponsorship=sponsorship)
+
+ html = Template('{% load sponsors %}{% list_sponsors "sponsors" %}').render(Context())
+
+ self.assertEqual(html.count("
+ src="https://analytics.python.org/js/script.file-downloads.outbound-links.tagged-events.js">
{% endif %}
@@ -23,13 +23,6 @@
- {% if not request.disable_tracking %}
-
- {% endif %}
{% stylesheet 'style' %}