Skip to content
2 changes: 2 additions & 0 deletions myus/myus/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class Meta:
fields = [
"name",
"slug",
"is_private",
"description",
"start_time",
"end_time",
Expand All @@ -98,6 +99,7 @@ class Meta:
fields = [
"name",
"slug",
"is_private",
"description",
"start_time",
"end_time",
Expand Down
24 changes: 24 additions & 0 deletions myus/myus/migrations/0015_hunt_is_private.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.1.3 on 2024-12-01 04:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
(
"myus",
"0012_hunt_solution_style_puzzle_solution_url_squashed_0014_alter_puzzle_solution_url",
),
]

operations = [
migrations.AddField(
model_name="hunt",
name="is_private",
field=models.BooleanField(
default=False,
help_text="If true, only organizers can view the hunt and its puzzles. Defaults to false.",
),
),
]
17 changes: 17 additions & 0 deletions myus/myus/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class Hunt(models.Model):
help_text="The default number of guesses teams get on each puzzle; 0 means unlimited",
validators=[MinValueValidator(0)],
)
is_private = models.BooleanField(
default=False,
help_text="If true, non-organizers will need to provide an exact URL to view the hunt.",
)

class LeaderboardStyle(models.TextChoices):
DEFAULT = "DEF", "Default (ordered by score, solve count, and last solve time)"
Expand All @@ -79,6 +83,19 @@ class SolutionStyle(models.TextChoices):
def public_puzzles(self):
return self.puzzles.filter(progress_threshold__lte=self.progress_floor)

def is_authorized_to_view(self, user: User, url_slug: str | None):
if not self.is_private:
return True
if user.is_authenticated and self.organizers.filter(id=user.id).exists():
return True
if self.slug == url_slug:
return True

return False

def is_organizer(self, user: User):
return user.is_authenticated and self.organizers.filter(id=user.id).exists()

def __str__(self):
return self.name

Expand Down
161 changes: 161 additions & 0 deletions myus/myus/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,80 @@ def test_view_hunt_with_id_and_wrong_slug_redirects_to_id_and_correct_slug(self)
)


class TestViewPrivateHunt(TestCase):
"""Test the view_hunt endpoint for private hunts"""

def setUp(self):
self.organizer = User.objects.create(username="organizer")
self.user = User.objects.create(username="testuser")

self.hunt = Hunt.objects.create(
name="Test Hunt", slug="test-hunt", is_private=True
)
self.hunt.organizers.add(self.organizer)

self.view_name = "view_hunt"
self.no_slug_url = reverse(self.view_name, args=[self.hunt.id])
self.incorrect_slug_url = reverse(
self.view_name, args=[self.hunt.id, "the-wrong-slug"]
)
self.correct_url = reverse(self.view_name, args=[self.hunt.id, self.hunt.slug])

def test_view_private_hunt_as_organizer_with_id_and_no_slug_success(self):
"""Organizer should be able to view hunt with no slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.no_slug_url)
self.assertRedirects(res, self.correct_url)

def test_view_private_hunt_as_organizer_with_id_and_incorrect_slug_success(self):
"""Organizer should be able to view hunt with incorrect slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.incorrect_slug_url)
self.assertRedirects(res, self.correct_url)

def test_view_private_hunt_as_organizer_with_id_and_correct_slug_success(self):
"""Organizer should be able to view hunt with correct slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_hunt.html")

def test_view_private_hunt_as_user_with_id_and_no_slug_failure(self):
"""User should not be able to view hunt with no slug"""
self.client.force_login(self.user)
res = self.client.get(self.no_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_as_user_with_id_and_incorrect_slug_failure(self):
"""User should not be able to view hunt with incorrect slug"""
self.client.force_login(self.user)
res = self.client.get(self.incorrect_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_as_user_with_id_and_correct_slug_success(self):
"""User should be able to view hunt with correct slug"""
self.client.force_login(self.user)
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_hunt.html")

def test_view_private_hunt_as_guest_with_id_and_no_slug_failure(self):
"""Guests should not be able to view hunt with no slug"""
res = self.client.get(self.no_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_as_guest_with_id_and_incorrect_slug_failure(self):
"""Guests should not be able to view hunt with incorrect slug"""
res = self.client.get(self.incorrect_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_as_guest_with_id_and_correct_slug_success(self):
"""Guests should be able to view hunt with correct slug"""
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_hunt.html")


class TestViewPuzzle(TestCase):
"""Test the view_puzzle endpoint

