diff --git a/README.md b/README.md index d0775bd..0f30f6f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,12 @@ A lot of configuration is done via environment variables, which are read into [` #### Hunt State +There are three states to the hunt website: + +- Before the hunt is "live" +- While the hunt is "live" +- After the hunt has "ended" + The variable `HUNT_IS_LIVE_DATETIME` optionally lets you switch control behavior before and after a nominal "start" time for the hunt. A context processor `hunt_is_live` sets a boolean context variable `hunt_is_live` indicating whether the current time is before or after `HUNT_IS_LIVE_DATETIME`. If not set, it will be set to the current time when the application starts up. #### HTML Metadata diff --git a/huntsite/content/tests.py b/huntsite/content/tests.py index 4bcd561..a1b6970 100644 --- a/huntsite/content/tests.py +++ b/huntsite/content/tests.py @@ -1,7 +1,6 @@ from datetime import timedelta from bs4 import BeautifulSoup, Tag -from django.conf import settings from django.test import Client from django.urls import reverse from django.utils import timezone @@ -42,7 +41,7 @@ def test_about_page(client): assert b"go down in history" in response.content -def test_story_page(client, monkeypatch): +def test_story_page(client, settings): # Set up puzzles and story entries puzzle1 = PuzzleFactory() story1_title = "Here is Story 1" @@ -77,7 +76,8 @@ def test_story_page(client, monkeypatch): user2_client.force_login(user2) ## Before hunt live - no story entries, only invitation - monkeypatch.setattr(settings, "HUNT_IS_LIVE_DATETIME", timezone.now() + timedelta(days=1)) + settings.HUNT_IS_LIVE_DATETIME = timezone.now() + timedelta(days=1) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timedelta(days=2) def _assert_invitation_available(story_cards: list[Tag], is_hidden: bool): invitation_story_card = story_cards[0] @@ -101,7 +101,7 @@ def _assert_invitation_available(story_cards: list[Tag], is_hidden: bool): _assert_invitation_available(story_cards, is_hidden=False) ## After hunt live - Arriving in North Pole - monkeypatch.setattr(settings, "HUNT_IS_LIVE_DATETIME", timezone.now() - timedelta(days=1)) + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timedelta(days=1) def _assert_arrived_available(story_cards: list[Tag], is_hidden: bool): arrived_story = story_cards[1] @@ -125,12 +125,20 @@ def _assert_arrived_available(story_cards: list[Tag], is_hidden: bool): ## User 1 solves puzzle 1 guess_submit(puzzle=puzzle1, user=user1, guess_text=puzzle1.answer) - def _assert_story1_available(story_cards: list[Tag], is_hidden: bool): + def _assert_story1_available( + story_cards: list[Tag], + is_hidden: bool, + has_spoiler_warning: bool = False, + ): story1_card = next(card for card in story_cards if story1_title in card.find("h3").text) assert story1_content in story1_card.text assert ( "is-hidden" in story1_card.find("div", class_="card-content")["class"] ) == is_hidden + if has_spoiler_warning: + assert f"Spoiler warning for {puzzle1.title}" in story1_card.text + else: + assert "Spoiler warning" not in story1_card.text # Anon should still only see invitation and arriving story response = anon_client.get("/story/") @@ -166,12 +174,20 @@ def _assert_story1_available(story_cards: list[Tag], is_hidden: bool): ## User 1 solves puzzle 2 guess_submit(puzzle=puzzle2, user=user1, guess_text=puzzle2.answer) - def _assert_story2_available(story_cards: list[Tag], is_hidden: bool): + def _assert_story2_available( + story_cards: list[Tag], + is_hidden: bool, + has_spoiler_warning: bool = False, + ): story2_card = next(card for card in story_cards if story2_title in card.find("h3").text) assert story2_content in story2_card.text assert ( "is-hidden" in story2_card.find("div", class_="card-content")["class"] ) == is_hidden + if has_spoiler_warning: + assert f"Spoiler warning for {puzzle2.title}" in story2_card.text + else: + assert "Spoiler warning" not in story2_card.text # Anon should still only see invitation and arriving story response = anon_client.get("/story/") @@ -210,12 +226,20 @@ def _assert_story2_available(story_cards: list[Tag], is_hidden: bool): ## User 2 solves puzzle 3 guess_submit(puzzle=puzzle3, user=user2, guess_text=puzzle3.answer) - def _assert_story3_available(story_cards: list[Tag], is_hidden: bool): + def _assert_story3_available( + story_cards: list[Tag], + is_hidden: bool, + has_spoiler_warning: bool = False, + ): story3_card = next(card for card in story_cards if story3_title in card.find("h3").text) assert story3_content in story3_card.text assert ( "is-hidden" in story3_card.find("div", class_="card-content")["class"] ) == is_hidden + if has_spoiler_warning: + assert f"Spoiler warning for {puzzle3.title}" in story3_card.text + else: + assert "Spoiler warning" not in story3_card.text # Anon should still only see invitation and arriving story response = anon_client.get("/story/") @@ -253,8 +277,27 @@ def _assert_story3_available(story_cards: list[Tag], is_hidden: bool): _assert_arrived_available(story_cards, is_hidden=True) _assert_story3_available(story_cards, is_hidden=False) + ## Hunt state is ended + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timedelta(days=1) + # All users should see all story entries, all are hidden + for client in (anon_client, user1_client, user2_client): + response = client.get("/story/") + assert response.status_code == 200 + assert len(response.context["entries"]) == 3 + assert response.context["entries"][0] == story1 + assert response.context["entries"][1] == story2 + assert response.context["entries"][2] == story3 + soup = BeautifulSoup(response.content, "html.parser") + story_cards = soup.find_all("div", class_="story-card") + assert len(story_cards) == 5 + _assert_invitation_available(story_cards, is_hidden=True) + _assert_arrived_available(story_cards, is_hidden=True) + _assert_story1_available(story_cards, is_hidden=True, has_spoiler_warning=True) + _assert_story2_available(story_cards, is_hidden=True, has_spoiler_warning=True) + _assert_story3_available(story_cards, is_hidden=True, has_spoiler_warning=True) -def test_victory_unlock(): + +def test_victory_unlock(settings): # Set up users anon_client = Client() user1 = UserFactory() @@ -276,6 +319,10 @@ def test_victory_unlock(): title=story_title, content=story_content, puzzle=puzzle, order_by=1, is_final=True ) + ## Hunt state is live + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timedelta(days=2) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timedelta(days=2) + ## Before solve, nobody sees victory entry or victory page for client in [anon_client, user1_client, user2_client]: response = client.get("/story/") @@ -325,6 +372,28 @@ def test_victory_unlock(): response = tester_client.get("/story/victory/") assert response.status_code == 200 + ## Hunt state is ended + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timedelta(days=1) + # Everyone sees victory entry and victory page + for client in (anon_client, user1_client, user2_client, tester_client): + response = client.get("/story/") + assert response.status_code == 200 + assert len(response.context["entries"]) == 1 + assert response.context["entries"][0] == story + soup = BeautifulSoup(response.content, "html.parser") + story_cards = soup.find_all("div", class_="story-card") + last_story_card = story_cards[-1] + assert story_title in last_story_card.text + assert ( + reverse("victory") + in last_story_card.find("div", class_="card-content").find("a")["href"] + ) + + response = client.get("/story/victory/") + assert response.status_code == 200 + assert story_title in response.content.decode() + assert story_content in response.content.decode() + def test_attributions_page(client): attrs_entry = AttributionsEntryFactory() diff --git a/huntsite/content/views.py b/huntsite/content/views.py index 891186c..75e1a82 100644 --- a/huntsite/content/views.py +++ b/huntsite/content/views.py @@ -4,6 +4,7 @@ from huntsite.content import models from huntsite.puzzles import models as puzzle_models +from huntsite.utils import HuntState, get_hunt_state @require_safe @@ -18,16 +19,19 @@ def about_page(request): @require_safe def story_page(request): entries = models.StoryEntry.objects.select_related("puzzle").all().order_by("order_by") - if request.user.is_anonymous: - solves = {} - else: - solves = { - solve.puzzle: solve - for solve in puzzle_models.Solve.objects.filter(user=request.user).select_related( - "puzzle" - ) - } - entries = [entry for entry in entries if entry.puzzle is None or entry.puzzle in solves] + hunt_state = get_hunt_state(request) + if hunt_state < HuntState.ENDED: + # Filter to only unlocked story entries by solve + if request.user.is_anonymous: + solves = {} + else: + solves = { + solve.puzzle: solve + for solve in puzzle_models.Solve.objects.filter(user=request.user).select_related( + "puzzle" + ) + } + entries = [entry for entry in entries if entry.puzzle is None or entry.puzzle in solves] context = { "entries": entries, } @@ -36,15 +40,19 @@ def story_page(request): @require_safe def victory_page(request): - if not request.user.is_finished and not request.user.is_tester: + if ( + request.user.is_finished + or request.user.is_tester + or get_hunt_state(request) >= HuntState.ENDED + ): + entry = models.StoryEntry.objects.get(is_final=True) + context = { + "entry": entry, + } + return TemplateResponse(request, "victory.html", context) + else: raise Http404 - entry = models.StoryEntry.objects.get(is_final=True) - context = { - "entry": entry, - } - return TemplateResponse(request, "victory.html", context) - @require_safe def attributions_page(request): diff --git a/huntsite/context_processors.py b/huntsite/context_processors.py index 390951c..7c1da18 100644 --- a/huntsite/context_processors.py +++ b/huntsite/context_processors.py @@ -1,8 +1,7 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site -from django.utils import timezone -from huntsite.tester_utils.session_handlers import read_time_travel_session_var +from huntsite.utils import HuntState, get_hunt_state def canonical(request): @@ -23,17 +22,12 @@ def meta(request): } -def hunt_is_live(request): - """Context processor to add the Santa missing flag to the context.""" - if ( - request.user.is_authenticated - and request.user.is_tester - and (time_traveling_at := read_time_travel_session_var(request)) - ): - now = time_traveling_at - else: - now = timezone.now() - return {"hunt_is_live": now >= settings.HUNT_IS_LIVE_DATETIME} +def hunt_state(request): + """Context processor to set the hunt state.""" + return { + "HuntState": HuntState, + "hunt_state": get_hunt_state(request), + } def announcement_message(request): diff --git a/huntsite/puzzles/factories.py b/huntsite/puzzles/factories.py index eac57b6..8b90db3 100644 --- a/huntsite/puzzles/factories.py +++ b/huntsite/puzzles/factories.py @@ -35,6 +35,7 @@ class Meta: slug = factory.Faker("slug") answer = factory.LazyFunction(answer_text_factory) pdf_url = factory.fuzzy.FuzzyChoice(MOCK_PUZZLES) + solution_pdf_url = factory.fuzzy.FuzzyChoice(MOCK_PUZZLES) available_at = factory.LazyFunction(timezone.now) calendar_entry = factory.RelatedFactory( diff --git a/huntsite/puzzles/forms.py b/huntsite/puzzles/forms.py index a0e780e..5486471 100644 --- a/huntsite/puzzles/forms.py +++ b/huntsite/puzzles/forms.py @@ -18,7 +18,7 @@ def __init__(self, *args, slug: str, **kwargs): self.helper.form_id = "guess-form" self.helper.attrs = { "hx-post": ".", - "hx-target": "#guesses-table", + "hx-target": "#guesses-results", "hx-on::after-request": "if(event.detail.successful) this.reset()", } self.helper.layout = Layout( diff --git a/huntsite/puzzles/migrations/0008_puzzle_solution_pdf_url.py b/huntsite/puzzles/migrations/0008_puzzle_solution_pdf_url.py new file mode 100644 index 0000000..e8104b9 --- /dev/null +++ b/huntsite/puzzles/migrations/0008_puzzle_solution_pdf_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-12-30 23:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzles', '0007_puzzle_canned_hints_available_at_cannedhint'), + ] + + operations = [ + migrations.AddField( + model_name='puzzle', + name='solution_pdf_url', + field=models.URLField(blank=True), + ), + ] diff --git a/huntsite/puzzles/models.py b/huntsite/puzzles/models.py index ac11ec4..07f9347 100644 --- a/huntsite/puzzles/models.py +++ b/huntsite/puzzles/models.py @@ -74,19 +74,21 @@ def with_solve_stats(self): ) return self.annotate(num_solves=Coalesce(Subquery(solves_count), Value(0))) - def with_guess_stats(self): - guesses_count = ( - Guess.objects.filter(puzzle=models.OuterRef("pk")) - .filter( - user__is_active=True, - user__is_staff=False, - user__is_tester=False, - ) - .values("puzzle") - .annotate(c=Count("*")) - .values("c") + def with_guess_stats( + self, + annotate_name="num_guesses", + filter_evaluations: list["GuessEvaluation"] | None = None, + ): + guesses = Guess.objects.filter(puzzle=models.OuterRef("pk")).filter( + user__is_active=True, + user__is_staff=False, + user__is_tester=False, ) - return self.annotate(num_guesses=Coalesce(Subquery(guesses_count), Value(0))) + if filter_evaluations is not None: + guesses = guesses.filter(evaluation__in=filter_evaluations) + guesses_count = guesses.values("puzzle").annotate(c=Count("*")).values("c") + annotation = {annotate_name: Coalesce(Subquery(guesses_count), Value(0))} + return self.annotate(**annotation) class AvailablePuzzleManager(models.Manager): @@ -108,6 +110,7 @@ class Puzzle(models.Model): ) pdf_url = models.URLField() + solution_pdf_url = models.URLField(blank=True) available_at = models.DateTimeField(default=timezone.now) @@ -137,6 +140,10 @@ def get_absolute_url(self): """Returns the URL for the detail page of the puzzle.""" return reverse("puzzle_detail", kwargs={"slug": self.slug}) + def get_solution_absolute_url(self): + """Returns the URL for the detail page of the puzzle.""" + return reverse("puzzle_solution", kwargs={"slug": self.slug}) + def clean(self): self.answer = clean_answer(self.answer) self.keep_going_answers = [clean_answer(ans) for ans in self.keep_going_answers] diff --git a/huntsite/puzzles/tests/test_models.py b/huntsite/puzzles/tests/test_models.py index d7269bf..2611027 100644 --- a/huntsite/puzzles/tests/test_models.py +++ b/huntsite/puzzles/tests/test_models.py @@ -5,7 +5,7 @@ import pytest from huntsite.puzzles.factories import MetapuzzleInfoFactory, PuzzleFactory -from huntsite.puzzles.models import AdventCalendarEntry, MetapuzzleInfo, Puzzle +from huntsite.puzzles.models import AdventCalendarEntry, GuessEvaluation, MetapuzzleInfo, Puzzle from huntsite.puzzles.services import guess_submit from huntsite.teams.factories import UserFactory from huntsite.teams.models import AnonymousUser @@ -74,6 +74,48 @@ def test_puzzle_manager_with_solves_by_user(): assert not result_with_anon.is_solved +def test_puzzle_manager_with_solve_stats(): + puzzle = PuzzleFactory() + + users = [UserFactory() for _ in range(5)] + + for user in users[0:3]: + guess_submit(puzzle, user, puzzle.answer) + + assert Puzzle.objects.with_solve_stats().get(pk=puzzle.pk).num_solves == 3 + + +def test_puzzle_manager_with_guess_stats(): + puzzle = PuzzleFactory(answer="THE ANSWER", keep_going_answers=["KEEP GOING ANSWER"]) + + users = [UserFactory() for _ in range(5)] + + # 3 wrong guesses + for user in users[:3]: + guess_submit(puzzle, user, puzzle.answer + "WRONG") + # 3 more wrong guesses + for user in users[2 : 2 + 3]: + guess_submit(puzzle, user, puzzle.answer + "WRONG WRONG") + # 4 keep going guesses + for user in users[:4]: + guess_submit(puzzle, user, puzzle.keep_going_answers[0]) + # 2 correct guesses + for user in users[:2]: + guess_submit(puzzle, user, puzzle.answer) + + # Total guesses: 12 + assert Puzzle.objects.with_guess_stats().get(pk=puzzle.pk).num_guesses == 12 + # Num incorrect guesses: 6 + assert ( + Puzzle.objects.with_guess_stats( + annotate_name="num_incorrect_guesses", filter_evaluations=[GuessEvaluation.INCORRECT] + ) + .get(pk=puzzle.pk) + .num_incorrect_guesses + == 6 + ) + + def test_available_puzzle_manager(): """AvailablePuzzleManager filters out puzzles that are not available yet.""" puzzle_avail = PuzzleFactory(available_at=timezone.now() - timezone.timedelta(days=1)) diff --git a/huntsite/puzzles/tests/test_views.py b/huntsite/puzzles/tests/test_views.py index e8d766a..1c7641f 100644 --- a/huntsite/puzzles/tests/test_views.py +++ b/huntsite/puzzles/tests/test_views.py @@ -1,4 +1,7 @@ +import re + from bs4 import BeautifulSoup +from django.test import Client from django.utils import timezone import pytest from pytest_django.asserts import assertRedirects, assertTemplateNotUsed, assertTemplateUsed @@ -40,6 +43,25 @@ def test_puzzle_list_view(client): assert puzzles[3].title not in puzzle_list_table.text +def test_puzzle_list_view_hunt_ended(client, settings): + """Stats are shown in list view""" + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + + _ = [ + PuzzleFactory( + calendar_entry__day=i, + available_at=timezone.now() + timezone.timedelta(days=i - 10), + ) + for i in range(4) + ] + + response = client.get("/puzzles/") + assert response.status_code == 200 + assert len(re.findall("0 solves", response.content.decode())) == 4 + assert len(re.findall("0 incorrect guesses", response.content.decode())) == 4 + + def test_puzzle_detail_auth(client): """Puzzle detail page requires logged in user.""" puzzle = PuzzleFactory() @@ -409,3 +431,75 @@ def test_puzzle_guess_submit_keep_going(client): def test_puzzle_solve_with_story_unlock(client): pass + + +def test_puzzle_answer_checker_hunt_state(settings): + """Serverside answer checker while hunt is live, clientside answer checker while hunt is + ended.""" + # Set up puzzle + puzzle = PuzzleFactory(available_at=timezone.now() - timezone.timedelta(days=1)) + # Set up users + anon_client = Client() + user = UserFactory() + user_client = Client() + user_client.force_login(user) + + ## Hunt state is live + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timezone.timedelta(days=2) + + # Anon user can't access puzzle detail page + response = anon_client.get(puzzle.get_absolute_url()) + assert response.status_code == 302 # redirect to login + # Registered user can access puzzle detail page + response = user_client.get(puzzle.get_absolute_url()) + assert response.status_code == 200 + assert puzzle.title in response.content.decode() + assert "Solves" not in response.content.decode() + assert "Incorrect guesses" not in response.content.decode() + assert puzzle.get_solution_absolute_url() not in response.content.decode() + assert "SERVERSIDE ANSWER CHECKER" in response.content.decode() + assert "CLIENTSIDE ANSWER CHECKER" not in response.content.decode() + # Registered user can submit guess + response = user_client.post(puzzle.get_absolute_url(), data={"guess": "I AM GUESSING"}) + assert response.status_code == 200 + + ## Hunt state is ended + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + + # Both users can access puzzle detail page + for client in (anon_client, user_client): + response = client.get(puzzle.get_absolute_url()) + assert response.status_code == 200 + assert puzzle.title in response.content.decode() + assert "Solves: 0" in response.content.decode() + assert "Incorrect guesses: 1" in response.content.decode() + assert puzzle.get_solution_absolute_url() in response.content.decode() + assert "SERVERSIDE ANSWER CHECKER" not in response.content.decode() + assert "CLIENTSIDE ANSWER CHECKER" in response.content.decode() + # User can't submit guesses anymore + response = user_client.post(puzzle.get_absolute_url(), data={"guess": "STILL GUESSING"}) + assert response.status_code == 403 + + +def test_puzzle_solution_page(settings): + # Set up puzzle + puzzle = PuzzleFactory(available_at=timezone.now() - timezone.timedelta(days=1)) + # Set up users + anon_client = Client() + user = UserFactory() + user_client = Client() + user_client.force_login(user) + + ## Hunt state is live + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timezone.timedelta(days=2) + # No one can access puzzle solution page + for client in (anon_client, user_client): + assert client.get(puzzle.get_solution_absolute_url()).status_code == 404 + + ## Hunt state is ended + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + # Both users can access puzzle detail page + for client in (anon_client, user_client): + assert client.get(puzzle.get_solution_absolute_url()).status_code == 200 diff --git a/huntsite/puzzles/urls.py b/huntsite/puzzles/urls.py index 842660c..72c9da8 100644 --- a/huntsite/puzzles/urls.py +++ b/huntsite/puzzles/urls.py @@ -5,4 +5,5 @@ urlpatterns = [ path("", views.puzzle_list, name="puzzle_list"), path("/", views.puzzle_detail, name="puzzle_detail"), + path("/solution/", views.puzzle_solution, name="puzzle_solution"), ] diff --git a/huntsite/puzzles/views.py b/huntsite/puzzles/views.py index acc8418..d218b72 100644 --- a/huntsite/puzzles/views.py +++ b/huntsite/puzzles/views.py @@ -1,5 +1,10 @@ +import base64 +import json + from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import Http404 from django.shortcuts import get_object_or_404, render from django.template.response import TemplateResponse from django.urls import reverse @@ -11,6 +16,7 @@ from huntsite.puzzles.models import GuessEvaluation, Puzzle import huntsite.puzzles.services as puzzle_services from huntsite.tester_utils.session_handlers import read_time_travel_session_var +from huntsite.utils import HuntState, get_hunt_state @require_safe @@ -29,13 +35,16 @@ def puzzle_list(request): logger.trace("Team '{user.team_name}' is viewing puzzle list", user=request.user) puzzle_manager = Puzzle.available - puzzles = ( - puzzle_manager.with_calendar_entry() - .with_meta_info() - .with_solves_by_user(request.user) - .all() - .order_by("calendar_entry__day") - ) + puzzle_queryset = puzzle_manager.with_calendar_entry().with_meta_info() + if get_hunt_state(request) < HuntState.ENDED: + puzzle_queryset = puzzle_queryset.with_solves_by_user(request.user) + else: + puzzle_queryset = puzzle_queryset.with_solve_stats().with_guess_stats( + annotate_name="num_incorrect_guesses", + filter_evaluations=[GuessEvaluation.INCORRECT], + ) + + puzzles = puzzle_queryset.all().order_by("calendar_entry__day") day_spine = list(range(1, 25)) context = { "day_spine": day_spine, @@ -52,9 +61,17 @@ def puzzle_list(request): } -@login_required @require_http_methods(["GET", "POST"]) def puzzle_detail(request, slug: str): + hunt_state = get_hunt_state(request) + if hunt_state < HuntState.ENDED: + return puzzle_detail_serverside(request, slug=slug) + else: + return puzzle_detail_clientside(request, slug=slug) + + +@login_required +def puzzle_detail_serverside(request, slug: str): """View to display the content page of a single puzzle and take guesses.""" puzzle_manager = Puzzle.objects if request.user.is_tester else Puzzle.available @@ -124,3 +141,52 @@ def puzzle_detail(request, slug: str): ) context["guesses"] = all_puzzle_guesses return render(request, "partials/puzzle_guess_list.html", context) + + +def puzzle_detail_clientside(request, slug: str): + """Puzzle detail view with clientside answer checker, for after hunt has ended.""" + puzzle_manager = Puzzle.objects + + if request.method == "GET": + queryset = ( + puzzle_manager.with_errata() + .with_clipboard_data() + .with_external_links() + .with_canned_hints( + as_of=read_time_travel_session_var(request) if request.user.is_tester else None + ) + .with_solve_stats() + .with_guess_stats( + annotate_name="num_incorrect_guesses", + filter_evaluations=[GuessEvaluation.INCORRECT], + ) + ) + puzzle = get_object_or_404(queryset, slug=slug) + # Base64-encode the answers so they're not in plaintext in the HTML source + answer_data = { + "answer": base64.b64encode(puzzle.answer.encode()).decode(), + "keep_goings": [ + base64.b64encode(ans.encode()).decode() for ans in puzzle.keep_going_answers + ], + } + context = { + "puzzle": puzzle, + "answer_data": json.dumps(answer_data), + } + return TemplateResponse(request, "puzzle_detail.html", context) + + elif request.method == "POST": + raise PermissionDenied + + +@require_safe +def puzzle_solution(request, slug: str): + hunt_state = get_hunt_state(request) + if not request.user.is_tester and hunt_state < HuntState.ENDED: + raise Http404 + + puzzle = get_object_or_404(Puzzle, slug=slug) + context = { + "puzzle": puzzle, + } + return TemplateResponse(request, "puzzle_solution.html", context) diff --git a/huntsite/tests.py b/huntsite/tests.py index e4a69c9..4656e3d 100644 --- a/huntsite/tests.py +++ b/huntsite/tests.py @@ -1,6 +1,6 @@ from bs4 import BeautifulSoup from django.conf import settings -from django.test import Client +from django.test import Client, RequestFactory from django.utils import timezone from metadata_parser import MetadataParser import pytest @@ -8,6 +8,8 @@ from huntsite.puzzles.factories import MetapuzzleInfoFactory, PuzzleFactory from huntsite.puzzles.services import guess_submit from huntsite.teams.factories import UserFactory +from huntsite.teams.models import AnonymousUser +from huntsite.utils import HuntState, get_hunt_state pytestmark = pytest.mark.django_db @@ -24,6 +26,29 @@ def test_home_page(client): assert "Advent Puzzle Hunt" in response.content.decode() +def test_hunt_state(settings): + request_factory = RequestFactory() + + # Prehunt + settings.HUNT_IS_LIVE_DATETIME = timezone.now() + timezone.timedelta(days=1) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timezone.timedelta(days=2) + request = request_factory.get("/") + request.user = AnonymousUser() + assert get_hunt_state(request) == HuntState.PREHUNT + + # Live + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) + request = request_factory.get("/") + request.user = AnonymousUser() + assert get_hunt_state(request) == HuntState.LIVE + + # Ended + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + request = request_factory.get("/") + request.user = AnonymousUser() + assert get_hunt_state(request) == HuntState.ENDED + + def test_navbar(client): response = client.get("/") assert response.status_code == 200 @@ -44,8 +69,32 @@ def test_navbar(client): assert "Logout" in navbar.text -def test_santa_missing(monkeypatch): - """Favicon and Navbar logo should change based on the HUNT_IS_LIVE_DATETIME setting.""" +def test_navbar_hunt_ended(client, settings): + """Navbar should not have any account-related options if hunt is ended.""" + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + + response = client.get("/") + assert response.status_code == 200 + soup = BeautifulSoup(response.content, "html.parser") + navbar = soup.find("nav") + assert "Advent Hunt" in navbar.text + assert "Login" not in navbar.text + assert "Register" not in navbar.text + + user = UserFactory(team_name="Test Herrings 🎏") + client.force_login(user) + response = client.get("/") + assert response.status_code == 200 + soup = BeautifulSoup(response.content, "html.parser") + navbar = soup.find("nav") + assert "Advent Hunt" in navbar.text + assert "Test Herrings 🎏" not in navbar.text + assert "Logout" not in navbar.text + + +def test_santa_missing(monkeypatch, settings): + """Favicon and Navbar logo should change based on the hunt state.""" # Set up users anon_client = Client() @@ -56,10 +105,9 @@ def test_santa_missing(monkeypatch): user2_client = Client() user2_client.force_login(user2) - ## Before changeover date, normal santa - monkeypatch.setattr( - settings, "HUNT_IS_LIVE_DATETIME", timezone.now() + timezone.timedelta(days=1) - ) + ## While hunt state is prehunt, normal santa + settings.HUNT_IS_LIVE_DATETIME = timezone.now() + timezone.timedelta(days=1) + settings.HUNT_IS_ENDED_DATETIME = timezone.now() + timezone.timedelta(days=2) for client in (anon_client, user1_client, user2_client): response = client.get("/") assert response.status_code == 200 @@ -73,10 +121,8 @@ def test_santa_missing(monkeypatch): assert "static/santa/" in navbar.img["src"] assert "static/santa-missing" not in navbar.img["src"] - ## After changeover date, santa missing - monkeypatch.setattr( - settings, "HUNT_IS_LIVE_DATETIME", timezone.now() - timezone.timedelta(days=1) - ) + ## While hunt state is live, santa missing + settings.HUNT_IS_LIVE_DATETIME = timezone.now() - timezone.timedelta(days=2) # But user1 finishes the hunt, Santa is back final_puzzle = PuzzleFactory() MetapuzzleInfoFactory(puzzle=final_puzzle, is_final=True) @@ -110,6 +156,22 @@ def test_santa_missing(monkeypatch): assert "static/santa/" in navbar.img["src"] assert "static/santa-missing" not in navbar.img["src"] + ## While hunt state is ended, santa missing + settings.HUNT_IS_ENDED_DATETIME = timezone.now() - timezone.timedelta(days=1) + # We don't really care about which one is shown for user1 that finished + for client in (anon_client, user2_client): + response = client.get("/") + assert response.status_code == 200 + soup = BeautifulSoup(response.content, "html.parser") + # Favicon + header = soup.find("head") + assert "static/santa/" not in str(header) + assert "static/santa-missing" in str(header) + # Navbar logo + navbar = soup.find("div", class_="navbar-brand") + assert "static/santa/" not in navbar.img["src"] + assert "static/santa-missing" in navbar.img["src"] + def test_santa_missing_og_image(client, monkeypatch): """The og:image meta tag should change based on the HUNT_IS_LIVE_DATETIME setting.""" diff --git a/huntsite/utils.py b/huntsite/utils.py new file mode 100644 index 0000000..9a24cc0 --- /dev/null +++ b/huntsite/utils.py @@ -0,0 +1,43 @@ +import enum + +from django.conf import settings +from django.utils import timezone + +from huntsite.tester_utils.session_handlers import read_time_travel_session_var + + +class HuntState(enum.IntEnum): + # IntEnum to allow inequality comparisons + PREHUNT = enum.auto() + LIVE = enum.auto() + ENDED = enum.auto() + + @property + def do_not_call_in_templates(self): + """Turn off calling in templates, otherwise value access doesn't work properly. + https://stackoverflow.com/questions/35953132/how-to-access-enum-types-in-django-templates + """ + return True + + def __str__(self): + return self.name.lower() + + +def get_hunt_state(request): + """Determine curent hunt state.""" + # Determine current time + if ( + request.user.is_authenticated + and request.user.is_tester + and (time_traveling_at := read_time_travel_session_var(request)) + ): + now = time_traveling_at + else: + now = timezone.now() + + if now >= settings.HUNT_IS_ENDED_DATETIME: + return HuntState.ENDED + elif now >= settings.HUNT_IS_LIVE_DATETIME: + return HuntState.LIVE + else: + return HuntState.PREHUNT diff --git a/project/settings.py b/project/settings.py index 3fad03e..3264d6f 100644 --- a/project/settings.py +++ b/project/settings.py @@ -145,7 +145,7 @@ class DeployEnvironment(StrEnum): # Local custom context processors "huntsite.context_processors.meta", "huntsite.context_processors.canonical", - "huntsite.context_processors.hunt_is_live", + "huntsite.context_processors.hunt_state", "huntsite.tester_utils.context_processors.time_travel", "huntsite.context_processors.announcement_message", "huntsite.context_processors.discord_server_link", @@ -434,6 +434,23 @@ class DeployEnvironment(StrEnum): if HUNT_IS_LIVE_DATETIME.tzinfo is None: HUNT_IS_LIVE_DATETIME = HUNT_IS_LIVE_DATETIME.replace(tzinfo=datetime.timezone.utc) +HUNT_IS_ENDED_DATETIME = env.datetime( + "HUNT_IS_ENDED_DATETIME", + default=(HUNT_IS_LIVE_DATETIME + timezone.timedelta(days=31)).isoformat(), +) +if HUNT_IS_ENDED_DATETIME.tzinfo is None: + HUNT_IS_ENDED_DATETIME = HUNT_IS_ENDED_DATETIME.replace(tzinfo=datetime.timezone.utc) +assert HUNT_IS_ENDED_DATETIME >= HUNT_IS_LIVE_DATETIME + +logger.info("HUNT_IS_LIVE_DATETIME: {}", HUNT_IS_LIVE_DATETIME) +logger.info("HUNT_IS_ENDED_DATETIME: {}", HUNT_IS_ENDED_DATETIME) +if timezone.now() >= HUNT_IS_ENDED_DATETIME: + logger.info("Hunt state is ended.") +elif timezone.now() >= HUNT_IS_LIVE_DATETIME: + logger.info("Hunt state is live.") +else: + logger.info("Hunt state is not yet live.") + ## Announcement message if ANNOUNCEMENT_MESSAGE := env("ANNOUNCEMENT_MESSAGE", default=None): logger.info("Announcement message active: " + ANNOUNCEMENT_MESSAGE) diff --git a/scripts/upload_puzzle_pdf.py b/scripts/upload_puzzle_pdf.py index 61efae1..61bcf27 100644 --- a/scripts/upload_puzzle_pdf.py +++ b/scripts/upload_puzzle_pdf.py @@ -14,7 +14,7 @@ } -def main(input_file: Path, dryrun: bool = False): +def main(input_file: Path, solution: bool = False, dryrun: bool = False): content = input_file.read_bytes() md5sum = md5(content) xxh32sum = xxh32(content) @@ -28,7 +28,12 @@ def main(input_file: Path, dryrun: bool = False): if not dryrun: client = S3Client(endpoint_url=env("R2_ENDPOINT_URL"), extra_args=UPLOAD_EXTRA_ARGS) - output_path = client.S3Path(f"s3://{env('R2_BUCKET_NAME')}/puzzles/{output_file_name}") + if not solution: + output_path = client.S3Path(f"s3://{env('R2_BUCKET_NAME')}/puzzles/{output_file_name}") + else: + output_path = client.S3Path( + f"s3://{env('R2_BUCKET_NAME')}/solutions/{output_file_name}" + ) output_path.upload_from(input_file, force_overwrite_to_cloud=True) print("Successfully uploaded to:", output_path) diff --git a/static/css/base.css b/static/css/base.css index 473319c..d137d01 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -227,12 +227,13 @@ footer.footer { /* Spoiler tag */ .spoiler { - display: inline-block; - width: 100%; - height: 100%; background-color: var(--bulma-strong-color); } .spoiler:hover { + background-color: var(--parchment); +} + +.spoiler.scheme-background:hover { background-color: var(--bulma-scheme-main); } diff --git a/static/css/puzzle_detail.css b/static/css/puzzle_detail.css index fb7b214..7f5d40d 100644 --- a/static/css/puzzle_detail.css +++ b/static/css/puzzle_detail.css @@ -2,6 +2,16 @@ section.no-bottom-padding { padding-bottom: 0; } +#solution-and-stats { + margin-left: auto; + margin-right: auto; + max-width: 40rem; +} + +#solution-and-stats p { + vertical-align: center; +} + #errata { margin-left: auto; margin-right: auto; @@ -20,11 +30,11 @@ section.no-bottom-padding { max-width: 40rem; } -#guess-form>form>.field { +#guess-form-container>form>.field { justify-content: center; } -#guess-form>form>.field>.control:has(>input#id_guess) { +#guess-form-container>form>.field>.control:has(>input#id_guess) { flex: 1; } @@ -39,7 +49,7 @@ section.no-bottom-padding { top: -0.75em; } -#guesses-table>table { +#guesses-results>table { margin-left: auto; margin-right: auto; } diff --git a/templates/base.html b/templates/base.html index 9c0516b..90994a6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,13 +17,13 @@ - {% if not hunt_is_live %} + {% if hunt_state == HuntState.PREHUNT %} {% else %} {% endif %} - {% if not hunt_is_live or user.is_finished %} + {% if hunt_state == HuntState.PREHUNT or user.is_finished %} {% comment %} Normal Santa Logo {% endcomment %}
- {% if not hunt_is_live %} + {% if hunt_state == HuntState.PREHUNT %}
{% include "partials/story_prehunt.html" %}
{% else %}
diff --git a/templates/partials/clientside_answer_checker.html b/templates/partials/clientside_answer_checker.html new file mode 100644 index 0000000..b85e392 --- /dev/null +++ b/templates/partials/clientside_answer_checker.html @@ -0,0 +1,114 @@ + +
+
+
+
+ +
+
+ +
+
+
+
+ + diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html index cb6eccf..f491c6c 100644 --- a/templates/partials/navbar.html +++ b/templates/partials/navbar.html @@ -3,7 +3,7 @@
- + {% if hunt_state < HuntState.ENDED %} + + {% endif %}
diff --git a/templates/partials/tester_controls.html b/templates/partials/tester_controls.html index 953c9ca..8df75d1 100644 --- a/templates/partials/tester_controls.html +++ b/templates/partials/tester_controls.html @@ -8,12 +8,15 @@ {% if puzzle %}

- Answer: {{ puzzle.answer }} + Answer: +
+ {{ puzzle.answer }}

{% if puzzle.keep_going_answers %}

Keep Going: - {{ puzzle.keep_going_answers|join:'
' }} +
+ {{ puzzle.keep_going_answers|join:'
' }}

{% endif %}
diff --git a/templates/puzzle_detail.html b/templates/puzzle_detail.html index fc43647..a045eb6 100644 --- a/templates/puzzle_detail.html +++ b/templates/puzzle_detail.html @@ -17,6 +17,18 @@

{{ puzzle.title }} {% if not puzzle.is_available %}Preview{% endif %}

+ {% if hunt_state >= HuntState.ENDED %} +
+

+ View solution +

+

+ Solves: {{ puzzle.num_solves }}  |  Incorrect guesses: {{ puzzle.num_incorrect_guesses }} +

+
+ {% endif %} {% if puzzle.errata.exists %}
@@ -74,12 +86,17 @@

{% include "partials/card_toggle_indicator.html" %}

-
{% crispy form %}
-
- {% if guesses %} - {% include "partials/puzzle_guess_list.html" %} - {% endif %} -
+ {% if hunt_state < HuntState.ENDED %} + +
{% crispy form %}
+
+ {% if guesses %} + {% include "partials/puzzle_guess_list.html" %} + {% endif %} +
+ {% else %} + {% include "partials/clientside_answer_checker.html" %} + {% endif %}
{% include "partials/card_toggle_script.html" %} diff --git a/templates/puzzle_list.html b/templates/puzzle_list.html index b371fcc..cf02188 100644 --- a/templates/puzzle_list.html +++ b/templates/puzzle_list.html @@ -59,6 +59,10 @@

List of Puzzles

{% if puzzle.is_solved %}{{ puzzle.answer }}{% endif %} + {% if hunt_state >= HuntState.ENDED %} + {{ puzzle.num_solves }} solves + {{ puzzle.num_incorrect_guesses }} incorrect guesses + {% endif %} {% endfor %} diff --git a/templates/puzzle_solution.html b/templates/puzzle_solution.html new file mode 100644 index 0000000..f3921ce --- /dev/null +++ b/templates/puzzle_solution.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load static %} +{% block title %} + Solution: {{ puzzle.title }} | {{ META_TITLE }} +{% endblock title %} +{% block header_extra %} + {% include "partials/timestamp_localize_libraries.html" %} +{% endblock header_extra %} +{% block body %} +
+
+ {% include "partials/announcement_message.html" %} + {% include "partials/messages.html" %} + +

Solution: {{ puzzle.title }}

+ Back to puzzle +

+ Answer:      {{ puzzle.answer }}     +

+ {% if not puzzle.solution_pdf_url %} +

Sorry, the solution for this puzzle isn't available yet.

+ {% else %} +
+

+ + Scroll with cursor within PDF frame to scroll PDF. Scroll with cursor outside of frame to scroll webpage. +

+

+ +    + + +    + + +    + +

+
+
+ +

+ Something prevented us from displaying the solution PDF! Click here to download it instead. +

+
+ +
+ {% endif %} +
+
+{% endblock body %} +{% block footer %} +{% endblock footer %} diff --git a/templates/story.html b/templates/story.html index 3da6555..536c7d7 100644 --- a/templates/story.html +++ b/templates/story.html @@ -12,18 +12,18 @@

The story so far...

You receive an exciting piece of mail from the North Pole...

- {% if hunt_is_live %} - {% include "partials/card_toggle_indicator.html" with start_hidden=True %} - {% else %} + {% if hunt_state == HuntState.PREHUNT %} {% include "partials/card_toggle_indicator.html" %} + {% else %} + {% include "partials/card_toggle_indicator.html" with start_hidden=True %} {% endif %}
-
+
{% include "partials/story_prehunt.html" %}
{% comment %} Hunt live story {% endcomment %} - {% if hunt_is_live %} + {% if hunt_state > HuntState.PREHUNT %}

You arrive at the North Pole…

@@ -42,14 +42,19 @@

You arrive at the North Pole…

{% for entry in entries %}
-

{{ entry.title }}

- {% if forloop.counter < entries|length %} +

+ {{ entry.title }} + {% if hunt_state >= HuntState.ENDED %} +  Spoiler warning for {{ entry.puzzle.title }} + {% endif %} +

+ {% if forloop.counter < entries|length or hunt_state >= HuntState.ENDED %} {% include "partials/card_toggle_indicator.html" with start_hidden=True %} {% else %} {% include "partials/card_toggle_indicator.html" %} {% endif %}
-
+
{% if entry.is_final %} Click here to view the final story entry. {% else %}