From 7779a2d2d4bd7619f70bc587beefd7e23cfca9b7 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Fri, 31 Jul 2026 19:36:48 -0300 Subject: [PATCH 1/2] feat: render real badges on profiles and author cards --- ak/homepage.py | 4 +- badges/display.py | 91 +++++ badges/tests/test_profile.py | 491 +++++++++++++++++++++++++++ badges/tests/test_seed_data.py | 9 +- core/views.py | 34 +- libraries/mixins.py | 3 +- libraries/utils.py | 6 +- news/views.py | 10 +- templates/v3/includes/_badge_v3.html | 4 +- templates/v3/posts_list.html | 2 +- templates/v3/user_profile_page.html | 4 +- users/models.py | 19 +- users/profile_cards.py | 10 +- users/views.py | 41 +-- 14 files changed, 656 insertions(+), 72 deletions(-) create mode 100644 badges/tests/test_profile.py diff --git a/ak/homepage.py b/ak/homepage.py index 063cbb634..918d42838 100644 --- a/ak/homepage.py +++ b/ak/homepage.py @@ -2,6 +2,7 @@ from django.urls import reverse +from badges.display import active_badges_prefetch from core.constants import SLACK_MEMBER_COUNT from core.models import HomepageSettings from core.templatetags.custom_static import large_static @@ -107,7 +108,8 @@ def build_community_posts(limit=5): popular_entries = ( Entry.objects.ranked() .filter(deleted_at__isnull=True, published=True) - .select_related("author")[:limit] + .select_related("author") + .prefetch_related(active_badges_prefetch("author__badges"))[:limit] ) return [entry.to_v3_post_card_dict() for entry in popular_entries] diff --git a/badges/display.py b/badges/display.py index 4f5c26d88..04060daca 100644 --- a/badges/display.py +++ b/badges/display.py @@ -5,11 +5,24 @@ Every row is built from ``badges.summary.user_badge_summary``, so the picker adds no queries of its own. + +The same module turns a user's awarded badges into what the v3 badge templates +render. Those templates take a component token and a label, never a model +instance, so the rank-to-asset mapping belongs here rather than on the user +model. + +Ordering there is by *rank*, not threshold: thresholds are not comparable across +badges (a reviewer diamond needs 5 achievements, a commits silver needs 12), so +the raw threshold only breaks ties within a rank. """ from typing import NamedTuple +from django.db.models import Prefetch +from django.utils import timezone + from badges.enums import BadgeLabel, TierRank, label_order, rank_order +from badges.models import UserBadge from badges.summary import user_badge_summary from core.constants import BadgeToken @@ -220,3 +233,81 @@ def _detail(phrases, tier, count, is_held, gap): def _unit(phrases, count): """The badge's unit noun, pluralised for ``count``.""" return phrases.unit if count == 1 else phrases.plural + + +def active_badges_prefetch(lookup="badges"): + """The rows ``held_badges`` reads, prefetched at ``lookup``. + + Callers rendering many users at once (author cards on a news page) need this, + or ``held_badges`` queries once per user instead of reading the cache. + + ``lookup`` exists because the path is load-bearing. A queryset that reaches + its users through ``select_related`` cannot be handed + ``Prefetch("author", queryset=User.objects.prefetch_related(...))``: Django + finds the foreign key already cached, skips the prefetch, and silently drops + the nested badge prefetch with it. Such a caller asks for the badges through + the path instead - ``active_badges_prefetch("author__badges")``. + """ + return Prefetch( + lookup, + queryset=UserBadge.objects.active().select_related("badge", "tier"), + ) + + +def held_badges(user, include_hidden=False): + """The user's active badges, highest rank first, each rank once. + + Returns an empty list when the user has hidden their badges, unless + ``include_hidden`` is set - which only the owner's own views should do. + + Retiring a tier keeps the badges already awarded against it, so a user who + also qualifies under its replacement holds the same rank twice. Both rows are + real history; only one of them is a badge to show. + """ + if user.hide_badges and not include_hidden: + return [] + if "badges" in getattr(user, "_prefetched_objects_cache", {}): + rows = [badge for badge in user.badges.all() if badge.revoked_at is None] + else: + rows = list(user.badges.active().select_related("badge", "tier")) + + unique = {} + for row in sorted(rows, key=_rank_key, reverse=True): + unique.setdefault((row.badge_id, row.tier.rank), row) + return list(unique.values()) + + +def featured_badge(user, include_hidden=False): + """The user's headline badge as a card dict, or ``None`` if they hold none. + + Display-only for now; letting the user choose which badge to feature comes + later. + """ + badges = held_badges(user, include_hidden=include_hidden) + return badge_card(badges[0]) if badges else None + + +def badge_cards(user, include_hidden=False): + """Every active badge as a card dict, highest rank first.""" + return [ + badge_card(badge) for badge in held_badges(user, include_hidden=include_hidden) + ] + + +def badge_card(user_badge): + """One awarded badge as the dict the badge templates read. + + ``awarded_at`` is stored in UTC, so the calendar day has to be taken in the + project's timezone rather than off the raw value: an evening award would + otherwise be dated to the following day everywhere west of UTC. + """ + return { + "name": user_badge.badge.get_label_display(), + "icon": TIER_TOKENS[user_badge.tier.rank], + "earned_date": timezone.localtime(user_badge.awarded_at).date(), + } + + +def _rank_key(user_badge): + """Sort key placing the highest rank first, threshold breaking ties.""" + return rank_order(user_badge.tier.rank), user_badge.tier.threshold diff --git a/badges/tests/test_profile.py b/badges/tests/test_profile.py new file mode 100644 index 000000000..d73653c8a --- /dev/null +++ b/badges/tests/test_profile.py @@ -0,0 +1,491 @@ +"""Tests for turning a user's awarded badges into rendered profile data.""" + +from datetime import date, timedelta + +import pytest +import waffle.testutils +from django.db import connection +from django.template.loader import render_to_string +from django.test.utils import CaptureQueriesContext, override_settings +from django.utils import timezone +from django.utils.formats import date_format +from model_bakery import baker + +from badges import display +from badges.enums import TierRank +from badges.models import Achievement, UserAchievement, UserBadge +from core.constants import BadgeToken + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _catalogue(catalogue): + """Seed the real achievement catalogue for every test in this module.""" + + +def _grant(user, slug, count=1): + """Grant `count` manual achievements of `slug` (recalcs via the signal).""" + achievement = Achievement.objects.get(slug=slug) + for _ in range(count): + UserAchievement.objects.create( + user=user, achievement=achievement, source_type="manual" + ) + + +def _reload(user): + """Re-fetch the user so cached_property badge state is discarded.""" + return type(user).objects.get(pk=user.pk) + + +def _published_entry(author, index): + """A visible entry suitable for both recent and ranked card origins.""" + from news.models import Entry + + published_at = timezone.now() - timedelta(minutes=index) + return Entry.objects.create( + title=f"Post {index}", + slug=f"profile-query-post-{index}", + author=author, + moderator=author, + approved_at=published_at, + publish_at=published_at, + summary="A test post.", + ) + + +def _badge_queries(call): + """Return a call's value and the number of UserBadge queries it issued.""" + with CaptureQueriesContext(connection) as queries: + value = call() + badge_query_count = sum( + 'FROM "badges_userbadge"' in query["sql"] for query in queries + ) + return value, badge_query_count + + +def test_no_badges_yields_no_featured_badge(plain_user): + """A fresh user has nothing to feature and an empty card list.""" + assert display.held_badges(plain_user) == [] + assert display.featured_badge(plain_user) is None + assert display.badge_cards(plain_user) == [] + assert plain_user.featured_badge is None + + +def test_featured_badge_and_card_list(plain_user): + """The featured badge and the card list expose real earned badges.""" + _grant(plain_user, "library-authoring") # bronze (threshold 1) + plain_user = _reload(plain_user) + + featured = display.featured_badge(plain_user) + assert featured["name"] == "Library Author" + assert featured["icon"] == BadgeToken.TIER_1 # bronze -> tier-1 + + cards = display.badge_cards(plain_user) + assert cards[0]["icon"] == BadgeToken.TIER_1 + assert cards[0]["name"] == "Library Author" + assert cards[0]["earned_date"] is not None + + +@pytest.mark.parametrize( + ("rank", "token"), + [ + (TierRank.BRONZE, BadgeToken.TIER_1), + (TierRank.SILVER, BadgeToken.TIER_2), + (TierRank.GOLD, BadgeToken.TIER_3), + (TierRank.PLATINUM, BadgeToken.TIER_4), + (TierRank.DIAMOND, BadgeToken.TIER_5), + ], +) +def test_rank_maps_to_the_component_token_of_the_same_height(rank, token): + """Diamond is the top rank, so it draws the component's top badge.""" + assert display.TIER_TOKENS[rank] == token + + +def test_token_numbers_climb_with_the_rank_ladder(): + """The reason the mapping above is what it is, asserted on its own. + + The component's assets are numbered rather than named after a metal, so the + number *is* the ladder - and a mapping that does not climb with the ranks + would show a lower-ranked member the higher-looking medal. + """ + assert [display.TIER_TOKENS[rank] for rank in TierRank] == sorted( + display.TIER_TOKENS.values() + ) + + +def test_badge_card_renders_its_semantic_asset_and_award_date(plain_user): + """The card hands the component a token and a date the template formats. + + A ``date`` rather than a preformatted string: the project renders dates + through Django's ``DATE_FORMAT`` everywhere else, so a string here would pin + this one card to a format nothing else uses. + """ + achievement = Achievement.objects.get(slug="library-review") + badge = achievement.badges.get() + diamond = badge.tiers.get(rank=TierRank.DIAMOND) + awarded_at = timezone.datetime(2025, 3, 7, 14, 30, tzinfo=timezone.UTC) + user_badge = UserBadge.objects.create( + user=plain_user, + badge=badge, + tier=diamond, + awarded_at=awarded_at, + ) + + card = display.badge_card(user_badge) + rendered = render_to_string("v3/includes/_badges_card.html", {"badges": [card]}) + + assert card["earned_date"] == date(2025, 3, 7) + assert "img/v3/badges/tier-5.png" in rendered + assert "img/v3/badges/tier-4.png" not in rendered + assert date_format(date(2025, 3, 7)) in rendered + + +@override_settings(TIME_ZONE="America/New_York") +def test_badge_card_dates_the_award_in_the_project_timezone(plain_user): + """A late-evening award keeps the day it happened on, not the UTC day. + + ``TIME_ZONE`` is UTC today, which makes the naive reading look correct; this + pins the boundary so configuring a real timezone cannot shift every badge + awarded after 7pm to the next day. + """ + badge = Achievement.objects.get(slug="library-review").badges.get() + user_badge = UserBadge.objects.create( + user=plain_user, + badge=badge, + tier=badge.tiers.get(rank=TierRank.BRONZE), + # 8:30pm in New York, already the 8th in UTC. + awarded_at=timezone.datetime(2025, 3, 8, 1, 30, tzinfo=timezone.UTC), + ) + + assert display.badge_card(user_badge)["earned_date"] == date(2025, 3, 7) + + +def test_featured_badge_picks_the_top_tier(plain_user): + """With several tiers earned, the highest one is featured.""" + badge = Achievement.objects.get(slug="library-review").badges.first() + # Reviewer tiers: bronze=1, silver=2, gold=3. Three achievements -> gold. + _grant(plain_user, "library-review", count=3) + plain_user = _reload(plain_user) + + assert display.held_badges(plain_user)[0].tier.rank == TierRank.GOLD + assert ( + UserBadge.objects.filter( + user=plain_user, badge=badge, revoked_at__isnull=True + ).count() + == 3 + ) + + +def test_rank_beats_threshold_across_badge_types(plain_user): + """A higher rank wins even when another badge has a larger threshold. + + Reviewer diamond needs only 5 achievements while commits silver needs 12, + so sorting by raw threshold would wrongly feature the silver. + """ + _grant(plain_user, "library-review", count=5) # reviewer diamond + _grant(plain_user, "code-commits", count=12) # commits silver + plain_user = _reload(plain_user) + + held = display.held_badges(plain_user) + assert held[0].tier.rank == TierRank.DIAMOND + orders = [TierRank(badge.tier.rank).order for badge in held] + assert orders == sorted(orders, reverse=True) + + +def test_replaced_tier_does_not_duplicate_a_rank(plain_user): + """Grandfathering can leave two rows for one rank; show the rank once. + + Retiring a tier deliberately keeps the badges awarded against it, so a user + who also qualifies under the replacement holds both rows and the badges card + would list the same medal twice. + """ + from badges.models import BadgeTier + from badges.services import recalculate_badges + + achievement = Achievement.objects.get(slug="library-review") + _grant(plain_user, "library-review", count=5) + badge = achievement.badges.first() + bronze = badge.tiers.get(rank=TierRank.BRONZE) + bronze.is_active = False + bronze.save(update_fields=["is_active"]) + BadgeTier.objects.create(badge=badge, rank=TierRank.BRONZE, threshold=5) + recalculate_badges(plain_user.pk, achievement.pk) + plain_user = _reload(plain_user) + + ranks = [held.tier.rank for held in display.held_badges(plain_user)] + + assert len(ranks) == len(set(ranks)) + assert ranks.count(TierRank.BRONZE) == 1 + + +def test_hide_badges_suppresses_every_public_accessor(plain_user): + """hide_badges empties everything the public profile can reach.""" + _grant(plain_user, "library-authoring") + plain_user = _reload(plain_user) + assert plain_user.featured_badge is not None + + plain_user.hide_badges = True + plain_user.save(update_fields=["hide_badges"]) + plain_user = _reload(plain_user) + + assert display.held_badges(plain_user) == [] + assert display.featured_badge(plain_user) is None + assert display.badge_cards(plain_user) == [] + assert plain_user.featured_badge is None + assert plain_user.to_v3_profile_dict()["badge"] is None + + +def test_hide_badges_can_be_bypassed_for_the_owner(plain_user): + """include_hidden lets the owner still see badges they have hidden.""" + _grant(plain_user, "library-authoring") + plain_user.hide_badges = True + plain_user.save(update_fields=["hide_badges"]) + plain_user = _reload(plain_user) + + assert display.featured_badge(plain_user, include_hidden=True)["icon"] == ( + BadgeToken.TIER_1 + ) + assert len(display.badge_cards(plain_user, include_hidden=True)) == 1 + + +def test_revoked_badges_are_not_displayed(plain_user): + """A revoked badge disappears from the profile without being deleted.""" + _grant(plain_user, "library-authoring") + UserBadge.objects.filter(user=plain_user).update(revoked_at="2026-01-01T00:00:00Z") + plain_user = _reload(plain_user) + + assert display.held_badges(plain_user) == [] + assert UserBadge.objects.filter(user=plain_user).exists() + + +def test_held_badges_uses_the_prefetch_cache(plain_user, django_assert_num_queries): + """Callers rendering many users must be able to avoid a query each.""" + from users.models import User + + _grant(plain_user, "library-authoring") + + user = User.objects.prefetch_related(display.active_badges_prefetch()).get( + pk=plain_user.pk + ) + with django_assert_num_queries(0): + assert len(display.held_badges(user)) == 1 + assert display.featured_badge(user)["icon"] == BadgeToken.TIER_1 + + +def test_news_author_prefetch_covers_the_whole_card( + plain_user, django_assert_num_queries +): + """A news page's author cards must cost no query each once prefetched. + + ``Prefetch("author", ...)`` looks like it does this and does not: the same + queryset select_relates the author, so Django finds the foreign key cached, + skips the prefetch, and drops the nested badge prefetch with it. Asserted + against the view's own tuple, through the function that reads it, because the + failure is silent - the page renders correctly and just queries per card. + """ + from news.models import Entry + from news.views import EntryDetailView + from users.profile_cards import user_profile_card + + _grant(plain_user, "library-authoring") + for index in range(3): + Entry.objects.create( + title=f"Post {index}", + slug=f"post-{index}", + author=plain_user, + publish_at=timezone.now(), + ) + + entries = list( + Entry.objects.select_related("author").prefetch_related( + *EntryDetailView.AUTHOR_PREFETCH + ) + ) + + assert len(entries) == 3 + with django_assert_num_queries(0): + cards = [user_profile_card(entry.author) for entry in entries] + assert {card["badge_label"] for card in cards} == {"Library Author"} + + +def test_community_recent_post_badge_query_is_constant(plain_user): + """The community page loads author badges once, not once per post card.""" + from core.views import build_recent_community_posts + + _grant(plain_user, "library-authoring") + _published_entry(plain_user, 0) + + one_card, one_badge_query = _badge_queries(build_recent_community_posts) + + for index in range(1, 4): + author = baker.make("users.User", email=f"community-{index}@example.com") + _grant(author, "library-authoring") + _published_entry(author, index) + four_cards, four_badge_queries = _badge_queries(build_recent_community_posts) + + assert one_badge_query == four_badge_queries == 1 + assert len(one_card) == 1 + assert len(four_cards) == 4 + assert {card["author"]["badge_label"] for card in four_cards} == {"Library Author"} + + +def test_homepage_ranked_post_badge_query_is_constant(plain_user): + """The V3 homepage loads ranked-post author badges in one query.""" + from ak.homepage import build_community_posts + + _grant(plain_user, "library-authoring") + _published_entry(plain_user, 0) + + one_card, one_badge_query = _badge_queries(build_community_posts) + + for index in range(1, 4): + author = baker.make("users.User", email=f"homepage-{index}@example.com") + _grant(author, "library-authoring") + _published_entry(author, index) + four_cards, four_badge_queries = _badge_queries(build_community_posts) + + assert one_badge_query == four_badge_queries == 1 + assert len(one_card) == 1 + assert len(four_cards) == 4 + assert {card["author"]["badge_label"] for card in four_cards} == {"Library Author"} + + +def test_library_intro_badge_query_is_constant(library_version, plain_user): + """The homepage library intro prefetches its User authors and maintainers.""" + from libraries.utils import build_library_intro_context + + _grant(plain_user, "library-authoring") + library_version.authors.add(plain_user) + + one_card, one_badge_query = _badge_queries( + lambda: build_library_intro_context(library_version) + ) + + for index in range(1, 4): + user = baker.make("users.User", email=f"library-intro-{index}@example.com") + _grant(user, "library-authoring") + relation = library_version.authors if index % 2 else library_version.maintainers + relation.add(user) + four_cards, four_badge_queries = _badge_queries( + lambda: build_library_intro_context(library_version) + ) + + assert one_badge_query == four_badge_queries == 1 + assert len(one_card["authors"]) == 1 + assert len(four_cards["authors"]) == 4 + assert {card["badge_label"] for card in four_cards["authors"]} == {"Library Author"} + + +def test_library_detail_user_badge_query_is_constant(library_version, plain_user): + """The detail-page User origins prefetch badges before card conversion.""" + from libraries.mixins import ContributorMixin + + _grant(plain_user, "library-authoring") + library_version.authors.add(plain_user) + mixin = ContributorMixin() + + def author_cards(): + authors = mixin.get_related(library_version, "authors") + return [author.to_v3_profile_dict("Author") for author in authors] + + one_card, one_badge_query = _badge_queries(author_cards) + + for index in range(1, 4): + user = baker.make("users.User", email=f"library-detail-{index}@example.com") + _grant(user, "library-authoring") + library_version.authors.add(user) + four_cards, four_badge_queries = _badge_queries(author_cards) + + assert one_badge_query == four_badge_queries == 1 + assert len(one_card) == 1 + assert len(four_cards) == 4 + assert {card["badge_label"] for card in four_cards} == {"Library Author"} + + +@waffle.testutils.override_flag("v3", active=True) +def test_own_profile_page_shows_hidden_badges(plain_user, tp): + """The owner's own v3 profile still renders badges they have hidden.""" + _grant(plain_user, "library-authoring") + plain_user.hide_badges = True + plain_user.save(update_fields=["hide_badges"]) + + tp.client.force_login(plain_user) + response = tp.get(tp.reverse("profile-account")) + + tp.response_200(response) + assert response.context["user_info"]["featured_badge"]["icon"] == BadgeToken.TIER_1 + assert len(response.context["profile_badges"]) == 1 + + +def test_user_profile_card_emits_the_keys_the_template_reads(plain_user): + """_user_profile.html reads `badge`/`badge_label`, not `badge_url`.""" + from users.profile_cards import user_profile_card + + _grant(plain_user, "library-authoring") # bronze (threshold 1) + plain_user = _reload(plain_user) + + card = user_profile_card(plain_user) + + assert card["badge"] == BadgeToken.TIER_1 + assert card["badge_label"] == "Library Author" + assert "badge_url" not in card + + +def test_user_profile_card_without_badges(plain_user): + """A badgeless user renders no badge rather than a placeholder medal.""" + from users.profile_cards import user_profile_card + + card = user_profile_card(plain_user) + + assert card["badge"] is None + assert card["badge_label"] == "" + + +def test_v3_profile_dict_carries_the_badge_label(plain_user): + """_user_profile.html shows the badge label on hover, so it must be set.""" + _grant(plain_user, "library-authoring") + plain_user = _reload(plain_user) + + profile = plain_user.to_v3_profile_dict() + + assert profile["badge"] == BadgeToken.TIER_1 + assert profile["badge_label"] == "Library Author" + + +@waffle.testutils.override_flag("v3", active=True) +def test_v3_news_list_renders_a_real_badge_for_the_sidebar_user(plain_user, tp): + """The sidebar card showed a hardcoded "Bug Catcher" label for everyone.""" + _grant(plain_user, "library-authoring") + tp.client.force_login(plain_user) + + response = tp.get(tp.reverse("news")) + + tp.response_200(response) + body = response.content.decode() + assert "Bug Catcher" not in body + assert "Library Author" in body + + +@waffle.testutils.override_flag("v3", active=True) +def test_v3_news_list_renders_no_badge_without_one(plain_user, tp): + """A badgeless user gets no badge chip rather than a placeholder label.""" + tp.client.force_login(plain_user) + + response = tp.get(tp.reverse("news")) + + tp.response_200(response) + assert "user-card__badge" not in response.content.decode() + + +@waffle.testutils.override_flag("v3", active=True) +def test_own_profile_page_renders_without_badges(plain_user, tp): + """The empty state must render; featured_badge is None, not a blank dict.""" + tp.client.force_login(plain_user) + + response = tp.get(tp.reverse("profile-account")) + + tp.response_200(response) + assert response.context["user_info"]["featured_badge"] is None + assert response.context["profile_badges"] == [] + assert "badges-card__empty" in response.content.decode() diff --git a/badges/tests/test_seed_data.py b/badges/tests/test_seed_data.py index 50787963a..fd43ee5ee 100644 --- a/badges/tests/test_seed_data.py +++ b/badges/tests/test_seed_data.py @@ -3,7 +3,8 @@ ``Achievement.slug`` is an open field by design (admins may add manual-only types), so the slugs the codebase hard-codes are only safe if something checks they still exist. These tests are that check: they cover the seams between -``badges.enums``, ``badges.seed_data`` and ``badges.sources``. +``badges.enums``, ``badges.seed_data``, ``badges.sources`` and the map in +``badges.display`` that turns a tier rank into a rendered asset. """ import os @@ -16,6 +17,7 @@ from model_bakery import baker from badges import sources +from badges.display import TIER_TOKENS from badges.enums import AchievementSlug, BadgeLabel, TierRank from badges.models import Achievement, Badge, BadgeTier from badges.seed_data import SEED_CATALOGUE, seed_catalogue @@ -105,6 +107,11 @@ def test_automatic_slugs_are_derived_from_the_iterators(): ] +def test_tier_token_map_covers_every_rank(): + """A rank missing from the map would raise when a user earns that tier.""" + assert set(TIER_TOKENS) == set(TierRank) + + @pytest.mark.django_db def test_seed_catalogue_creates_the_whole_taxonomy(catalogue): """Seeding produces one achievement and badge per entry, with five tiers.""" diff --git a/core/views.py b/core/views.py index c0966bcc2..e7593e380 100644 --- a/core/views.py +++ b/core/views.py @@ -33,6 +33,7 @@ from django.views.generic import TemplateView from waffle import flag_is_active +from badges.display import active_badges_prefetch from core.templatetags.custom_static import large_static from config.settings import ENABLE_DB_CACHE from libraries.constants import LATEST_RELEASE_URL_PATH_STR @@ -143,6 +144,18 @@ class BoostDevelopmentView(CalendarView): template_name = "boost_development.html" +def build_recent_community_posts(): + """The four recent post cards, with their authors' active badges loaded.""" + entries = ( + Entry.objects.published() + .filter(deleted_at__isnull=True) + .select_related("author") + .prefetch_related(active_badges_prefetch("author__badges")) + .order_by("-publish_at")[:4] + ) + return [entry.to_v3_post_card_dict() for entry in entries] + + class CommunityView(MailingListCardMixin, V3Mixin, TemplateView): template_name = "community.html" v3_template_name = "v3/community.html" @@ -308,14 +321,7 @@ def get_v3_context_data(self, **kwargs): }, ) ) - recent_entries = ( - Entry.objects.published() - .filter(deleted_at__isnull=True) - .select_related("author") - .order_by("-publish_at")[:4] - ) - - ctx["posts"] = [entry.to_v3_post_card_dict() for entry in recent_entries] + ctx["posts"] = build_recent_community_posts() ctx["news_url"] = self.request.build_absolute_uri(reverse("news")) ctx["contribute_url"] = self.request.build_absolute_uri( "/doc/contributor-guide/contributors-faq.html" @@ -1626,15 +1632,15 @@ def get_context_data(self, **kwargs): "checked": True, }, { - "value": "diamond", + "value": "platinum", "icon": BadgeToken.TIER_4, - "icon_alt": "Diamond badge", + "icon_alt": "Platinum badge", "checked": False, }, { - "value": "platinum", + "value": "diamond", "icon": BadgeToken.TIER_5, - "icon_alt": "Platinum badge", + "icon_alt": "Diamond badge", "checked": False, }, { @@ -1658,13 +1664,13 @@ def get_context_data(self, **kwargs): { "value": "star-tier-4", "icon": BadgeToken.STAR_TIER_4, - "icon_alt": "Diamond star", + "icon_alt": "Platinum star", "checked": False, }, { "value": "star-tier-5", "icon": BadgeToken.STAR_TIER_5, - "icon_alt": "Platinum star", + "icon_alt": "Diamond star", "checked": False, }, { diff --git a/libraries/mixins.py b/libraries/mixins.py index f7d2e5748..83f4b425f 100644 --- a/libraries/mixins.py +++ b/libraries/mixins.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.utils.html import format_html +from badges.display import active_badges_prefetch from core.models import RenderedContent from libraries.constants import ( LATEST_RELEASE_URL_PATH_STR, @@ -283,7 +284,7 @@ def get_related(self, library_version, relation="maintainers", exclude_ids=None) raise ValueError("relation must be maintainers or authors.") if exclude_ids: qs = qs.exclude(id__in=exclude_ids) - qs = list(qs) + qs = list(qs.prefetch_related(active_badges_prefetch())) patch_commit_authors(qs) return qs diff --git a/libraries/utils.py b/libraries/utils.py index 19e481512..3089b78ad 100644 --- a/libraries/utils.py +++ b/libraries/utils.py @@ -15,7 +15,7 @@ from dateutil.parser import ParserError, parse from django.conf import settings -from django.db.models import Count, F, QuerySet +from django.db.models import Count, F, QuerySet, prefetch_related_objects from django.db.models.functions import Lower from django.urls import reverse from django.utils import timezone as django_timezone @@ -606,6 +606,10 @@ def build_library_intro_context( maintainers = list(library_version.maintainers.exclude(id__in=author_ids)) combined = (authors + maintainers)[:max_authors] + if combined: + from badges.display import active_badges_prefetch + + prefetch_related_objects(combined, active_badges_prefetch()) roles = {} for user in combined: roles[user.id] = "Author" if user.id in author_ids else "Maintainer" diff --git a/news/views.py b/news/views.py index 0ac66240b..3d7fdc87f 100644 --- a/news/views.py +++ b/news/views.py @@ -40,6 +40,7 @@ from wagtail.blocks import Block from wagtail.images.models import Image +from badges.display import active_badges_prefetch from core.mixins import V3Mixin from pages.blocks import NEWS_BLOCK, BLOG_BLOCK, LINK_BLOCK, VIDEO_BLOCK from pages.models import PostPage, PostIndexPage @@ -260,7 +261,14 @@ class EntryDetailView(V3Mixin, DetailView): template_name = "news/detail.html" v3_template_name = "news/v3/detail.html" - AUTHOR_PREFETCH = ("author__maintainers",) + # Each author card reads the author's badges; without the prefetch that is + # one extra query per card, and a detail page renders up to five. Asked for + # through the path, because these querysets also select_related the author - + # see ``badges.display.active_badges_prefetch``. + AUTHOR_PREFETCH = ( + "author__maintainers", + active_badges_prefetch("author__badges"), + ) def get_queryset(self): qs = super().get_queryset() diff --git a/templates/v3/includes/_badge_v3.html b/templates/v3/includes/_badge_v3.html index 1b0c27a16..100fd4015 100644 --- a/templates/v3/includes/_badge_v3.html +++ b/templates/v3/includes/_badge_v3.html @@ -7,8 +7,8 @@ tier-1 = bronze (entry / 2 years) tier-2 = silver (5 years) tier-3 = gold (10 years) - tier-4 = diamond (15 years) - tier-5 = platinum (20+ years / top) + tier-4 = platinum (15 years) + tier-5 = diamond (20+ years / top) Props: token (required) — one of: diff --git a/templates/v3/posts_list.html b/templates/v3/posts_list.html index 9dceb0882..f5d400efe 100644 --- a/templates/v3/posts_list.html +++ b/templates/v3/posts_list.html @@ -38,7 +38,7 @@

Posts

{% if request.user.is_authenticated %} {% with u=request.user %} {% url 'v3-news-create' as create_url %} - {% include 'v3/includes/_user_card.html' with username=u.display_name avatar_url=u.avatar_url badge_name='Bug Catcher' badge_icon_src=u.badge_url member_since=u.year_joined role='Contributor' flag_emoji=u.flag_emoji cta_url=create_url cta_label='Create Post' only %} + {% include 'v3/includes/_user_card.html' with username=u.display_name avatar_url=u.avatar_url badge_name=u.featured_badge.name badge=u.featured_badge.icon member_since=u.year_joined role='Contributor' flag_emoji=u.flag_emoji cta_url=create_url cta_label='Create Post' only %} {% endwith %} {% else %} {% include 'v3/includes/_user_card.html' with logged_out=True cta_url='#' cta_label='Create Post' only %} diff --git a/templates/v3/user_profile_page.html b/templates/v3/user_profile_page.html index 55d692678..6b9c6e219 100644 --- a/templates/v3/user_profile_page.html +++ b/templates/v3/user_profile_page.html @@ -14,7 +14,7 @@