From 56e4f402ac6a26f4cf70e2b18b707e5515886189 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:51:48 +0000 Subject: [PATCH 1/8] feat(opal): add staff views for recent guess activity Staff could see guesses one puzzle at a time, or one person at a time, but had no way to watch a hunt as it happened. Add two admin-only views: - `opal-recent-activity`, a top-level page with the 20 most recent guesses on every hunt, one section per hunt - `opal-hunt-log`, a paginated log of every guess on a single hunt Both use a shared table that drops the per-puzzle log's `#` column in favor of linking each row to that puzzle's answer log, and adds the guesser's solve count within the hunt so a row says how far along that person is. The count is one query per page rather than one per row. The hunt list gains a `Log` button next to `Leaderboard`, and a link to the all-hunts activity page, both for superusers only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016WStV14GfczKudQ9iMPdpe --- .../opal/components/attempt_table.html | 41 +++++ opal/templates/opal/hunt_log.html | 28 ++++ opal/templates/opal/opalhunt_list.html | 16 ++ opal/templates/opal/recent_activity.html | 30 ++++ opal/tests.py | 141 +++++++++++++++++- opal/urls.py | 6 + opal/views.py | 86 ++++++++++- 7 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 opal/templates/opal/components/attempt_table.html create mode 100644 opal/templates/opal/hunt_log.html create mode 100644 opal/templates/opal/recent_activity.html diff --git a/opal/templates/opal/components/attempt_table.html b/opal/templates/opal/components/attempt_table.html new file mode 100644 index 000000000..f048174ef --- /dev/null +++ b/opal/templates/opal/components/attempt_table.html @@ -0,0 +1,41 @@ +{% comment %} + The guess-log table shared by the recent-activity and hunt-log views. + Expects `hunt`, plus `attempts` whose rows each carry a `solve_count`. +{% endcomment %} + + + + + + + + + {% for attempt in attempts %} + + + + + + + + {% empty %} + + + + {% endfor %} +
TimestampUserSolvesPuzzleGuess
+ {% if attempt.is_correct %} + ✅ + {% elif attempt.is_close %} + ▶️ + {% else %} + ✖️ + {% endif %} + {{ attempt.created_at|date:"Y-m-d H:i" }} + + {{ attempt.user.get_full_name|truncatechars:"24" }} + {{ attempt.solve_count }} + {{ attempt.puzzle.title|truncatechars:"32" }} + + {{ attempt.guess|truncatechars:24 }} +
No guesses yet.
diff --git a/opal/templates/opal/hunt_log.html b/opal/templates/opal/hunt_log.html new file mode 100644 index 000000000..9ab1b2b67 --- /dev/null +++ b/opal/templates/opal/hunt_log.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} +{% load django_bootstrap5 %} +{% block title %} + Guess log for {{ hunt.name }} +{% endblock title %} +{% block main-class %} + col-12 +{% endblock main-class %} +{% block side-class %} + col-6 col-md-4 +{% endblock side-class %} +{% block layout-content %} +

+ All recent activity + Leaderboard + Show puzzles +

+

+ There have been {{ paginator.count }} guesses on this hunt. +

+ {% include "opal/components/attempt_table.html" %} + {% if page_obj.has_other_pages %} + {% bootstrap_pagination page_obj justify_content="center" %} + {% endif %} +{% endblock layout-content %} +{% comment %} +vim: ft=htmldjango +{% endcomment %} diff --git a/opal/templates/opal/opalhunt_list.html b/opal/templates/opal/opalhunt_list.html index 73b338124..2a0959d75 100644 --- a/opal/templates/opal/opalhunt_list.html +++ b/opal/templates/opal/opalhunt_list.html @@ -18,6 +18,13 @@


List of hunts

+ {% if request.user.is_superuser %} +

+ Recent activity on all hunts +

+ {% endif %} {% for hunt in hunts %} {% if not hunt.active %}

@@ -27,6 +34,8 @@

List of hunts

{% if request.user.is_superuser %} (edit) (scoreboard) + (log) {% endif %}

{% elif hunt.has_started %} @@ -42,6 +51,9 @@

Show puzzles {% if request.user.is_superuser %} Leaderboard + Log {% endif %}

Started {{ hunt.start_date }} @@ -85,6 +97,10 @@

