-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #45 from madjaqk/39_date_time_widgets
#39: Use datetime-local inputs for hunt start/end times
- Loading branch information
Showing
5 changed files
with
278 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -37,5 +37,8 @@ staticfiles | |
# VSCode | ||
\.vscode/ | ||
|
||
# PyCharm | ||
.idea/ | ||
|
||
# Direnv (see https://direnv.net/ ) | ||
.envrc |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,70 +1,178 @@ | ||
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): | ||
self.hunt = Hunt.objects.create(name="Test Hunt", slug="test-hunt") | ||
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") |
Oops, something went wrong.