diff --git a/myus/myus/forms.py b/myus/myus/forms.py index 32d276e..ad81acc 100644 --- a/myus/myus/forms.py +++ b/myus/myus/forms.py @@ -78,6 +78,7 @@ class Meta: fields = [ "name", "slug", + "is_private", "description", "start_time", "end_time", @@ -98,6 +99,7 @@ class Meta: fields = [ "name", "slug", + "is_private", "description", "start_time", "end_time", diff --git a/myus/myus/migrations/0015_hunt_is_private.py b/myus/myus/migrations/0015_hunt_is_private.py new file mode 100644 index 0000000..511e307 --- /dev/null +++ b/myus/myus/migrations/0015_hunt_is_private.py @@ -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.", + ), + ), + ] diff --git a/myus/myus/models.py b/myus/myus/models.py index 1681c92..53a30be 100644 --- a/myus/myus/models.py +++ b/myus/myus/models.py @@ -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)" @@ -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 diff --git a/myus/myus/tests.py b/myus/myus/tests.py index 50d8ef6..a603361 100644 --- a/myus/myus/tests.py +++ b/myus/myus/tests.py @@ -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 @@ -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""" @@ -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): diff --git a/myus/myus/views.py b/myus/myus/views.py index c9daca6..617b79a 100644 --- a/myus/myus/views.py +++ b/myus/myus/views.py @@ -6,7 +6,7 @@ from django.db.models import OuterRef, Sum, Subquery, Count, Q, F, DurationField from django.db.models.functions import Coalesce, Greatest from django.http import Http404, JsonResponse -from django.http import HttpResponse +from django.http import HttpResponse, HttpRequest from django.shortcuts import render, redirect, get_object_or_404 from django.template.loader import render_to_string from django.views.decorators.csrf import csrf_exempt @@ -31,7 +31,7 @@ def index(request): # user = request.user - hunts = Hunt.objects.all() + hunts = Hunt.objects.filter(is_private=False) return render( request, "index.html", @@ -89,15 +89,23 @@ def get_team(user, hunt): def redirect_from_hunt_id_to_hunt_id_and_slug(view_func): - """Redirect from a URL with a hunt ID to a URL with a hunt ID and a slug + """If hunt is private, non-organizers will need to provide an exact slug. - Also redirect from a URL with a hunt ID and the wrong slug to the correct slug + If hunt is public: + - Redirect from a URL with a hunt ID to a URL with a hunt ID and a slug + - Also redirect from a URL with a hunt ID and the wrong slug to the correct slug """ @wraps(view_func) - def wrapper(request, hunt_id: int, *args, slug: Optional[str] = None, **kwargs): + def wrapper( + request: HttpRequest, hunt_id: int, *args, slug: Optional[str] = None, **kwargs + ): hunt = get_object_or_404(Hunt, id=hunt_id) + user = request.user + if not hunt.is_authorized_to_view(user, slug): + raise PermissionDenied + if hunt.slug != slug: view_name = urls.resolve(request.path_info).url_name return redirect(view_name, hunt.id, hunt.slug) @@ -108,14 +116,16 @@ def wrapper(request, hunt_id: int, *args, slug: Optional[str] = None, **kwargs): def force_url_to_include_both_hunt_and_puzzle_slugs(view_func): - """Redirect from a URL missing a hunt or puzzle slug to one that includes them + """If hunt is private, non-organizers will need to provide an exact slug. - Also redirect from a URL where the ID doesn't match the slug to the correct URL + If hunt is public: + - Redirect from a URL missing a hunt or puzzle slug to one that includes them + - Also redirect from a URL where the ID doesn't match the slug to the correct URL """ @wraps(view_func) def wrapper( - request, + request: HttpRequest, hunt_id: int, puzzle_id: int, *args, @@ -126,6 +136,10 @@ def wrapper( hunt = get_object_or_404(Hunt, id=hunt_id) puzzle = get_object_or_404(Puzzle, hunt=hunt, id=puzzle_id) + user = request.user + if not hunt.is_authorized_to_view(user, hunt_slug): + raise PermissionDenied + if hunt.slug != hunt_slug or puzzle.slug != puzzle_slug: view_name = urls.resolve(request.path_info).url_name return redirect( @@ -154,7 +168,7 @@ def view_hunt(request, hunt_id: int, slug: Optional[str] = None): user = request.user hunt = get_object_or_404(Hunt, id=hunt_id) team = get_team(user, hunt) - is_organizer = user.is_authenticated and hunt.organizers.filter(id=user.id).exists() + is_organizer = hunt.is_organizer(user) if is_organizer: puzzles = hunt.puzzles.all() @@ -185,7 +199,7 @@ def leaderboard(request, hunt_id: int, slug: Optional[str] = None): user = request.user hunt = get_object_or_404(Hunt, id=hunt_id) team = get_team(user, hunt) - is_organizer = user.is_authenticated and hunt.organizers.filter(id=user.id).exists() + is_organizer = hunt.is_organizer(user) # for the sake of simplicity, assume teams won't end up with two correct guesses for a puzzle teams = hunt.teams.annotate( @@ -255,7 +269,7 @@ def view_puzzle( puzzle = get_object_or_404(Puzzle, hunt=hunt, id=puzzle_id) team = get_team(user, hunt) - is_organizer = user.is_authenticated and hunt.organizers.filter(id=user.id).exists() + is_organizer = hunt.is_organizer(user) if not is_organizer and not puzzle.is_viewable_by(team): raise Http404("Puzzle is not viewable by team (or the public)") @@ -365,7 +379,7 @@ def view_puzzle_log( hunt = get_object_or_404(Hunt, id=hunt_id) puzzle = get_object_or_404(Puzzle, hunt=hunt, id=puzzle_id) - is_organizer = user.is_authenticated and hunt.organizers.filter(id=user.id).exists() + is_organizer = hunt.is_organizer(user) if not is_organizer: raise Http404("Puzzle stats are only viewable by organizers") @@ -462,8 +476,7 @@ def my_team(request, hunt_id: int, slug: Optional[str] = None): if user.is_authenticated else [] ), - "is_organizer": not user.is_anonymous - and hunt.organizers.filter(id=user.id).exists(), + "is_organizer": hunt.is_organizer(user), }, ) @@ -483,7 +496,7 @@ def new_puzzle(request, hunt_id: int, slug: Optional[str] = None): user = request.user hunt = get_object_or_404(Hunt, id=hunt_id) - if not hunt.organizers.filter(id=user.id).exists(): + if not hunt.is_organizer(user): return HttpResponse(status=403) if request.method == "POST": @@ -528,7 +541,7 @@ def edit_puzzle( user = request.user hunt = get_object_or_404(Hunt, id=hunt_id) puzzle = get_object_or_404(Puzzle, hunt=hunt, id=puzzle_id) - if not hunt.organizers.filter(id=user.id).exists(): + if not hunt.is_organizer(user): return HttpResponse(status=403) if request.method == "POST": @@ -565,7 +578,7 @@ def edit_hunt( ): user = request.user hunt = get_object_or_404(Hunt, id=hunt_id) - if not hunt.organizers.filter(id=user.id).exists(): + if not hunt.is_organizer(user): raise PermissionDenied if request.method == "POST":