Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions core/templates/core/leaderboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ <h2 class="font-heading font-semibold text-xl text-on-surface">Classement</h2>
{% if profiles %}
<div class="space-y-2.5">
{% for p in profiles %}
{% with rank=forloop.counter0|add:page_obj.start_index %}
<a href="{% url 'profile' p.github_username %}" class="group flex items-center gap-4 bg-surface-container-lowest border border-outline-variant/40 rounded-xl p-3.5 pl-4 shadow-card hover:shadow-card-hover hover:border-primary/20 transition-all relative overflow-hidden focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary">
{% if forloop.counter <= 3 %}
<span class="absolute left-0 top-0 bottom-0 w-1 {% if forloop.counter == 1 %}bg-primary{% elif forloop.counter == 2 %}bg-secondary-container{% else %}bg-tertiary{% endif %}"></span>
{% if rank <= 3 %}
<span class="absolute left-0 top-0 bottom-0 w-1 {% if rank == 1 %}bg-primary{% elif rank == 2 %}bg-secondary-container{% else %}bg-tertiary{% endif %}"></span>
{% endif %}
<span class="w-8 text-center font-display font-semibold text-lg shrink-0 {% if forloop.counter <= 3 %}text-primary{% else %}text-on-surface-variant{% endif %}">{{ forloop.counter }}</span>
<span class="w-8 text-center font-display font-semibold text-lg shrink-0 {% if rank <= 3 %}text-primary{% else %}text-on-surface-variant{% endif %}">{{ rank }}</span>
{% if p.avatar_url %}
<img src="{{ p.avatar_url }}" alt="" class="w-11 h-11 rounded-xl object-cover bg-surface-container shrink-0" loading="lazy">
{% else %}
Expand All @@ -73,8 +74,44 @@ <h2 class="font-heading font-semibold text-xl text-on-surface">Classement</h2>
<div class="text-xs text-on-surface-variant">contrib.</div>
</div>
</a>
{% endwith %}
{% endfor %}
</div>

{% if page_obj.paginator.num_pages > 1 %}
{% with qs=querystring %}
<nav class="flex items-center justify-center gap-1.5 mt-10" aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?{{ qs }}{% if qs %}&{% endif %}page={{ page_obj.previous_page_number }}"
class="w-10 h-10 grid place-items-center rounded-lg border border-outline-variant/60 text-on-surface-variant hover:bg-surface-container transition-colors">
<span class="material-symbols-outlined text-[20px]">chevron_left</span>
</a>
{% endif %}

