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
41 changes: 41 additions & 0 deletions opal/templates/opal/components/attempt_table.html
Original file line number Diff line number Diff line change
@@ -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 %}
<table class="table table-striped">
<tr class="table-dark">
<th>Puzzle</th>
<th>User</th>
<th class="text-center text-nowrap" style="width:1%;">Solves</th>
<th>Guess</th>
<th>Timestamp</th>
</tr>
{% for attempt in attempts %}
<tr data-testid="opal-attempt-row">
<td>
<a href="{% url "opal-attempts-list" hunt.slug attempt.puzzle.slug %}">{{ attempt.puzzle.title|truncatechars:"12" }}</a>
</td>
<td>
<a href="{% url "opal-person-log" hunt.slug attempt.user.pk %}">{{ attempt.user.get_full_name|truncatechars:"24" }}</a>
</td>
<td class="text-center fw-bold">{{ attempt.solve_count }}</td>
<td class="{% if attempt.is_correct %}text-success{% elif attempt.is_close %}text-dark{% else %}text-danger{% endif %}">
<tt>{{ attempt.guess|truncatechars:24 }}</tt>
</td>
<td>
{% if attempt.is_correct %}
{% elif attempt.is_close %}
▶️
{% else %}
✖️
{% endif %}
{{ attempt.created_at|date:"Y-m-d H:i" }}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="fst-italic">No guesses yet.</td>
</tr>
{% endfor %}
</table>
28 changes: 28 additions & 0 deletions opal/templates/opal/hunt_log.html
Original file line number Diff line number Diff line change
@@ -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 %}
<p>
<a class="btn btn-dark" href="{% url "opal-recent-activity" %}">All recent activity</a>
<a class="btn btn-info" href="{% url "opal-leaderboard" hunt.slug %}">Leaderboard</a>
<a class="btn btn-primary" href="{{ hunt.get_absolute_url }}">Show puzzles</a>
</p>
<p>
There have been <b>{{ paginator.count }} guesses</b> on this hunt.
</p>
{% 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 %}
14 changes: 7 additions & 7 deletions opal/templates/opal/opalattempt_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,21 @@
<table class="table table-striped">
<tr class="table-dark">
<th>#</th>
<th>Timestamp</th>
<th>User</th>
<th>Guess</th>
<th>Timestamp</th>
</tr>
{% for attempt in attempts %}
<tr>
<td>
<a href="{% url "admin:opal_opalattempt_change" attempt.pk %}">{{ forloop.counter }}</a>
</td>
<td>
<a href="{% url "opal-person-log" puzzle.hunt.slug attempt.user.pk %}">{{ attempt.user.get_full_name|truncatechars:"24" }}</a>
</td>
<td class="{% if attempt.is_correct %}text-success{% elif attempt.is_close %}text-dark{% else %}text-danger{% endif %}">
<tt>{{ attempt.guess|truncatechars:24 }}</tt>
</td>
<td>
{% if attempt.is_correct %}
Expand All @@ -41,12 +47,6 @@
{% endif %}
{{ attempt.created_at|date:"Y-m-d H:i" }}
</td>
<td>
<a href="{% url "opal-person-log" puzzle.hunt.slug attempt.user.pk %}">{{ attempt.user.get_full_name|truncatechars:"24" }}</a>
</td>
<td class="{% if attempt.is_correct %}text-success{% elif attempt.is_close %}text-dark{% else %}text-danger{% endif %}">
<tt>{{ attempt.guess|truncatechars:24 }}</tt>
</td>
</tr>
{% endfor %}
</table>
Expand Down
16 changes: 16 additions & 0 deletions opal/templates/opal/opalhunt_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
</p>
<hr />
<h1>List of hunts</h1>
{% if request.user.is_superuser %}
<p>
<a class="btn btn-secondary"
href="{% url "opal-recent-activity" %}"
data-testid="opal-recent-activity-link">Recent activity on all hunts</a>
</p>
{% endif %}
{% for hunt in hunts %}
{% if not hunt.active %}
<p>
Expand All @@ -27,6 +34,8 @@ <h1>List of hunts</h1>
{% if request.user.is_superuser %}
<a href="{% url "admin:opal_opalhunt_change" hunt.pk %}">(edit)</a>
<a href="{% url "opal-leaderboard" hunt.slug %}">(scoreboard)</a>
<a href="{% url "opal-hunt-log" hunt.slug %}"
data-testid="opal-hunt-log-link">(log)</a>
{% endif %}
</p>
{% elif hunt.has_started %}
Expand All @@ -42,6 +51,9 @@ <h2 class="alert-heading">
<a class="btn btn-primary" href="{{ hunt.get_absolute_url }}">Show puzzles</a>
{% if request.user.is_superuser %}
<a class="btn btn-info" href="{% url "opal-leaderboard" hunt.slug %}">Leaderboard</a>
<a class="btn btn-secondary"
href="{% url "opal-hunt-log" hunt.slug %}"
data-testid="opal-hunt-log-link">Log</a>
{% endif %}
<p class="mb-0 mt-2">
Started {{ hunt.start_date }}
Expand Down Expand Up @@ -85,6 +97,10 @@ <h2 class="alert-heading">{{ hunt }}</h2>
<li>
<a href="{% url "opal-leaderboard" hunt.slug %}">(leaderboard)</a>
</li>
<li>
<a href="{% url "opal-hunt-log" hunt.slug %}"
data-testid="opal-hunt-log-link">(log)</a>
</li>
<li>
<a href="{% url "admin:opal_opalhunt_change" hunt.pk %}">(edit)</a>
</li>
Expand Down
31 changes: 31 additions & 0 deletions opal/templates/opal/recent_activity.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% 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 %}
<p>
<a class="btn btn-dark" href="{% url "opal-hunt-list" %}">Back to list of hunts</a>
</p>
{% for section in sections %}
<h2>{{ section.hunt }}</h2>
<p>
<a class="btn btn-primary btn-sm" href="{{ hunt.get_absolute_url }}">Show puzzles</a>
<a class="btn btn-info btn-sm"
href="{% url "opal-leaderboard" section.hunt.slug %}">Leaderboard</a>
<a class="btn btn-secondary btn-sm"
href="{% url "opal-hunt-log" section.hunt.slug %}">Log</a>
</p>
{% include "opal/components/attempt_table.html" with hunt=section.hunt attempts=section.attempts %}
{% empty %}
<p>There are no hunts yet.</p>
{% endfor %}
{% endblock layout-content %}
{% comment %}
vim: ft=htmldjango
{% endcomment %}
140 changes: 139 additions & 1 deletion opal/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -739,3 +739,141 @@ 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-hunt-log-link", count=3)
6 changes: 6 additions & 0 deletions opal/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
name="opal-puzzle-list",
),
path(r"leaderboard/<slug:hunt_slug>/", views.leaderboard, name="opal-leaderboard"),
path(r"activity/", views.recent_activity, name="opal-recent-activity"),
path(
r"log/<slug:hunt_slug>/",
views.HuntAttemptsList.as_view(),
name="opal-hunt-log",
),
path(
r"puzzle/<slug:hunt_slug>/<slug:puzzle_slug>/",
views.show_puzzle,
Expand Down
Loading
Loading