From c5b7f3f3e2b8060b2390063b621e262112870481 Mon Sep 17 00:00:00 2001 From: Jack Brounstein Date: Thu, 7 Mar 2024 18:03:32 -0600 Subject: [PATCH] #39: use specific datetime-local widgets (which provides a calendar datepicker on modern browsers) for hunt start/end times --- .gitignore | 3 + myus/__init__.py | 0 myus/myus/forms.py | 129 +++++++++++++++++++++++++++++++++++++ myus/myus/tests.py | 154 ++++++++++++++++++++++++++++++++++++++------- myus/myus/views.py | 134 +++++---------------------------------- 5 files changed, 278 insertions(+), 142 deletions(-) create mode 100644 myus/__init__.py create mode 100644 myus/myus/forms.py diff --git a/.gitignore b/.gitignore index 61a207e..a8285e8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,8 @@ staticfiles # VSCode \.vscode/ +# PyCharm +.idea/ + # Direnv (see https://direnv.net/ ) .envrc diff --git a/myus/__init__.py b/myus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myus/myus/forms.py b/myus/myus/forms.py new file mode 100644 index 0000000..78115e5 --- /dev/null +++ b/myus/myus/forms.py @@ -0,0 +1,129 @@ +from django import forms + +from .models import Hunt, Puzzle, Team, User + + +class MarkdownTextarea(forms.Textarea): + template_name = "widgets/markdown_textarea.html" + + +# Custom date-time field using a datetime-local input +# Implementation from StackOverflow: https://stackoverflow.com/a/69965027 +class DateTimeLocalInput(forms.DateTimeInput): + input_type = "datetime-local" + + +class DateTimeLocalField(forms.DateTimeField): + input_formats = [ + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M", + ] + widget = DateTimeLocalInput(format="%Y-%m-%dT%H:%M") + + +# based on UserCreationForm from Django source +class RegisterForm(forms.ModelForm): + """ + A form that creates a user, with no privileges, from the given username and + password. + """ + + password1 = forms.CharField(label="Password", widget=forms.PasswordInput) + password2 = forms.CharField( + label="Password confirmation", + widget=forms.PasswordInput, + help_text="Enter the same password as above, for verification.", + ) + email = forms.EmailField( + label="Email address", + required=False, + help_text="Optional, but you'll get useful email notifications when we implement those.", + ) + bio = forms.CharField( + widget=MarkdownTextarea, + required=False, + help_text="(optional) Tell us about yourself. What kinds of puzzle genres or subject matter do you like?", + ) + + class Meta: + model = User + fields = ("username", "email", "display_name", "discord_username", "bio") + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError( + "The two password fields didn't match.", + code="password_mismatch", + ) + return password2 + + def save(self, commit=True): + user = super(RegisterForm, self).save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class HuntForm(forms.ModelForm): + description = forms.CharField(widget=MarkdownTextarea, required=False) + start_time = DateTimeLocalField(required=False, help_text="Date/time must be UTC") + end_time = DateTimeLocalField(required=False, help_text="Date/time must be UTC") + + class Meta: + model = Hunt + fields = [ + "name", + "slug", + "description", + "start_time", + "end_time", + "member_limit", + "guess_limit", + ] + + +class GuessForm(forms.Form): + guess = forms.CharField() + + +class TeamForm(forms.ModelForm): + class Meta: + model = Team + fields = ["name"] + + +class InviteMemberForm(forms.Form): + username = forms.CharField() + + def clean(self): + cleaned_data = super().clean() + username = cleaned_data.get("username") + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise forms.ValidationError("No such user!") + + cleaned_data["user"] = user + return cleaned_data + + +class PuzzleForm(forms.ModelForm): + content = forms.CharField(widget=MarkdownTextarea, required=False) + + class Meta: + model = Puzzle + fields = [ + "name", + "slug", + "content", + "answer", + "points", + "order", + "progress_points", + "progress_threshold", + ] diff --git a/myus/myus/tests.py b/myus/myus/tests.py index b8dfaf3..e540cd9 100644 --- a/myus/myus/tests.py +++ b/myus/myus/tests.py @@ -1,14 +1,18 @@ +from datetime import datetime, timezone from http import HTTPStatus from django.urls import reverse from django.test import TestCase -from myus.models import Hunt, Puzzle +from myus.forms import HuntForm +from myus.models import Hunt, Puzzle, User + class TestViewHunt(TestCase): - """ Test the view_hunt endpoint + """Test the view_hunt endpoint - The tests related to the handling of URLs with IDs and slugs should be taken as general tests for the redirect_from_hunt_id_to_hunt_id_and_slug decorator + The tests related to the handling of URLs with IDs and slugs should be taken as + general tests for the redirect_from_hunt_id_to_hunt_id_and_slug decorator """ def setUp(self): @@ -16,55 +20,159 @@ def setUp(self): self.view_name = "view_hunt" def test_view_hunt_with_id_and_slug_success(self): - """ Visiting the view_hunt endpoint with both ID and slug in the URL displays the requested page """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, self.hunt.slug])) + """Visiting the view_hunt endpoint with both ID and slug in the URL displays the requested page""" + res = self.client.get( + reverse(self.view_name, args=[self.hunt.id, self.hunt.slug]) + ) self.assertEqual(res.status_code, HTTPStatus.OK) self.assertTemplateUsed(res, "view_hunt.html") def test_view_hunt_with_id_only_redirects_to_id_and_slug(self): - """ Visiting the view_hunt endpoint with only ID in the URL redirects to the URL with ID and slug """ + """Visiting the view_hunt endpoint with only ID in the URL redirects to the URL with ID and slug""" res = self.client.get(reverse(self.view_name, args=[self.hunt.id])) - self.assertRedirects(res, reverse(self.view_name, args=[self.hunt.id, self.hunt.slug])) + self.assertRedirects( + res, reverse(self.view_name, args=[self.hunt.id, self.hunt.slug]) + ) def test_view_hunt_with_id_and_wrong_slug_redirects_to_id_and_correct_slug(self): - """ Visiting the view_hunt endpoint with ID and the wrong slug in URL redirects to URL with ID and correct slug """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, "the-wrong-slug"])) - self.assertRedirects(res, reverse(self.view_name, args=[self.hunt.id, self.hunt.slug])) + """Visiting the view_hunt endpoint with ID and the wrong slug in URL redirects to URL with ID and correct slug""" + res = self.client.get( + reverse(self.view_name, args=[self.hunt.id, "the-wrong-slug"]) + ) + self.assertRedirects( + res, reverse(self.view_name, args=[self.hunt.id, self.hunt.slug]) + ) class TestViewPuzzle(TestCase): - """ Test the view_puzzle endpoint + """Test the view_puzzle endpoint The tests related to the handling of URLs with IDs and slugs should be taken as general tests for the force_url_to_include_both_hunt_and_puzzle_slugs decorator """ + def setUp(self): self.hunt = Hunt.objects.create(name="Test Hunt", slug="test-hunt") - self.puzzle = Puzzle.objects.create(name="Test Puzzle", slug="test-puzzle", hunt=self.hunt) + self.puzzle = Puzzle.objects.create( + name="Test Puzzle", slug="test-puzzle", hunt=self.hunt + ) self.view_name = "view_puzzle" - self.correct_url = reverse(self.view_name, args=[self.hunt.id, self.hunt.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_puzzle_with_ids_and_slugs_success(self): - """ Visiting the view_puzzle endpoint with hunt and puzzle IDs and slug in the URL displays the requested page """ + """Visiting the view_puzzle endpoint with hunt and puzzle IDs and slug in the URL displays the requested page""" res = self.client.get(self.correct_url) self.assertEqual(res.status_code, HTTPStatus.OK) self.assertTemplateUsed(res, "view_puzzle.html") def test_view_puzzle_with_no_hunt_slug_redirects_to_full_url(self): - """ Visiting the view_puzzle endpoint with no hunt_slug in the URL redirects to the full URL """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, self.puzzle.id, self.puzzle.slug])) + """Visiting the view_puzzle endpoint with no hunt_slug in the URL redirects to the full URL""" + res = self.client.get( + reverse( + self.view_name, args=[self.hunt.id, self.puzzle.id, self.puzzle.slug] + ) + ) self.assertRedirects(res, self.correct_url) def test_view_puzzle_with_no_puzzle_slug_redirects_to_full_url(self): - """ Visiting the view_puzzle endpoint with no puzzle_slug in the URL redirects to the full URL """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, self.hunt.slug, self.puzzle.id])) + """Visiting the view_puzzle endpoint with no puzzle_slug in the URL redirects to the full URL""" + res = self.client.get( + reverse(self.view_name, args=[self.hunt.id, self.hunt.slug, self.puzzle.id]) + ) self.assertRedirects(res, self.correct_url) def test_view_puzzle_with_no_hunt_or_puzzle_slug_redirects_to_full_url(self): - """ Visiting the view_puzzle endpoint with no hunt_slug or puzzle_slug in the URL redirects to the full URL """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, self.puzzle.id])) + """Visiting the view_puzzle endpoint with no hunt_slug or puzzle_slug in the URL redirects to the full URL""" + res = self.client.get( + reverse(self.view_name, args=[self.hunt.id, self.puzzle.id]) + ) self.assertRedirects(res, self.correct_url) - def test_view_puzzle_with_ids_and_wrong_slugs_redirects_to_ids_and_correct_slugs(self): - """ Visiting the view_puzzle endpoint with IDs and the wrong slugs in URL redirects to URL with IDs and correct slugs """ - res = self.client.get(reverse(self.view_name, args=[self.hunt.id, "wrong-hunt-slug", self.puzzle.id, "wrong-puzzle-slug"])) + def test_view_puzzle_with_ids_and_wrong_slugs_redirects_to_ids_and_correct_slugs( + self, + ): + """Visiting the view_puzzle endpoint with IDs and the wrong slugs in URL redirects to URL with IDs and correct slugs""" + res = self.client.get( + reverse( + self.view_name, + args=[ + self.hunt.id, + "wrong-hunt-slug", + self.puzzle.id, + "wrong-puzzle-slug", + ], + ) + ) self.assertRedirects(res, self.correct_url) + + +class TestHuntForm(TestCase): + """Test the HuntForm""" + + def setUp(self): + self.shared_test_data = { + "name": "Test Hunt", + "slug": "test", + "member_limit": 0, + "guess_limit": 20, + } + + def test_hunt_form_accepts_start_time_in_iso_format(self): + """The HuntForm accepts the start_time field in ISO format (YYYY-MM-DDTHH:MM:SS)""" + test_data = self.shared_test_data.copy() + start_time = datetime(2024, 3, 15, 1, 2, tzinfo=timezone.utc) + test_data["start_time"] = start_time.isoformat() + form = HuntForm(data=test_data) + self.assertTrue(form.is_valid(), msg=form.errors) + hunt = form.save() + self.assertEqual(hunt.start_time, start_time) + + def test_hunt_form_accepts_start_time_without_seconds(self): + """The HuntForm accepts the start_time field without seconds specified + + The out-of-the-box datetime-local input appears to provide data in this format + """ + test_data = self.shared_test_data.copy() + start_time = datetime(2024, 3, 15, 1, 2, tzinfo=timezone.utc) + test_data["start_time"] = start_time.strftime("%Y-%m-%dT%H:%M") + form = HuntForm(data=test_data) + self.assertTrue(form.is_valid(), msg=form.errors) + hunt = form.save() + self.assertEqual(hunt.start_time, start_time) + + def test_hunt_form_start_time_uses_datetime_local_input(self): + """The HuntForm uses a datetime-local input for the start_time field""" + form = HuntForm(data=self.shared_test_data) + start_time_field = form.fields["start_time"] + self.assertEqual(start_time_field.widget.input_type, "datetime-local") + + def test_hunt_form_accepts_end_time_in_iso_format(self): + """The HuntForm accepts the end_time field in ISO format (YYYY-MM-DDTHH:MM:SS)""" + test_data = self.shared_test_data.copy() + end_time = datetime(2024, 3, 15, 1, 2, tzinfo=timezone.utc) + test_data["end_time"] = end_time.isoformat() + form = HuntForm(data=test_data) + self.assertTrue(form.is_valid(), msg=form.errors) + hunt = form.save() + self.assertEqual(hunt.end_time, end_time) + + def test_hunt_form_accepts_end_time_without_seconds(self): + """The HuntForm accepts the end_time field without seconds specified + + The out-of-the-box datetime-local input appears to provide data in this format + """ + test_data = self.shared_test_data.copy() + end_time = datetime(2024, 3, 15, 1, 2, tzinfo=timezone.utc) + test_data["end_time"] = end_time.strftime("%Y-%m-%dT%H:%M") + form = HuntForm(data=test_data) + self.assertTrue(form.is_valid(), msg=form.errors) + hunt = form.save() + self.assertEqual(hunt.end_time, end_time) + + def test_hunt_form_end_time_displays_datetime_local_widget(self): + """The HuntForm uses a datetime-local input for the end_time field""" + form = HuntForm(data=self.shared_test_data) + end_time_field = form.fields["end_time"] + self.assertEqual(end_time_field.widget.input_type, "datetime-local") diff --git a/myus/myus/views.py b/myus/myus/views.py index b7cbe0e..cc48ea2 100644 --- a/myus/myus/views.py +++ b/myus/myus/views.py @@ -1,20 +1,25 @@ from functools import wraps from typing import Optional -from django.shortcuts import render, redirect, get_object_or_404 +from django import urls +from django.contrib.auth.decorators import login_required +from django.db.models import OuterRef, Sum, Subquery, Count, Q +from django.db.models.functions import Coalesce from django.http import Http404, JsonResponse -from django.db.models import OuterRef, Exists, Sum, Subquery, Count, Max, Q -from django.db.models.functions import Cast, Coalesce +from django.http import HttpResponse +from django.shortcuts import render, redirect, get_object_or_404 from django.template.loader import render_to_string - -from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt -from django.http import HttpResponse -import django.forms as forms -import django.urls as urls - -from .models import Hunt, User, Team, Puzzle, Guess, ExtraGuessGrant +from .forms import ( + GuessForm, + HuntForm, + InviteMemberForm, + PuzzleForm, + RegisterForm, + TeamForm +) +from .models import Hunt, Team, Puzzle, Guess, ExtraGuessGrant def index(request): @@ -29,56 +34,6 @@ def index(request): ) -class MarkdownTextarea(forms.Textarea): - template_name = "widgets/markdown_textarea.html" - - -# based on UserCreationForm from Django source -class RegisterForm(forms.ModelForm): - """ - A form that creates a user, with no privileges, from the given username and - password. - """ - - password1 = forms.CharField(label="Password", widget=forms.PasswordInput) - password2 = forms.CharField( - label="Password confirmation", - widget=forms.PasswordInput, - help_text="Enter the same password as above, for verification.", - ) - email = forms.EmailField( - label="Email address", - required=False, - help_text="Optional, but you'll get useful email notifications when we implement those.", - ) - bio = forms.CharField( - widget=MarkdownTextarea, - required=False, - help_text="(optional) Tell us about yourself. What kinds of puzzle genres or subject matter do you like?", - ) - - class Meta: - model = User - fields = ("username", "email", "display_name", "discord_username", "bio") - - def clean_password2(self): - password1 = self.cleaned_data.get("password1") - password2 = self.cleaned_data.get("password2") - if password1 and password2 and password1 != password2: - raise forms.ValidationError( - "The two password fields didn't match.", - code="password_mismatch", - ) - return password2 - - def save(self, commit=True): - user = super(RegisterForm, self).save(commit=False) - user.set_password(self.cleaned_data["password1"]) - if commit: - user.save() - return user - - def register(request): if request.method == "POST": form = RegisterForm(request.POST) @@ -92,22 +47,6 @@ def register(request): return render(request, "register.html", {"form": form}) -class HuntForm(forms.ModelForm): - description = forms.CharField(widget=MarkdownTextarea, required=False) - - class Meta: - model = Hunt - fields = [ - "name", - "slug", - "description", - "start_time", - "end_time", - "member_limit", - "guess_limit", - ] - - @login_required def new_hunt(request): if request.method == "POST": @@ -256,10 +195,6 @@ def leaderboard(request, hunt_id: int, slug: Optional[str] = None): ) -class GuessForm(forms.Form): - guess = forms.CharField() - - def normalize_answer(answer): return "".join(c for c in answer if c.isalnum()).upper() @@ -371,28 +306,6 @@ def view_puzzle_log(request, hunt_id: int, puzzle_id: int, hunt_slug: Optional[s ) -class TeamForm(forms.ModelForm): - class Meta: - model = Team - fields = ["name"] - - -class InviteMemberForm(forms.Form): - username = forms.CharField() - - def clean(self): - cleaned_data = super().clean() - username = cleaned_data.get("username") - - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise forms.ValidationError("No such user!") - - cleaned_data["user"] = user - return cleaned_data - - @login_required @redirect_from_hunt_id_to_hunt_id_and_slug def my_team(request, hunt_id: int, slug: Optional[str] = None): @@ -480,23 +393,6 @@ def my_team(request, hunt_id: int, slug: Optional[str] = None): ) -class PuzzleForm(forms.ModelForm): - content = forms.CharField(widget=MarkdownTextarea, required=False) - - class Meta: - model = Puzzle - fields = [ - "name", - "slug", - "content", - "answer", - "points", - "order", - "progress_points", - "progress_threshold", - ] - - @redirect_from_hunt_id_to_hunt_id_and_slug @login_required def new_puzzle(request, hunt_id: int, slug: Optional[str] = None):