Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hunt end #70

Merged
merged 10 commits into from
Dec 31, 2024
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 77 additions & 8 deletions huntsite/content/tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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/")
Expand Down Expand Up @@ -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/")
Expand Down Expand Up @@ -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/")
Expand Down Expand Up @@ -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()
Expand All @@ -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/")
Expand Down Expand Up @@ -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()
Expand Down
42 changes: 25 additions & 17 deletions huntsite/content/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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):
Expand Down
20 changes: 7 additions & 13 deletions huntsite/context_processors.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions huntsite/puzzles/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion huntsite/puzzles/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions huntsite/puzzles/migrations/0008_puzzle_solution_pdf_url.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
31 changes: 19 additions & 12 deletions huntsite/puzzles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading