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 @@

Job Board Sponsors

{% 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 %}
-
+ {% sponsor_logo sponsorship.sponsor.web_logo dimension as logo %} + {% if logo %} + {% if sponsorship.sponsor.landing_page_url %}{% endif %} + {{ sponsorship.sponsor.name }} logo + {% if sponsorship.sponsor.landing_page_url %}{% endif %} + {% endif %}

{{ sponsorship.sponsor.name }}

{% 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' %}