{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<span class="min-w-10 h-10 px-3 grid place-items-center rounded-lg bg-primary text-on-primary font-semibold">{{ num }}</span>
{% elif num >= page_obj.number|add:-2 and num <= page_obj.number|add:2 %}
<a href="?{{ qs }}{% if qs %}&{% endif %}page={{ num }}"
class="min-w-10 h-10 px-3 grid place-items-center rounded-lg border border-outline-variant/60 text-on-surface-variant hover:bg-surface-container transition-colors">{{ num }}</a>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<a href="?{{ qs }}{% if qs %}&{% endif %}page={{ num }}"
class="min-w-10 h-10 px-3 grid place-items-center rounded-lg border border-outline-variant/60 text-on-surface-variant hover:bg-surface-container transition-colors">{{ num }}</a>
{% elif num == page_obj.number|add:-3 or num == page_obj.number|add:3 %}
<span class="w-6 text-center text-on-surface-variant">…</span>
{% endif %}
{% endfor %}

{% if page_obj.has_next %}
<a href="?{{ qs }}{% if qs %}&{% endif %}page={{ page_obj.next_page_number }}"
class="w-10 h-10 grid place-items-center rounded-lg border border-outline-variant/60 text-on-surface-variant hover:bg-surface-container transition-colors">
<span class="material-symbols-outlined text-[20px]">chevron_right</span>
</a>
{% endif %}
</nav>
{% endwith %}
{% endif %}

{% else %}
<div class="text-center py-16 px-4 bg-surface-container-low/50 rounded-2xl">
<span class="material-symbols-outlined text-[40px] text-on-surface-variant">groups</span>
Expand All @@ -85,4 +122,4 @@ <h3 class="font-heading font-semibold text-lg text-on-surface mt-3">Personne ici
{% endif %}
</div>
</div>
{% endblock %}
{% endblock %}
60 changes: 60 additions & 0 deletions core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,63 @@ def test_explore_clone_size_filter_pagination(client, max_size, htmx):
assert response.context["page_obj"].number == 2
assert len(response.context["page_obj"]) == 1
assert b"Excluded boundary" not in response.content


@pytest.mark.django_db
def test_leaderboard_pagination(client, monkeypatch):
cache.clear()
# Small page size so we don't need 50+ users. The view reads this module
# constant at request time, so patching it works.
monkeypatch.setattr("core.views.LEADERBOARD_PAGE_SIZE", 2)

# lbuser{i} has i contributions. Odd i -> Ghana (1, 3, 5), even -> Nigeria (2, 4).
for i in range(1, 6):
user = User.objects.create_user(f"lbuser{i}")
profile = user.profile # created by signal
profile.github_username = f"lbuser{i}"
profile.country = "Ghana" if i % 2 else "Nigeria"
profile.save()
Contribution.objects.bulk_create([
Contribution(
user=user, repo_full_name="a/b", title=f"pr{j}",
pr_url=f"https://example.com/{i}/{j}",
)
for j in range(i)
])

url = reverse("leaderboard")

# Page 1: hero shown, highest contributor first, 2 rows.
r1 = client.get(url)
assert r1.status_code == 200
assert r1.context["page_obj"].number == 1
assert [p.github_username for p in r1.context["profiles"]] == ["lbuser5", "lbuser4"]
assert r1.context["top"].github_username == "lbuser5"

# Page 2: no hero, ranks continue from 3.
r2 = client.get(url, {"page": 2})
assert r2.status_code == 200
assert r2.context["top"] is None
assert r2.context["page_obj"].start_index() == 3
assert [p.github_username for p in r2.context["profiles"]] == ["lbuser3", "lbuser2"]

# Bad or out-of-range page numbers must not crash.
assert client.get(url, {"page": "abc"}).status_code == 200
assert client.get(url, {"page": 999}).status_code == 200

# Country filter: 3 Ghana profiles -> 2 pages, and every page link keeps ?country=Ghana.
r3 = client.get(url, {"country": "Ghana"})
assert r3.status_code == 200
assert r3.context["page_obj"].paginator.num_pages == 2
links = re.findall(r'href="([^"]+)"', r3.content.decode())
page_links = [link for link in links if "page=" in link]
assert page_links
for link in page_links:
query = parse_qs(urlsplit(unescape(link)).query)
assert query["country"] == ["Ghana"]

next_page = next(link for link in page_links if "page=2" in link)
r4 = client.get(url + "?" + urlsplit(unescape(next_page)).query)
assert r4.status_code == 200
assert r4.context["page_obj"].number == 2
assert [p.github_username for p in r4.context["profiles"]] == ["lbuser1"]
63 changes: 42 additions & 21 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
# Number of issues per page in the explore view.
PAGE_SIZE = 30

# Number of contributors per page on the leaderboard.
LEADERBOARD_PAGE_SIZE = 50

# Fields needed by issue cards / explore list (avoid pulling full body blobs).
ISSUE_LIST_FIELDS = (
"id",
Expand Down Expand Up @@ -207,19 +210,23 @@ def profile_view(request, username):

def leaderboard(request):
country = request.GET.get("country", "").strip()
lb_key = cache_key("leaderboard", country or "all")
cached = cache_get(lb_key)
if cached is not None:
return HttpResponse(cached)

profiles = (
Profile.objects.select_related("user")
.annotate(total=Count("user__contributions"))
.filter(total__gt=0)
.order_by("-total")
)
if country:
profiles = profiles.filter(country__iexact=country)
# Cache only the computed rows (not rendered HTML), so the template is
# rendered per request. This also fixes #38 (anonymous navbar served to
# logged-in users) and means pagination doesn't need page in the key.
rows_key = cache_key("leaderboard", "rows", country.lower() or "all")
profiles = cache_get(rows_key)
if profiles is None:
qs = (
Profile.objects.select_related("user")
.annotate(total=Count("user__contributions"))
.filter(total__gt=0)
.order_by("-total", "pk") # pk tiebreaker = stable ranking across pages
)
if country:
qs = qs.filter(country__iexact=country)
profiles = list(qs) # no [:50] slice any more
cache_set(rows_key, profiles, CACHE_TTL)

countries_key = cache_key("leaderboard", "countries")
countries = cache_get(countries_key)
Expand All @@ -233,23 +240,37 @@ def leaderboard(request):
)
cache_set(countries_key, countries, CACHE_TTL)

profiles = list(profiles[:50])
top = profiles[0] if profiles else None
paginator = Paginator(profiles, LEADERBOARD_PAGE_SIZE)
# get_page() handles ?page=abc and out-of-range values safely.
page_obj = paginator.get_page(request.GET.get("page", "1"))
page_rows = list(page_obj.object_list)

response = render(
on_first_page = page_obj.number == 1
if on_first_page:
podium, rest = page_rows[:3], page_rows[3:]
else:
podium, rest = [], page_rows

# Rank of the row just before `rest` begins: 3 on page 1, 50 on page 2, ...
rest_offset = max(page_obj.start_index() - 1, 0) + (len(podium))

querystring = urlencode({"country": country}) if country else ""

return render(
request,
"core/leaderboard.html",
{
"profiles": profiles,
"podium": profiles[:3],
"rest": profiles[3:],
"top": top,
"profiles": page_rows,
"podium": podium,
"rest": rest,
"rest_offset": rest_offset,
"top": profiles[0] if (profiles and on_first_page) else None,
"page_obj": page_obj,
"querystring": querystring,
"countries": countries,
"selected_country": country,
},
)
cache_set(lb_key, response.content, CACHE_TTL)
return response


@require_http_methods(["GET", "HEAD"])
Expand Down
Loading