{{ hunt }}

  • (leaderboard)
  • +
  • + (log) +
  • (edit)
  • diff --git a/opal/templates/opal/recent_activity.html b/opal/templates/opal/recent_activity.html new file mode 100644 index 000000000..2a4d618c7 --- /dev/null +++ b/opal/templates/opal/recent_activity.html @@ -0,0 +1,30 @@ +{% extends "layout.html" %} +{% block title %} + Recent OPAL activity +{% endblock title %} +{% block main-class %} + col-12 +{% endblock main-class %} +{% block side-class %} + col-6 col-md-4 +{% endblock side-class %} +{% block layout-content %} +

    + Back to list of hunts +

    + {% for section in sections %} +

    {{ section.hunt }}

    +

    + Leaderboard + Log +

    + {% include "opal/components/attempt_table.html" with hunt=section.hunt attempts=section.attempts %} + {% empty %} +

    There are no hunts yet.

    + {% endfor %} +{% endblock layout-content %} +{% comment %} +vim: ft=htmldjango +{% endcomment %} diff --git a/opal/tests.py b/opal/tests.py index dcd82e6f0..86559a4a8 100644 --- a/opal/tests.py +++ b/opal/tests.py @@ -9,7 +9,7 @@ from core.factories import GroupFactory, UserFactory from opal.factories import OpalAttemptFactory, OpalHuntFactory, OpalPuzzleFactory -from opal.views import _Eligibility +from opal.views import HUNT_LOG_PAGE_SIZE, RECENT_ATTEMPTS_PER_HUNT, _Eligibility from rpg.factories import AchievementFactory from rpg.models import AchievementUnlock @@ -739,3 +739,142 @@ def test_guess_budget_is_rechecked_under_the_lock(otis): ) assert not OpalAttempt.objects.filter(puzzle=puzzle, user=alice).exists() + + +@pytest.mark.django_db +def test_recent_activity(otis): + """The all-hunts activity page shows the newest guesses of every hunt.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + old_hunt = OpalHuntFactory.create( + slug="old", start_date=datetime.datetime(2024, 1, 1, tzinfo=UTC) + ) + new_hunt = OpalHuntFactory.create( + slug="new", start_date=datetime.datetime(2025, 1, 1, tzinfo=UTC) + ) + OpalHuntFactory.create( + slug="quiet", start_date=datetime.datetime(2023, 1, 1, tzinfo=UTC) + ) + old_puzzle = OpalPuzzleFactory.create(hunt=old_hunt, answer="old") + new_puzzle = OpalPuzzleFactory.create(hunt=new_hunt, answer="new") + + OpalAttemptFactory.create(user=alice, puzzle=old_puzzle, guess="nope") + fresh = [ + OpalAttemptFactory.create(user=alice, puzzle=new_puzzle, guess=f"guess{i}") + for i in range(RECENT_ATTEMPTS_PER_HUNT + 5) + ] + + otis.login(alice) + otis.get_40x("opal-recent-activity") + + otis.login(admin) + resp = otis.get_20x("opal-recent-activity") + sections = resp.context["sections"] + # a section for every hunt, newest hunt first, even the one nobody guessed on + assert [section["hunt"].slug for section in sections] == ["new", "old", "quiet"] + # each hunt is capped at its most recent guesses, newest first + assert [attempt.pk for attempt in sections[0]["attempts"]] == [ + attempt.pk for attempt in reversed(fresh[-RECENT_ATTEMPTS_PER_HUNT:]) + ] + assert len(sections[1]["attempts"]) == 1 + assert sections[2]["attempts"] == [] + + +@pytest.mark.django_db +def test_recent_activity_solve_counts(otis): + """The solve count on a row counts only that hunt, and only correct guesses.""" + alice = UserFactory.create(username="alice") + bob = UserFactory.create(username="bob") + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + hunt = OpalHuntFactory.create(slug="hunt") + puzzle1 = OpalPuzzleFactory.create(hunt=hunt, answer="one") + puzzle2 = OpalPuzzleFactory.create(hunt=hunt, answer="two") + other_puzzle = OpalPuzzleFactory.create(answer="elsewhere") + + OpalAttemptFactory.create(user=alice, puzzle=puzzle1, guess="one") + OpalAttemptFactory.create(user=alice, puzzle=puzzle2, guess="two") + OpalAttemptFactory.create(user=alice, puzzle=other_puzzle, guess="elsewhere") + bobs_guess = OpalAttemptFactory.create(user=bob, puzzle=puzzle1, guess="wrong") + + otis.login(admin) + for resp in ( + otis.get_20x("opal-recent-activity"), + otis.get_20x("opal-hunt-log", "hunt"), + ): + if "sections" in resp.context: + rows = next( + s["attempts"] for s in resp.context["sections"] if s["hunt"] == hunt + ) + else: + rows = resp.context["attempts"] + solve_counts = {attempt.pk: attempt.solve_count for attempt in rows} + # Alice's solve elsewhere doesn't count, and Bob has solved nothing here + assert solve_counts.pop(bobs_guess.pk) == 0 + assert set(solve_counts.values()) == {2} + + +@pytest.mark.django_db +def test_hunt_log(otis): + """The per-hunt guess log paginates every guess, newest first.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + hunt = OpalHuntFactory.create(slug="hunt") + puzzle = OpalPuzzleFactory.create(hunt=hunt, answer="answer") + other_puzzle = OpalPuzzleFactory.create(answer="answer") + + # Frozen time makes every guess share a timestamp, which is exactly the case + # the pk tiebreaker in the ordering exists for. + with freeze_time("2025-03-04"): + attempts = OpalAttemptFactory.create_batch( + HUNT_LOG_PAGE_SIZE + 3, user=alice, puzzle=puzzle, guess="wrong" + ) + OpalAttemptFactory.create(user=alice, puzzle=other_puzzle, guess="wrong") + + otis.login(alice) + otis.get_40x("opal-hunt-log", "hunt") + + otis.login(admin) + resp = otis.get_20x("opal-hunt-log", "hunt") + assert resp.context["hunt"] == hunt + # guesses on other hunts stay out of this log + assert resp.context["paginator"].count == len(attempts) + assert resp.context["page_obj"].paginator.num_pages == 2 + newest_first = [attempt.pk for attempt in reversed(attempts)] + assert [a.pk for a in resp.context["attempts"]] == newest_first[:HUNT_LOG_PAGE_SIZE] + otis.assert_testid(resp, "opal-attempt-row", count=HUNT_LOG_PAGE_SIZE) + + resp = otis.get_20x("opal-hunt-log", "hunt", data={"page": 2}) + assert [a.pk for a in resp.context["attempts"]] == newest_first[HUNT_LOG_PAGE_SIZE:] + + otis.get_not_found("opal-hunt-log", "nonexistent") + + +@pytest.mark.django_db +def test_staff_log_links_on_hunt_list(otis): + """Only admins see the links to the guess logs from the hunt list.""" + verified_group = GroupFactory(name="Verified") + alice = UserFactory.create(username="alice", groups=(verified_group,)) + admin = UserFactory.create(username="admin", is_staff=True, is_superuser=True) + + OpalHuntFactory.create( + slug="started", start_date=datetime.datetime(2024, 1, 1, tzinfo=UTC) + ) + OpalHuntFactory.create( + slug="upcoming", start_date=datetime.datetime(2099, 1, 1, tzinfo=UTC) + ) + OpalHuntFactory.create(slug="archived", active=False) + + otis.login(alice) + resp = otis.get_20x("opal-hunt-list") + otis.assert_no_testid(resp, "opal-recent-activity-link") + otis.assert_no_testid(resp, "opal-hunt-log-link") + + otis.login(admin) + resp = otis.get_20x("opal-hunt-list") + otis.assert_testid(resp, "opal-recent-activity-link", count=1) + otis.assert_testid(resp, "opal-hunt-log-link", count=3) diff --git a/opal/urls.py b/opal/urls.py index 50bf51e6b..f4c8b9018 100644 --- a/opal/urls.py +++ b/opal/urls.py @@ -21,6 +21,12 @@ name="opal-puzzle-list", ), path(r"leaderboard//", views.leaderboard, name="opal-leaderboard"), + path(r"activity/", views.recent_activity, name="opal-recent-activity"), + path( + r"log//", + views.HuntAttemptsList.as_view(), + name="opal-hunt-log", + ), path( r"puzzle///", views.show_puzzle, diff --git a/opal/views.py b/opal/views.py index 488ac542f..3ab711c37 100644 --- a/opal/views.py +++ b/opal/views.py @@ -1,5 +1,6 @@ import datetime import logging +from collections.abc import Iterable from typing import Any, NamedTuple import requests @@ -9,7 +10,7 @@ from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q -from django.db.models.aggregates import Max +from django.db.models.aggregates import Count, Max from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.http.response import HttpResponse, HttpResponseRedirect @@ -36,6 +37,42 @@ def has_early_access(u: User) -> bool: return u.is_staff or u.is_superuser or u.groups.filter(name="Testsolver").exists() +# How many guesses each hunt contributes to the all-hunts activity page. +RECENT_ATTEMPTS_PER_HUNT = 20 +# How many guesses one page of a single hunt's guess log holds. +HUNT_LOG_PAGE_SIZE = 100 +# Newest first, with the pk breaking ties. Guesses landing in the same instant +# would otherwise be ordered arbitrarily, which a page boundary turns into a +# guess repeated on one page and missing from the next. +ATTEMPT_LOG_ORDERING = ("-created_at", "-pk") + + +def _with_solve_counts( + attempts: Iterable[OpalAttempt], hunt: OpalHunt +) -> list[OpalAttempt]: + """The attempts as a list, each carrying `solve_count` for its guesser. + + That is the number of puzzles of `hunt` the guesser has solved by now, which + is what makes a row of the log readable: it says how far along the person + making the guess is. Counted in a single query for the whole page, since + `OpalHunt.num_solves` would be one query per row. + """ + rows = list(attempts) + solve_counts: dict[int, int] = { + d["user"]: d["num_solves"] + for d in OpalAttempt.objects.filter( + puzzle__hunt=hunt, + user__in=[attempt.user for attempt in rows], + is_correct=True, + ) + .values("user") + .annotate(num_solves=Count("puzzle", distinct=True)) + } + for attempt in rows: + attempt.solve_count = solve_counts.get(attempt.user.pk, 0) # type: ignore[attr-defined] + return rows + + class HuntList(ListView[OpalHunt]): model = OpalHunt context_object_name = "hunts" @@ -105,6 +142,53 @@ def get_queryset(self) -> QuerySet[OpalAttempt]: return OpalAttempt.objects.filter(puzzle=self.puzzle).order_by("-created_at") +class HuntAttemptsList(AdminRequiredMixin, ListView[OpalAttempt]): + """Every guess on one hunt, newest first, a page at a time.""" + + hunt: OpalHunt + model = OpalAttempt + context_object_name = "attempts" + template_name = "opal/hunt_log.html" + paginate_by = HUNT_LOG_PAGE_SIZE + + def setup(self, request: AuthHttpRequest, *args: Any, **kwargs: Any): + super().setup(request, *args, **kwargs) + self.hunt = get_object_or_404(OpalHunt, slug=self.kwargs["hunt_slug"]) + + def get_queryset(self) -> QuerySet[OpalAttempt]: + return ( + OpalAttempt.objects.filter(puzzle__hunt=self.hunt) + .select_related("user", "puzzle") + .order_by(*ATTEMPT_LOG_ORDERING) + ) + + def get_context_data(self, **kwargs: Any): + context = super().get_context_data(**kwargs) + context["hunt"] = self.hunt + # Only the current page gets solve counts; the queryset itself is every + # guess ever made on the hunt. + context["attempts"] = _with_solve_counts(context["object_list"], self.hunt) + return context + + +@admin_required +def recent_activity(request: AuthHttpRequest) -> HttpResponse: + """The last few guesses on every hunt at once, newest hunt first.""" + sections = [ + { + "hunt": hunt, + "attempts": _with_solve_counts( + OpalAttempt.objects.filter(puzzle__hunt=hunt) + .select_related("user", "puzzle") + .order_by(*ATTEMPT_LOG_ORDERING)[:RECENT_ATTEMPTS_PER_HUNT], + hunt, + ), + } + for hunt in OpalHunt.objects.order_by("-start_date") + ] + return render(request, "opal/recent_activity.html", {"sections": sections}) + + # this is ugly af and untested but i'm getting dinner with my senpai in an hour @admin_required def leaderboard(request: AuthHttpRequest, hunt_slug: str) -> HttpResponse: From 89b7ce73dfb4b5203a78ec83cf1425c3b451398d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:00:36 +0000 Subject: [PATCH 2/8] feat(opal): link to the full hunt log below each activity table The recent-activity page shows only the last 20 guesses per hunt, so each section now ends with a link to that hunt's paginated log. The button at the top of the section scrolls out of view once a table is read through. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016WStV14GfczKudQ9iMPdpe --- opal/templates/opal/recent_activity.html | 4 ++++ opal/tests.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/opal/templates/opal/recent_activity.html b/opal/templates/opal/recent_activity.html index 2a4d618c7..877cd64f1 100644 --- a/opal/templates/opal/recent_activity.html +++ b/opal/templates/opal/recent_activity.html @@ -21,6 +21,10 @@

    {{ section.hunt }}

    href="{% url "opal-hunt-log" section.hunt.slug %}">Log

    {% include "opal/components/attempt_table.html" with hunt=section.hunt attempts=section.attempts %} +

    + See the full guess log for {{ section.hunt }} → +

    {% empty %}

    There are no hunts yet.

    {% endfor %} diff --git a/opal/tests.py b/opal/tests.py index 86559a4a8..37de77e2f 100644 --- a/opal/tests.py +++ b/opal/tests.py @@ -780,6 +780,8 @@ def test_recent_activity(otis): ] assert len(sections[1]["attempts"]) == 1 assert sections[2]["attempts"] == [] + # every section, even the empty one, offers the full log for its hunt + otis.assert_testid(resp, "opal-full-log-link", count=len(sections)) @pytest.mark.django_db From 3f08ebc588e6bf80ceea35936fbfd71ef5d18a37 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Mon, 24 Aug 2026 09:02:39 -0400 Subject: [PATCH 3/8] polish: change table presentation slightly --- opal/templates/opal/components/attempt_table.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opal/templates/opal/components/attempt_table.html b/opal/templates/opal/components/attempt_table.html index f048174ef..4922865b2 100644 --- a/opal/templates/opal/components/attempt_table.html +++ b/opal/templates/opal/components/attempt_table.html @@ -25,9 +25,9 @@ {{ attempt.user.get_full_name|truncatechars:"24" }} - {{ attempt.solve_count }} + {{ attempt.solve_count }} - {{ attempt.puzzle.title|truncatechars:"32" }} + {{ attempt.puzzle.title|truncatechars:"12" }} {{ attempt.guess|truncatechars:24 }} From 330a003cb286b79eabd0e6dcd930048201e2c5b7 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Mon, 24 Aug 2026 09:03:04 -0400 Subject: [PATCH 4/8] edit: change wording --- opal/templates/opal/recent_activity.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opal/templates/opal/recent_activity.html b/opal/templates/opal/recent_activity.html index 877cd64f1..3fec4834c 100644 --- a/opal/templates/opal/recent_activity.html +++ b/opal/templates/opal/recent_activity.html @@ -23,7 +23,7 @@

    {{ section.hunt }}

    {% include "opal/components/attempt_table.html" with hunt=section.hunt attempts=section.attempts %}

    See the full guess log for {{ section.hunt }} → + data-testid="opal-full-log-link">Full guess log for {{ section.hunt }} →

    {% empty %}

    There are no hunts yet.

    From 4d92f555ee9c3d9d55346b275d788802bc986014 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:05:54 +0000 Subject: [PATCH 5/8] polish(opal): shrink the guess log's Solves column The column was sized by auto table layout and its numbers pushed to the right edge, so a one- or two-digit count sat well clear of the header above it. Collapse the column onto the header word and centre the count under it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016WStV14GfczKudQ9iMPdpe --- opal/templates/opal/components/attempt_table.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/opal/templates/opal/components/attempt_table.html b/opal/templates/opal/components/attempt_table.html index 4922865b2..2dbe17de9 100644 --- a/opal/templates/opal/components/attempt_table.html +++ b/opal/templates/opal/components/attempt_table.html @@ -6,7 +6,9 @@ Timestamp User - Solves + {# width:1% collapses the column onto the header word, which is already wider + than any count it will hold; centering keeps the digits under it #} + Solves Puzzle Guess @@ -25,7 +27,7 @@ {{ attempt.user.get_full_name|truncatechars:"24" }} - {{ attempt.solve_count }} + {{ attempt.solve_count }} {{ attempt.puzzle.title|truncatechars:"12" }} From c6ff734cb498744c2ca9ca081e06460226352a03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:09:42 +0000 Subject: [PATCH 6/8] polish(opal): reorder the guess log columns Put the timestamp last in both logs, since it is the column a reader scans least. The shared table of the activity views leads with the puzzle, then the guesser and their solve count, then the guess itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016WStV14GfczKudQ9iMPdpe --- .../opal/components/attempt_table.html | 24 +++++++++---------- opal/templates/opal/opalattempt_list.html | 14 +++++------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/opal/templates/opal/components/attempt_table.html b/opal/templates/opal/components/attempt_table.html index 2dbe17de9..6a4ad81ff 100644 --- a/opal/templates/opal/components/attempt_table.html +++ b/opal/templates/opal/components/attempt_table.html @@ -4,16 +4,26 @@ {% endcomment %} - + {# width:1% collapses the column onto the header word, which is already wider than any count it will hold; centering keeps the digits under it #} - + {% for attempt in attempts %} + + + + - - - - {% empty %} diff --git a/opal/templates/opal/opalattempt_list.html b/opal/templates/opal/opalattempt_list.html index 9274bdc0b..435975fe9 100644 --- a/opal/templates/opal/opalattempt_list.html +++ b/opal/templates/opal/opalattempt_list.html @@ -22,15 +22,21 @@
    TimestampPuzzle UserSolvesPuzzle GuessTimestamp
    + {{ attempt.puzzle.title|truncatechars:"12" }} + + {{ attempt.user.get_full_name|truncatechars:"24" }} + {{ attempt.solve_count }} + {{ attempt.guess|truncatechars:24 }} + {% if attempt.is_correct %} ✅ @@ -24,16 +34,6 @@ {% endif %} {{ attempt.created_at|date:"Y-m-d H:i" }} - {{ attempt.user.get_full_name|truncatechars:"24" }} - {{ attempt.solve_count }} - {{ attempt.puzzle.title|truncatechars:"12" }} - - {{ attempt.guess|truncatechars:24 }} -
    - + {% for attempt in attempts %} + + - - {% endfor %}
    #Timestamp User GuessTimestamp
    {{ forloop.counter }} + {{ attempt.user.get_full_name|truncatechars:"24" }} + + {{ attempt.guess|truncatechars:24 }} + {% if attempt.is_correct %} ✅ @@ -41,12 +47,6 @@ {% endif %} {{ attempt.created_at|date:"Y-m-d H:i" }} - {{ attempt.user.get_full_name|truncatechars:"24" }} - - {{ attempt.guess|truncatechars:24 }} -
    From b54205c4d16a83211f2fb2891630e3ae9aa2f3d0 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Mon, 24 Aug 2026 09:16:21 -0400 Subject: [PATCH 7/8] fix: no multiline comment --- opal/templates/opal/components/attempt_table.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/opal/templates/opal/components/attempt_table.html b/opal/templates/opal/components/attempt_table.html index 6a4ad81ff..dcdee3a3b 100644 --- a/opal/templates/opal/components/attempt_table.html +++ b/opal/templates/opal/components/attempt_table.html @@ -6,8 +6,6 @@ Puzzle User - {# width:1% collapses the column onto the header word, which is already wider - than any count it will hold; centering keeps the digits under it #} Solves Guess Timestamp From 700d2a44b2f02b0d76b315ea6bcc27ed3c59c7d2 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Mon, 24 Aug 2026 09:18:28 -0400 Subject: [PATCH 8/8] edit: buttons --- opal/templates/opal/recent_activity.html | 5 +---- opal/tests.py | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/opal/templates/opal/recent_activity.html b/opal/templates/opal/recent_activity.html index 3fec4834c..9d3a05935 100644 --- a/opal/templates/opal/recent_activity.html +++ b/opal/templates/opal/recent_activity.html @@ -15,16 +15,13 @@ {% for section in sections %}

    {{ section.hunt }}

    + Show puzzles Leaderboard Log

    {% include "opal/components/attempt_table.html" with hunt=section.hunt attempts=section.attempts %} -

    - Full guess log for {{ section.hunt }} → -

    {% empty %}

    There are no hunts yet.

    {% endfor %} diff --git a/opal/tests.py b/opal/tests.py index 37de77e2f..580286d26 100644 --- a/opal/tests.py +++ b/opal/tests.py @@ -780,8 +780,6 @@ def test_recent_activity(otis): ] assert len(sections[1]["attempts"]) == 1 assert sections[2]["attempts"] == [] - # every section, even the empty one, offers the full log for its hunt - otis.assert_testid(resp, "opal-full-log-link", count=len(sections)) @pytest.mark.django_db @@ -878,5 +876,4 @@ def test_staff_log_links_on_hunt_list(otis): otis.login(admin) resp = otis.get_20x("opal-hunt-list") - otis.assert_testid(resp, "opal-recent-activity-link", count=1) otis.assert_testid(resp, "opal-hunt-log-link", count=3)