Expand Down Expand Up @@ -108,6 +182,91 @@ def test_view_puzzle_with_ids_and_wrong_slugs_redirects_to_ids_and_correct_slugs
self.assertRedirects(res, self.correct_url)


class TestViewPrivateHuntPuzzle(TestCase):
def setUp(self):
self.organizer = User.objects.create(username="organizer")
self.user = User.objects.create(username="testuser")

self.hunt = Hunt.objects.create(
name="Test Hunt", slug="test-hunt", is_private=True
)
self.puzzle = Puzzle.objects.create(
name="Test Puzzle", slug="test-puzzle", hunt=self.hunt
)
self.hunt.organizers.add(self.organizer)

self.view_name = "view_puzzle"
self.no_slug_url = reverse(
self.view_name, args=[self.hunt.id, self.puzzle.id, self.puzzle.slug]
)
self.incorrect_slug_url = reverse(
self.view_name,
args=[self.hunt.id, "the-wrong-slug", self.puzzle.id, self.puzzle.slug],
)
self.correct_url = reverse(
self.view_name,
args=[self.hunt.id, self.hunt.slug, self.puzzle.id, self.puzzle.slug],
)

def test_view_private_hunt_puzzle_as_organizer_with_id_and_no_slug_success(self):
"""Organizer should be able to view hunt with no slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.no_slug_url)
self.assertRedirects(res, self.correct_url)

def test_view_private_hunt_puzzle_as_organizer_with_id_and_incorrect_slug_success(
self,
):
"""Organizer should be able to view hunt with incorrect slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.incorrect_slug_url)
self.assertRedirects(res, self.correct_url)

def test_view_private_hunt_puzzle_as_organizer_with_id_and_correct_slug_success(
self,
):
"""Organizer should be able to view hunt with correct slug"""
self.client.force_login(self.organizer)
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_puzzle.html")

def test_view_private_hunt_puzzle_as_user_with_id_and_no_slug_failure(self):
"""User should not be able to view hunt with no slug"""
self.client.force_login(self.user)
res = self.client.get(self.no_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_puzzle_as_user_with_id_and_incorrect_slug_failure(self):
"""User should not be able to view hunt with incorrect slug"""
self.client.force_login(self.user)
res = self.client.get(self.incorrect_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_puzzle_as_user_with_id_and_correct_slug_success(self):
"""User should be able to view hunt with correct slug"""
self.client.force_login(self.user)
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_puzzle.html")

def test_view_private_hunt_puzzle_as_guest_with_id_and_no_slug_failure(self):
"""Guests should not be able to view hunt with no slug"""
res = self.client.get(self.no_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_puzzle_as_guest_with_id_and_incorrect_slug_failure(self):
"""Guests should not be able to view hunt with incorrect slug"""
res = self.client.get(self.incorrect_slug_url)
self.assertEqual(res.status_code, HTTPStatus.FORBIDDEN)

def test_view_private_hunt_puzzle_as_guest_with_id_and_correct_slug_success(self):
"""Guests should be able to view hunt with correct slug"""
res = self.client.get(self.correct_url)
self.assertEqual(res.status_code, HTTPStatus.OK)
self.assertTemplateUsed(res, "view_puzzle.html")


class TestNewHuntForm(TestCase):
"""Test the NewHuntForm"""

Expand All @@ -117,6 +276,8 @@ def setUp(self):
"slug": "test",
"member_limit": 0,
"guess_limit": 20,
"leaderboard_style": "DEF",
"solution_style": "VIS",
}

def test_hunt_form_accepts_start_time_in_iso_format(self):
Expand Down
Loading