diff --git a/docker-compose.yml b/docker-compose.yml index 06120065..c90e83d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,10 +20,6 @@ services: app: build: context: . - args: - http_proxy: - https_proxy: - no_proxy: image: django_puzzlehunt # Name image for use in huey restart: always volumes: @@ -33,11 +29,16 @@ services: - ./docker/volumes/logs:/var/log/external environment: - DOMAIN + - SITE_TITLE - DJANGO_SECRET_KEY - DJANGO_ENABLE_DEBUG - DJANGO_EMAIL_USER - DJANGO_EMAIL_PASSWORD + - DJANGO_EMAIL_HOST + - DJANGO_EMAIL_PORT + - DJANGO_EMAIL_FROM - DJANGO_USE_SHIBBOLETH + - PUZZLEHUNT_CHAT_ENABLED - DJANGO_SETTINGS_MODULE=puzzlehunt_server.settings.env_settings - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} - ENABLE_DEBUG_TOOLBAR @@ -60,6 +61,11 @@ services: - DJANGO_SETTINGS_MODULE=puzzlehunt_server.settings.env_settings - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} - SENTRY_DSN + - DJANGO_EMAIL_USER + - DJANGO_EMAIL_PASSWORD + - DJANGO_EMAIL_HOST + - DJANGO_EMAIL_PORT + - DJANGO_EMAIL_FROM depends_on: - app @@ -69,9 +75,6 @@ services: context: ./docker/ dockerfile: apacheDockerfile args: - http_proxy: - https_proxy: - no_proxy: DOMAIN: ${DOMAIN} depends_on: - app @@ -85,4 +88,4 @@ services: volumes: static: - media: \ No newline at end of file + media: diff --git a/docs/setup.rst b/docs/setup.rst index 1ce97eee..b72d91ef 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -16,7 +16,18 @@ following steps to get a sample server up and going uncommented lines. Other lines can be safely ignored as they only provide additional functionality. 5. Run ``docker-compose up`` (possibly using ``sudo`` if needed) -6. You should now have the server running on a newly created VM, accessible via +6. Once up, you'll need to run the following commands to collect all the static + files (to be run any time after you alter the static files), to load in an + initial hunt to pacify some of the display logic (to be run only once), and + to create a new admin user (follow the prompts). + +.. code-block:: bash + + docker-compose exec app python3 /code/manage.py collectstatic --noinput + docker-compose exec app python3 /code/manage.py loaddata initial_hunt + docker-compose exec app python3 /code/manage.py createsuperuser + +7. You should now have the server running on a newly created VM, accessible via (http://localhost). The repository you cloned has been linked into the VM by docker, so any changes made to the repository on the host system should show up automatically. (A ``docker-compose restart`` may diff --git a/huntserver/admin.py b/huntserver/admin.py index 691ec30c..7f332be9 100644 --- a/huntserver/admin.py +++ b/huntserver/admin.py @@ -11,6 +11,7 @@ from django.contrib.flatpages.models import FlatPage from django.contrib.flatpages.forms import FlatpageForm import re +from .utils import get_validation_error, get_puzzle_answer_regex # Register your models here. from . import models @@ -62,6 +63,7 @@ class Meta: class HuntAdmin(admin.ModelAdmin): form = HuntAdminForm inlines = (HintUnlockPLanInline,) + ordering = ['-hunt_number'] fieldsets = ( ('Basic Info', {'fields': ('hunt_name', 'hunt_number', 'team_size', 'location', ('start_date', 'display_start_date'), ('end_date', 'display_end_date'), @@ -102,8 +104,8 @@ def user_username(self, person): class PrepuzzleAdminForm(forms.ModelForm): class Meta: model = models.Prepuzzle - fields = ['puzzle_name', 'released', 'hunt', 'answer', 'resource_file', 'template', - 'response_string'] + fields = ['puzzle_name', 'released', 'hunt', 'answer', 'answer_validation_type', + 'resource_file', 'template', 'response_string'] widgets = { 'template': HtmlEditor(attrs={'style': 'width: 90%; height: 400px;'}), } @@ -111,7 +113,7 @@ class Meta: class PrepuzzleAdmin(admin.ModelAdmin): form = PrepuzzleAdminForm - list_display = ['puzzle_name', 'hunt', 'released'] + list_display = ['puzzle_name', 'hunt', 'released', 'answer_validation_type'] readonly_fields = ('puzzle_url',) # Needed to add request to modelAdmin @@ -180,18 +182,23 @@ def save(self, *args, **kwargs): instance.puzzle_set.add(*self.cleaned_data['reverse_unlocks']) return instance - def clean_answer(self): - data = self.cleaned_data.get('answer') - if(re.fullmatch(r"[a-zA-Z]+", data.upper()) is None): - raise forms.ValidationError("Answer must only contain the characters A-Z.") + def clean(self): + data = self.cleaned_data + answer = data.get('answer') + validation_type = data.get('answer_validation_type') + if(validation_type == models.Puzzle.ANSWER_STRICT): + data['answer'] = answer.upper() + if(re.fullmatch(get_puzzle_answer_regex(validation_type), answer) is None): + self.add_error('answer', forms.ValidationError(get_validation_error(validation_type))) return data class Meta: model = models.Puzzle - fields = ('hunt', 'puzzle_name', 'puzzle_number', 'puzzle_id', 'answer', 'is_meta', - 'doesnt_count', 'puzzle_page_type', 'puzzle_file', 'resource_file', - 'solution_file', 'extra_data', 'num_required_to_unlock', 'unlock_type', - 'points_cost', 'points_value', 'solution_is_webpage', 'solution_resource_file') + fields = ('hunt', 'puzzle_name', 'puzzle_number', 'puzzle_id', 'answer', + 'answer_validation_type', 'puzzle_type', 'puzzle_page_type', 'puzzle_file', + 'resource_file', 'solution_file', 'extra_data', 'num_required_to_unlock', + 'unlock_type', 'points_cost', 'points_value', 'solution_is_webpage', + 'solution_resource_file') class PuzzleAdmin(admin.ModelAdmin): @@ -202,15 +209,15 @@ class Media: list_filter = ('hunt',) search_fields = ['puzzle_id', 'puzzle_name'] - list_display = ['combined_id', 'puzzle_name', 'hunt', 'is_meta'] + list_display = ['combined_id', 'puzzle_name', 'hunt', 'puzzle_type'] list_display_links = ['combined_id', 'puzzle_name'] ordering = ['-hunt', 'puzzle_number'] inlines = (ResponseInline,) radio_fields = {"unlock_type": admin.VERTICAL} fieldsets = ( (None, { - 'fields': ('hunt', 'puzzle_name', 'answer', 'puzzle_number', 'puzzle_id', 'is_meta', - 'doesnt_count', 'puzzle_page_type', 'puzzle_file', 'resource_file', + 'fields': ('hunt', 'puzzle_name', 'answer', 'answer_validation_type', 'puzzle_number', + 'puzzle_id', 'puzzle_type', 'puzzle_page_type', 'puzzle_file', 'resource_file', 'solution_is_webpage', 'solution_file', 'solution_resource_file', 'extra_data', 'unlock_type') }), @@ -233,7 +240,6 @@ def combined_id(self, puzzle): class ResponseAdmin(admin.ModelAdmin): list_display = ['__str__', 'puzzle_just_name'] search_fields = ['regex', 'text'] - ordering = ['-puzzle'] def puzzle_just_name(self, response): return response.puzzle.puzzle_name @@ -265,13 +271,13 @@ class TeamAdminForm(forms.ModelForm): ) ) - num_unlock_points = forms.IntegerField(disabled=True) + num_unlock_points = forms.IntegerField(disabled=True, initial=0) class Meta: model = models.Team - fields = ['team_name', 'hunt', 'location', 'join_code', 'playtester', 'playtest_start_date', - 'playtest_end_date', 'num_available_hints', 'num_unlock_points', 'unlockables', - 'num_unlock_points'] + fields = ['team_name', 'hunt', 'location', 'join_code', 'playtester', 'is_local', + 'playtest_start_date', 'playtest_end_date', 'num_available_hints', + 'num_unlock_points', 'unlockables'] def __init__(self, *args, **kwargs): super(TeamAdminForm, self).__init__(*args, **kwargs) @@ -295,7 +301,7 @@ def save(self, commit=True): class TeamAdmin(admin.ModelAdmin): form = TeamAdminForm search_fields = ['team_name'] - list_display = ['short_team_name', 'location', 'hunt', 'playtester'] + list_display = ['short_team_name', 'hunt', 'is_local', 'playtester'] list_filter = ['hunt'] def short_team_name(self, team): diff --git a/huntserver/forms.py b/huntserver/forms.py index 75bd3cd9..dfe678a4 100644 --- a/huntserver/forms.py +++ b/huntserver/forms.py @@ -1,8 +1,9 @@ from django import forms -from .models import Person +from .models import Person, Puzzle +from .utils import get_puzzle_answer_regex, get_validation_error, strip_puzzle_answer from django.contrib.auth.models import User from crispy_forms.helper import FormHelper -from crispy_forms.layout import Submit, Layout +from crispy_forms.layout import Submit, Layout, Hidden from crispy_forms.bootstrap import StrictButton, InlineField from django.core.exceptions import ValidationError import re @@ -13,6 +14,7 @@ class AnswerForm(forms.Form): def __init__(self, *args, **kwargs): disable_form = kwargs.pop('disable_form', False) + self.validation_type = kwargs.pop('validation_type', Puzzle.ANSWER_STRICT) super(AnswerForm, self).__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_class = 'form-inline' @@ -30,13 +32,15 @@ def __init__(self, *args, **kwargs): StrictButton('Submit', value="submit", type="submit", css_class='btn btn-default') ) - def clean_answer(self): - # Currently the desire is to strip all non A-Z characters (Github issue #129) - new_cleaned_data = re.sub(r"[^A-Z]", "", self.cleaned_data.get('answer').upper()) - if(new_cleaned_data == ""): - raise ValidationError("Submission was empty after stripping non A-Z characters", - code='all_spaces') - return new_cleaned_data + def clean(self): + data = self.cleaned_data + if(self.validation_type == Puzzle.ANSWER_STRICT): + data['answer'] = data.get('answer').upper() + data['answer'] = strip_puzzle_answer(data.get('answer'), self.validation_type) + if(re.fullmatch(get_puzzle_answer_regex(self.validation_type), data.get('answer')) is None): + print("error", get_puzzle_answer_regex(self.validation_type), data.get('answer')) + self.add_error('answer', forms.ValidationError(get_validation_error(self.validation_type))) + return data class SubmissionForm(forms.Form): diff --git a/huntserver/hunt_views.py b/huntserver/hunt_views.py index e1e38cbe..047eab5c 100644 --- a/huntserver/hunt_views.py +++ b/huntserver/hunt_views.py @@ -10,14 +10,15 @@ from django.template.loader import render_to_string from django.utils import timezone from django.utils.encoding import smart_str -from django.db.models import F from django.urls import reverse_lazy, reverse from pathlib import Path +from django.db.models import F, Max, Count, Subquery, OuterRef +from django.db.models.fields import PositiveIntegerField, DateTimeField import json import os import re -from .models import Puzzle, Hunt, Submission, Message, Unlockable, Prepuzzle, Hint +from .models import Puzzle, Hunt, Submission, Message, Unlockable, Prepuzzle, Hint, Solve from .forms import AnswerForm, HintRequestForm import logging @@ -39,6 +40,10 @@ def protected_static(request, file_path): if(len(path.parts) < 2): return HttpResponseNotFound('

Page not found

') + if(base == "puzzle"): + base = "puzzles" + file_path = file_path.replace("puzzle", "puzzles", 1) + if(base == "puzzles" or base == "solutions"): puzzle_id = re.match(r'[0-9a-fA-F]+', path.parts[1]) if(puzzle_id is None): @@ -130,7 +135,9 @@ def prepuzzle(request, prepuzzle_num): puzzle = Prepuzzle.objects.get(pk=prepuzzle_num) if request.method == 'POST': - form = AnswerForm(request.POST) + form = AnswerForm(request.POST , validation_type=puzzle.answer_validation_type) + is_correct = False + response = "" if form.is_valid(): user_answer = re.sub(r"[ _\-;:+,.!?]", "", form.cleaned_data['answer']) @@ -139,19 +146,13 @@ def prepuzzle(request, prepuzzle_num): is_correct = True response = puzzle.response_string logger.info("User %s solved prepuzzle %s." % (str(request.user), prepuzzle_num)) - else: - is_correct = False - response = "" - else: - is_correct = None - response = "" response_vars = {'response': response, 'is_correct': is_correct} return HttpResponse(json.dumps(response_vars)) else: if(not (puzzle.released or request.user.is_staff)): return redirect('huntserver:current_hunt_info') - form = AnswerForm() + form = AnswerForm(validation_type=puzzle.answer_validation_type) context = {'form': form, 'puzzle': puzzle} return HttpResponse(Template(puzzle.template).render(RequestContext(request, context))) @@ -188,39 +189,50 @@ def puzzle_view(request, puzzle_id): team = puzzle.hunt.team_from_user(request.user) if(team is not None): - request.ratelimit_key = team.team_name - - is_ratelimited(request, fn=puzzle_view, key='user', rate='2/10s', method='POST', - increment=True) - if(not puzzle.hunt.is_public): - is_ratelimited(request, fn=puzzle_view, key=get_ratelimit_key, rate='5/m', method='POST', - increment=True) - - if(getattr(request, 'limited', False)): - logger.info("User %s rate-limited for puzzle %s" % (str(request.user), puzzle_id)) - return HttpResponseForbidden() + request.ratelimit_key = puzzle_id + team.team_name + else: + request.ratelimit_key = "" # Dealing with answer submissions, proper procedure is to create a submission # object and then rely on Submission.respond for automatic responses. if request.method == 'POST': - if(team is None): - if(puzzle.hunt.is_public): - team = puzzle.hunt.dummy_team - else: + if(puzzle.hunt.is_public): + team = puzzle.hunt.dummy_team + else: + if(team is None): # If the hunt isn't public and you aren't signed in, please stop... return HttpResponse('fail') - form = AnswerForm(request.POST) + form = AnswerForm(request.POST, validation_type=puzzle.answer_validation_type) form.helper.form_action = reverse('huntserver:puzzle', kwargs={'puzzle_id': puzzle_id}) if form.is_valid(): user_answer = form.cleaned_data['answer'] - s = Submission.objects.create(submission_text=user_answer, team=team, - puzzle=puzzle, submission_time=timezone.now()) + s = Submission(submission_text=user_answer, team=team, + puzzle=puzzle, submission_time=timezone.now()) s.respond() else: s = None + if(puzzle.hunt.is_public): + limited = False + else: + if(s is not None and not s.is_correct and s.response_text == "Wrong Answer."): + limited = is_ratelimited(request, fn=puzzle_view, key=get_ratelimit_key, + rate='3/5m', method='POST', increment=True) + else: + limited = is_ratelimited(request, fn=puzzle_view, key=get_ratelimit_key, + rate='3/5m', method='POST', increment=False) + + if(limited): + logger.info("User %s rate-limited for puzzle %s" % (str(request.user), puzzle_id)) + return HttpResponseForbidden() + + if(s is not None): + s.save() + if(s.is_correct and not puzzle.hunt.is_public): + s.create_solve() + # Deal with answers for public hunts if(puzzle.hunt.is_public): if(s is None): @@ -246,7 +258,7 @@ def puzzle_view(request, puzzle_id): last_date = timezone.now().strftime(DT_FORMAT) # Send back rendered response for display - context = {'submission_list': submission_list, 'last_date': last_date} + context = {'submission_list': submission_list, 'last_date': last_date, 'submission':user_answer} return HttpResponse(json.dumps(context)) # Will return HTML rows for all submissions the user does not yet have @@ -289,14 +301,16 @@ def puzzle_view(request, puzzle_id): else: submissions = None disable_form = False - form = AnswerForm(disable_form=disable_form) + form = AnswerForm(disable_form=disable_form, validation_type=puzzle.answer_validation_type) form.helper.form_action = reverse('huntserver:puzzle', kwargs={'puzzle_id': puzzle_id}) try: last_date = Submission.objects.latest('modified_date').modified_date.strftime(DT_FORMAT) except Submission.DoesNotExist: last_date = timezone.now().strftime(DT_FORMAT) + solve_count = puzzle.solved_for.exclude(playtester=True).count() context = {'form': form, 'submission_list': submissions, 'puzzle': puzzle, - 'PROTECTED_URL': settings.PROTECTED_URL, 'last_date': last_date, 'team': team} + 'PROTECTED_URL': settings.PROTECTED_URL, 'last_date': last_date, + 'team': team, 'solve_count': solve_count} return render(request, 'puzzle.html', context) @@ -417,6 +431,32 @@ def chat(request): return render(request, 'chat.html', context) +@login_required +def leaderboard(request, criteria=""): + curr_hunt = get_object_or_404(Hunt, is_current_hunt=True) + if(criteria == "cmu"): + teams = curr_hunt.real_teams.filter(is_local=True) + else: + teams = curr_hunt.real_teams.all() + teams = teams.exclude(playtester=True) + sq1 = Solve.objects.filter(team__pk=OuterRef('pk'), + puzzle__puzzle_type=Puzzle.META_PUZZLE).order_by() + sq1 = sq1.values('team').annotate(c=Count('*')).values('c') + sq1 = Subquery(sq1, output_field=PositiveIntegerField()) + sq2 = Solve.objects.filter(team__pk=OuterRef('pk'), + puzzle__puzzle_type=Puzzle.FINAL_PUZZLE).order_by() + sq2 = sq2.annotate(last_time=Max('submission__submission_time')).values('last_time') + sq2 = Subquery(sq2, output_field=DateTimeField()) + all_teams = teams.annotate(metas=sq1, finals=sq2, solves=Count('solved')) + all_teams = all_teams.annotate(last_time=Max('solve__submission__submission_time')) + all_teams = all_teams.order_by(F('finals').asc(nulls_last=True), + F('metas').desc(nulls_last=True), + F('solves').desc(nulls_last=True), + F('last_time').asc(nulls_last=True)) + context = {'team_data': all_teams} + return render(request, 'leaderboard.html', context) + + @login_required def chat_status(request): """ diff --git a/huntserver/info_views.py b/huntserver/info_views.py index e01c0bb9..a71b9107 100644 --- a/huntserver/info_views.py +++ b/huntserver/info_views.py @@ -43,7 +43,9 @@ def registration(request): elif(re.match(".*[A-Za-z0-9].*", request.POST.get("team_name"))): join_code = ''.join(random.choice("ACDEFGHJKMNPRSTUVWXYZ2345679") for _ in range(5)) team = Team.objects.create(team_name=request.POST.get("team_name"), hunt=curr_hunt, - location=request.POST.get("need_room"), + # location=request.POST.get("need_room"), + location="remote", + is_local=(request.POST.get("team_is_local") is not None), join_code=join_code) request.user.person.teams.add(team) logger.info("User %s created team %s" % (str(request.user), str(team))) @@ -76,6 +78,13 @@ def registration(request): logger.info("User %s changed the location for team %s from %s to %s" % (str(request.user), str(team.team_name), old_location, team.location)) messages.success(request, "Location successfully updated") + elif(request.POST["form_type"] == "new_affiliation" and team is not None): + old_affiliation = team.is_local + team.is_local = (request.POST.get("team_is_local") is not None) + team.save() + logger.info("User %s changed the affiliation for team %s from %s to %s" % + (str(request.user), str(team.team_name), old_affiliation, team.is_local)) + messages.success(request, "Affiliation successfully updated") elif(request.POST["form_type"] == "new_name" and team is not None and not team.hunt.in_reg_lockdown): if(curr_hunt.team_set.filter(team_name__iexact=request.POST.get("team_name")).exists()): diff --git a/huntserver/migrations/0054_auto_20200318_2145.py b/huntserver/migrations/0054_auto_20200318_2145.py index 905408aa..73ae7484 100644 --- a/huntserver/migrations/0054_auto_20200318_2145.py +++ b/huntserver/migrations/0054_auto_20200318_2145.py @@ -12,7 +12,7 @@ def setup_pages(apps, schema_editor): try: our_site = Site.objects.get(pk=1) except: - our_site = Site.objects.create(domain=settings.DOMAIN, name=settings.DOMAIN) + our_site = Site.objects.create(domain=settings.DOMAIN, name=settings.SITE_TITLE) source = get_template("contact_us.html").template.source.split(" -->")[1] fp = FlatPage.objects.create(url='/contact-us/', title='Contact Us', content=source) diff --git a/huntserver/migrations/0073_auto_20210207_2300.py b/huntserver/migrations/0073_auto_20210207_2300.py new file mode 100644 index 00000000..c0e0059b --- /dev/null +++ b/huntserver/migrations/0073_auto_20210207_2300.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.13 on 2021-02-08 04:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0072_auto_20201103_1539'), + ] + + operations = [ + migrations.AddField( + model_name='puzzle', + name='puzzle_type', + field=models.CharField(choices=[('STD', 'A standard puzzle'), ('MET', 'A meta puzzle'), ('NON', 'An unscored puzzle')], default='STD', help_text='The type of puzzle.', max_length=3), + ), + migrations.AlterField( + model_name='puzzle', + name='puzzle_page_type', + field=models.CharField(choices=[('PDF', 'Puzzle page displays a PDF'), ('LNK', 'Puzzle page links a webpage'), ('WEB', 'Puzzle page displays a webpage'), ('EMB', 'Puzzle is html embedded in the webpage')], default='WEB', help_text='The type of webpage for this puzzle.', max_length=3), + ), + ] diff --git a/huntserver/migrations/0074_auto_20210207_2304.py b/huntserver/migrations/0074_auto_20210207_2304.py new file mode 100644 index 00000000..a0f16bde --- /dev/null +++ b/huntserver/migrations/0074_auto_20210207_2304.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.13 on 2021-02-08 04:04 + +from django.db import migrations + + +def set_puzzle_type(apps, schema_editor): + Puzzle = apps.get_model('huntserver', 'Puzzle') + for puzzle in Puzzle.objects.all().iterator(): + if(puzzle.is_meta): + puzzle.puzzle_type = "MET" + elif(puzzle.doesnt_count): + puzzle.puzzle_type = "NON" + else: + puzzle.puzzle_type = "STD" + puzzle.save() + + +def reverse_func(apps, schema_editor): + pass # code for reverting migration, if any + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0073_auto_20210207_2300'), + ] + + operations = [ + migrations.RunPython(set_puzzle_type, reverse_func) + ] diff --git a/huntserver/migrations/0075_auto_20210208_0939.py b/huntserver/migrations/0075_auto_20210208_0939.py new file mode 100644 index 00000000..c449da60 --- /dev/null +++ b/huntserver/migrations/0075_auto_20210208_0939.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.13 on 2021-02-08 14:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0074_auto_20210207_2304'), + ] + + operations = [ + migrations.RemoveField( + model_name='puzzle', + name='doesnt_count', + ), + migrations.RemoveField( + model_name='puzzle', + name='is_meta', + ), + migrations.AlterField( + model_name='puzzle', + name='puzzle_type', + field=models.CharField(choices=[('STD', 'Standard'), ('MET', 'Meta'), ('FIN', 'Final'), ('NON', 'Non-puzzle')], default='STD', help_text='The type of puzzle.', max_length=3), + ), + ] diff --git a/huntserver/migrations/0076_team_is_local.py b/huntserver/migrations/0076_team_is_local.py new file mode 100644 index 00000000..fe878168 --- /dev/null +++ b/huntserver/migrations/0076_team_is_local.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2021-02-10 21:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0075_auto_20210208_0939'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='is_local', + field=models.BooleanField(default=False, help_text='Is this team from CMU (or your organization)'), + ), + ] diff --git a/huntserver/migrations/0077_hint_responder.py b/huntserver/migrations/0077_hint_responder.py new file mode 100644 index 00000000..8a3395ef --- /dev/null +++ b/huntserver/migrations/0077_hint_responder.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.13 on 2021-03-26 00:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0076_team_is_local'), + ] + + operations = [ + migrations.AddField( + model_name='hint', + name='responder', + field=models.ForeignKey(blank=True, help_text='Staff member that has claimed the hint.', null=True, on_delete=django.db.models.deletion.CASCADE, to='huntserver.Person'), + ), + ] diff --git a/huntserver/migrations/0078_auto_20220205_2106.py b/huntserver/migrations/0078_auto_20220205_2106.py new file mode 100644 index 00000000..7a9b95ff --- /dev/null +++ b/huntserver/migrations/0078_auto_20220205_2106.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.13 on 2022-02-06 02:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('huntserver', '0077_hint_responder'), + ] + + operations = [ + migrations.AddField( + model_name='prepuzzle', + name='answer_validation_type', + field=models.CharField(choices=[('STR', 'Strict: Only uppercase A-Z'), ('ACA', 'Case sensitive, no spaces'), ('CAS', 'Case sensitive, spaces allowed'), ('ANY', 'Anything: Full unicode, any case')], default='STR', help_text='The type of answer validation used for this puzzle.', max_length=3), + ), + migrations.AddField( + model_name='puzzle', + name='answer_validation_type', + field=models.CharField(choices=[('STR', 'Strict: Only uppercase A-Z'), ('ACA', 'Case sensitive, no spaces'), ('CAS', 'Case sensitive, spaces allowed'), ('ANY', 'Anything: Full unicode, any case')], default='STR', help_text='The type of answer validation used for this puzzle.', max_length=3), + ), + migrations.AlterField( + model_name='puzzle', + name='puzzle_page_type', + field=models.CharField(choices=[('PDF', 'Puzzle page displays a PDF'), ('LNK', 'Puzzle page links a webpage'), ('WEB', 'Puzzle page displays a webpage'), ('EMB', 'Puzzle is html embedded in the webpage')], default='EMB', help_text='The type of webpage for this puzzle.', max_length=3), + ), + migrations.AlterField( + model_name='puzzle', + name='solution_is_webpage', + field=models.BooleanField(default=True, help_text='Is this solution an html webpage?'), + ), + ] diff --git a/huntserver/models.py b/huntserver/models.py index d7fc8706..d8a4b01b 100644 --- a/huntserver/models.py +++ b/huntserver/models.py @@ -127,6 +127,15 @@ def save(self, *args, **kwargs): self.full_clean() if self.is_current_hunt: Hunt.objects.filter(is_current_hunt=True).update(is_current_hunt=False) + if(self.resource_file.name == "" and self.pk): + old_obj = Hunt.objects.get(pk=self.pk) + if(old_obj.resource_file.name != ""): + extension = old_obj.resource_file.name.split('.')[-1] + folder = "".join(old_obj.resource_file.name.split('.')[:-1]) + if(extension == "zip"): + shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) + if os.path.exists(os.path.join(settings.MEDIA_ROOT, old_obj.resource_file.name)): + os.remove(os.path.join(settings.MEDIA_ROOT, old_obj.resource_file.name)) super(Hunt, self).save(*args, **kwargs) @property @@ -218,11 +227,37 @@ class Meta: PDF_PUZZLE = 'PDF' LINK_PUZZLE = 'LNK' WEB_PUZZLE = 'WEB' + EMBED_PUZZLE = 'EMB' puzzle_page_type_choices = [ (PDF_PUZZLE, 'Puzzle page displays a PDF'), (LINK_PUZZLE, 'Puzzle page links a webpage'), (WEB_PUZZLE, 'Puzzle page displays a webpage'), + (EMBED_PUZZLE, 'Puzzle is html embedded in the webpage'), + ] + + STANDARD_PUZZLE = 'STD' + META_PUZZLE = 'MET' + FINAL_PUZZLE = 'FIN' + NON_PUZZLE = 'NON' + + puzzle_type_choices = [ + (STANDARD_PUZZLE, 'Standard'), + (META_PUZZLE, 'Meta'), + (FINAL_PUZZLE, 'Final'), + (NON_PUZZLE, 'Non-puzzle'), + ] + + ANSWER_STRICT = 'STR' + ANSWER_ANY_CASE = 'ACA' + ANSWER_CASE_AND_SPACES = 'CAS' + ANSWER_ANYTHING = "ANY" + + answer_validation_choices = [ + (ANSWER_STRICT, 'Strict: Only uppercase A-Z'), + (ANSWER_ANY_CASE, 'Case sensitive, no spaces'), + (ANSWER_CASE_AND_SPACES, 'Case sensitive, spaces allowed'), + (ANSWER_ANYTHING, 'Anything: Full unicode, any case'), ] hunt = models.ForeignKey( @@ -241,20 +276,20 @@ class Meta: answer = models.CharField( max_length=100, help_text="The answer to the puzzle, not case sensitive") - is_meta = models.BooleanField( - default=False, - verbose_name="Is a metapuzzle", - help_text="Is this puzzle a meta-puzzle?") + puzzle_type = models.CharField( + max_length=3, + choices=puzzle_type_choices, + default=STANDARD_PUZZLE, + blank=False, + help_text="The type of puzzle." + ) puzzle_page_type = models.CharField( max_length=3, choices=puzzle_page_type_choices, - default=WEB_PUZZLE, + default=EMBED_PUZZLE, blank=False, help_text="The type of webpage for this puzzle." ) - doesnt_count = models.BooleanField( - default=False, - help_text="Should this puzzle not count towards scoring?") puzzle_file = models.FileField( upload_to=get_puzzle_file_path, storage=PuzzleOverwriteStorage(), @@ -266,7 +301,7 @@ class Meta: blank=True, help_text="Puzzle resources, MUST BE A ZIP FILE.") solution_is_webpage = models.BooleanField( - default=False, + default=True, help_text="Is this solution an html webpage?") solution_file = models.FileField( upload_to=get_solution_file_path, @@ -282,6 +317,13 @@ class Meta: max_length=200, blank=True, help_text="A misc. field for any extra data to be stored with the puzzle.") + answer_validation_type = models.CharField( + max_length=3, + choices=answer_validation_choices, + default=ANSWER_STRICT, + blank=False, + help_text="The type of answer validation used for this puzzle." + ) # Unlocking: unlock_type = models.CharField( @@ -306,47 +348,39 @@ class Meta: default=0, help_text="The number of points this puzzle grants upon solving.") + + # Overridden to delete old files on clear def save(self, *args, **kwargs): + check_attrs = ["puzzle_file", "resource_file", "solution_file", "solution_resource_file"] if(self.pk): - # TODO: Clean up this repetitive code old_obj = Puzzle.objects.get(pk=self.pk) - if(self.puzzle_file.name == "" and old_obj.puzzle_file.name != ""): - full_name = os.path.join(settings.MEDIA_ROOT, old_obj.puzzle_file.name) - extension = old_obj.puzzle_file.name.split('.')[-1] - folder = "".join(old_obj.puzzle_file.name.split('.')[:-1]) - if(extension == "zip"): - shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) - if os.path.exists(full_name): - os.remove(full_name) - if(self.resource_file.name == "" and old_obj.resource_file.name != ""): - full_name = os.path.join(settings.MEDIA_ROOT, old_obj.resource_file.name) - extension = old_obj.resource_file.name.split('.')[-1] - folder = "".join(old_obj.resource_file.name.split('.')[:-1]) - if(extension == "zip"): - shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) - if os.path.exists(full_name): - os.remove(full_name) - if(self.solution_file.name == "" and old_obj.solution_file.name != ""): - full_name = os.path.join(settings.MEDIA_ROOT, old_obj.solution_file.name) - extension = old_obj.solution_file.name.split('.')[-1] - folder = "".join(old_obj.solution_file.name.split('.')[:-1]) - if(extension == "zip"): - shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) - if os.path.exists(full_name): - os.remove(full_name) - old_name = old_obj.solution_resource_file.name - if(self.solution_resource_file.name == "" and old_name != ""): - full_name = os.path.join(settings.MEDIA_ROOT, old_obj.solution_resource_file.name) - extension = old_obj.solution_resource_file.name.split('.')[-1] - folder = "".join(old_obj.solution_resource_file.name.split('.')[:-1]) - if(extension == "zip"): - shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) - if os.path.exists(full_name): - os.remove(full_name) + for attr in check_attrs: + file = getattr(self, attr) + old_file = getattr(old_obj, attr) + + if(file.name == "" and old_file.name != ""): + full_name = os.path.join(settings.MEDIA_ROOT, old_file.name) + extension = old_file.name.split('.')[-1] + folder = "".join(old_file.name.split('.')[:-1]) + if(extension == "zip"): + shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True) + if os.path.exists(full_name): + os.remove(full_name) super(Puzzle, self).save(*args, **kwargs) + def clean(self): + if not (self.puzzle_file.name.lower().endswith(".pdf") or self.puzzle_file.name == ""): + raise ValidationError('Puzzle files must be a .pdf file') + if not (self.solution_file.name.lower().endswith(".pdf") or self.solution_file.name == ""): + raise ValidationError('Solution files must be a .pdf file') + if not (self.resource_file.name.lower().endswith(".zip") or self.resource_file.name == ""): + raise ValidationError('Resource files must be a .zip file') + if not (self.solution_resource_file.name.lower().endswith(".zip") or + self.solution_resource_file.name == ""): + raise ValidationError('Solution resource files must be a .zip file') + def serialize_for_ajax(self): """ Serializes the ID, puzzle_number and puzzle_name fields for ajax transmission """ message = dict() @@ -368,6 +402,18 @@ def __str__(self): class Prepuzzle(models.Model): """ A class representing a pre-puzzle within a hunt """ + ANSWER_STRICT = 'STR' + ANSWER_ANY_CASE = 'ACA' + ANSWER_CASE_AND_SPACES = 'CAS' + ANSWER_ANYTHING = "ANY" + + answer_validation_choices = [ + (ANSWER_STRICT, 'Strict: Only uppercase A-Z'), + (ANSWER_ANY_CASE, 'Case sensitive, no spaces'), + (ANSWER_CASE_AND_SPACES, 'Case sensitive, spaces allowed'), + (ANSWER_ANYTHING, 'Anything: Full unicode, any case'), + ] + puzzle_name = models.CharField( max_length=200, help_text="The name of the puzzle as it will be seen by hunt participants") @@ -394,6 +440,13 @@ class Prepuzzle(models.Model): response_string = models.TextField( default="", help_text="Data returned to the webpage for use upon solving.") + answer_validation_type = models.CharField( + max_length=3, + choices=answer_validation_choices, + default=ANSWER_STRICT, + blank=False, + help_text="The type of answer validation used for this puzzle." + ) def __str__(self): if(self.hunt): @@ -403,7 +456,7 @@ def __str__(self): # Overridden to delete old files on clear def save(self, *args, **kwargs): - if(self.resource_file.name == ""): + if(self.pk and self.resource_file.name == ""): old_obj = Prepuzzle.objects.get(pk=self.pk) if(old_obj.resource_file.name != ""): extension = old_obj.resource_file.name.split('.')[-1] @@ -456,6 +509,9 @@ class Team(models.Model): max_length=80, blank=True, help_text="The physical location that the team is solving at") + is_local = models.BooleanField( + default=False, + help_text="Is this team from CMU (or your organization)") join_code = models.CharField( max_length=5, help_text="The 5 character random alphanumeric password needed for a user to join a team") @@ -648,6 +704,14 @@ def __str__(self): else: return name + @property + def full_name(self): + name = self.user.first_name + " " + self.user.last_name + if(name == " "): + return "Anonymous User" + else: + return name + @property def formatted_phone_number(self): match = re.match("(?:\\+?1 ?-?)?\\(?([0-9]{3})\\)?-? ?([0-9]{3})-? ?([0-9]{4})", self.phone) @@ -682,7 +746,7 @@ def serialize_for_ajax(self): """ Serializes the time, puzzle, team, and status fields for ajax transmission """ message = dict() df = DateFormat(self.submission_time.astimezone(time_zone)) - message['time_str'] = df.format("h:i a") + message['time_str'] = df.format("m/d") + "
" + df.format("h:i A") message['puzzle'] = self.puzzle.serialize_for_ajax() message['team_pk'] = self.team.pk message['status_type'] = "submission" @@ -691,7 +755,10 @@ def serialize_for_ajax(self): @property def is_correct(self): """ A boolean indicating if the submission given is exactly correct """ - return self.submission_text.upper() == self.puzzle.answer.upper() + if(self.puzzle.answer_validation_type == Puzzle.ANSWER_STRICT): + return self.submission_text.upper() == self.puzzle.answer.upper() + else: + return self.submission_text == self.puzzle.answer @property def convert_markdown_response(self): @@ -705,28 +772,25 @@ def save(self, *args, **kwargs): def create_solve(self): """ Creates a solve based on this submission """ - Solve.objects.create(puzzle=self.puzzle, team=self.team, submission=self) - logger.info("Team %s correctly solved puzzle %s" % (str(self.team.team_name), - str(self.puzzle.puzzle_id))) + + # Make sure we don't have duplicate submission objects + if(self.puzzle not in self.team.solved.all()): + Solve.objects.create(puzzle=self.puzzle, team=self.team, submission=self) + logger.info("Team %s correctly solved puzzle %s" % (str(self.team.team_name), + str(self.puzzle.puzzle_id))) + t = self.team + t.num_unlock_points = models.F('num_unlock_points') + self.puzzle.points_value + t.save() + t.refresh_from_db() + t.unlock_puzzles() + t.unlock_hints() # The one and only place to call unlock hints # Automatic submission response system # Returning an empty string means that huntstaff should respond via the queue # Order of response importance: Regex, Defaults, Staff response. def respond(self): """ Takes the submission's text and uses various methods to craft and populate a response. - If the response is correct a solve is created and the correct puzzles are unlocked """ - # Compare against correct answer - if(self.is_correct): - # Make sure we don't have duplicate or after hunt submission objects - if(not self.puzzle.hunt.is_public): - if(self.puzzle not in self.team.solved.all()): - self.create_solve() - t = self.team - t.num_unlock_points = models.F('num_unlock_points') + self.puzzle.points_value - t.save() - t.refresh_from_db() - t.unlock_puzzles() - t.unlock_hints() # The one and only place to call unlock hints + If the response is correct, a solve is created and the correct puzzles are unlocked """ # Check against regexes for resp in self.puzzle.response_set.all(): @@ -744,7 +808,6 @@ def respond(self): str(self.puzzle.puzzle_id))) self.response_text = response - self.save() def update_response(self, text): """ Updates the response with the given text """ @@ -784,7 +847,7 @@ def serialize_for_ajax(self): message['team_pk'] = self.team.pk time = self.submission.submission_time df = DateFormat(time.astimezone(time_zone)) - message['time_str'] = df.format("h:i a") + message['time_str'] = df.format("m/d") + "
" + df.format("h:i A") message['status_type'] = "solve" return message @@ -915,6 +978,27 @@ class Hint(models.Model): help_text="Hint response time") last_modified_time = models.DateTimeField( help_text="Last time of modification") + responder = models.ForeignKey( + Person, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text="Staff member that has claimed the hint.") + + @property + def answered(self): + """ A boolean indicating if the hint has been answered """ + return self.response != "" + + @property + def status(self): + """ A string indicating the status of the hint """ + if(self.answered): + return "answered" + elif(self.responder): + return "claimed" + else: + return "unclaimed" def __str__(self): return (self.team.short_name + ": " + self.puzzle.puzzle_name + diff --git a/huntserver/staff_views.py b/huntserver/staff_views.py index b465158f..d7c4d14e 100644 --- a/huntserver/staff_views.py +++ b/huntserver/staff_views.py @@ -3,9 +3,8 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib import admin from django.core.exceptions import ObjectDoesNotExist -from django.core.mail import EmailMessage from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.http import HttpResponse, HttpResponseNotFound, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseNotFound from django.shortcuts import render, redirect from django.template.loader import render_to_string from django.utils import timezone @@ -20,6 +19,7 @@ from .models import Submission, Hunt, Team, Puzzle, Unlock, Solve, Message, Prepuzzle, Hint, Person from .forms import SubmissionForm, UnlockForm, EmailForm, HintResponseForm, LookupForm +from .utils import send_mass_email DT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' @@ -352,7 +352,7 @@ def charts(request): 'puzzle__puzzle_name', 'team__team_name', 'submission__submission_time') - results = list(results.annotate(Count('puzzle__solve')).order_by('puzzle__puzzle_id')) + results = list(results.annotate(Count('puzzle__solve')).order_by('puzzle__puzzle_number')) context = {'data1_list': puzzle_info_dict1, 'data2_list': puzzle_info_dict2, 'data3_list': submission_hours, 'data4_list': solve_hours, @@ -528,16 +528,27 @@ def staff_hints_text(request): and render the hint page. Hints are pre-rendered for standard and AJAX requests. """ + claim_failed = False if request.method == 'POST': - form = HintResponseForm(request.POST) - if not form.is_valid(): - return HttpResponse(status=400) - h = Hint.objects.get(pk=form.cleaned_data['hint_id']) - h.response = form.cleaned_data['response'] - h.response_time = timezone.now() - h.last_modified_time = timezone.now() - h.save() - hints = [h] + if("claim" in request.POST and "hint_id" in request.POST): + h = Hint.objects.get(pk=request.POST.get("hint_id")) + if(not h.responder): + h.responder = request.user.person + h.last_modified_time = timezone.now() + h.save() + else: + claim_failed = True + hints = [h] + else: + form = HintResponseForm(request.POST) + if not form.is_valid(): + return HttpResponse(status=400) + h = Hint.objects.get(pk=form.cleaned_data['hint_id']) + h.response = form.cleaned_data['response'] + h.response_time = timezone.now() + h.last_modified_time = timezone.now() + h.save() + hints = [h] else: team_id = request.GET.get("team_id") hint_status = request.GET.get("hint_status") @@ -556,8 +567,10 @@ def staff_hints_text(request): if(hint_status): if(hint_status == "answered"): hints = hints.exclude(response="") - elif(hint_status == "unanswered"): - hints = hints.filter(response="") + elif(hint_status == "claimed"): + hints = hints.exclude(responder=None).filter(response="") + elif(hint_status == "unclaimed"): + hints = hints.filter(responder=None).filter(response="") arg_string = arg_string + ("&hint_status=%s" % hint_status) if(request.is_ajax()): last_date = datetime.strptime(request.GET.get("last_date"), DT_FORMAT) @@ -586,7 +599,7 @@ def staff_hints_text(request): request=request)) if request.is_ajax() or request.method == 'POST': - context = {'hint_list': hint_list, 'last_date': last_date} + context = {'hint_list': hint_list, 'last_date': last_date, 'claim_failed': claim_failed} return HttpResponse(json.dumps(context)) else: form = HintResponseForm() @@ -602,24 +615,31 @@ def staff_hints_control(request): A view to handle the incrementing, decrementing, and updating the team hint counts on the hints staff page. """ + hunt = Hunt.objects.get(is_current_hunt=True) if request.is_ajax(): if("action" in request.POST and "value" in request.POST and "team_pk" in request.POST): if(request.POST.get("action") == "update"): try: update_value = int(request.POST.get("value")) - team_pk = int(request.POST.get("team_pk")) - team = Team.objects.get(pk=team_pk) - if(team.num_available_hints + update_value >= 0): - team.num_available_hints = F('num_available_hints') + update_value - team.save() + team_pk = request.POST.get("team_pk") + if(team_pk == "all_teams"): + for team in hunt.team_set.all(): + if(team.num_available_hints + update_value >= 0): + team.num_available_hints = F('num_available_hints') + update_value + team.save() + else: + team_pk = int(team_pk) + team = Team.objects.get(pk=team_pk) + if(team.num_available_hints + update_value >= 0): + team.num_available_hints = F('num_available_hints') + update_value + team.save() except ValueError: pass # Maybe a 4XX or 5XX in the future else: return HttpResponse("Incorrect usage of hint control page") - hunt = Hunt.objects.get(is_current_hunt=True) return HttpResponse(json.dumps(list(hunt.team_set.values_list('pk', 'num_available_hints')))) @@ -641,14 +661,11 @@ def emails(request): if email_form.is_valid(): subject = email_form.cleaned_data['subject'] message = email_form.cleaned_data['message'] - email_to_chunks = [email_list[x: x + 80] for x in range(0, len(email_list), 80)] - for to_chunk in email_to_chunks: - email = EmailMessage(subject, message, 'puzzlehuntcmu@gmail.com', [], to_chunk) - email.send() - return HttpResponseRedirect('') + task_id = send_mass_email(email_list, subject, message) + return HttpResponse(task_id.id) else: email_form = EmailForm() - context = {'email_list': (', ').join(email_list), 'email_form': email_form} + context = {'email_list': ('
').join(email_list), 'email_form': email_form} return render(request, 'email.html', add_apps_to_context(context, request)) @@ -673,7 +690,8 @@ def lookup(request): team = Team.objects.get(pk=request.GET.get("team_pk")) team.latest_submissions = team.submission_set.values_list('puzzle') team.latest_submissions = team.latest_submissions.annotate(Max('submission_time')) - sq1 = Solve.objects.filter(team__pk=OuterRef('pk'), puzzle__is_meta=True).order_by() + sq1 = Solve.objects.filter(team__pk=OuterRef('pk'), + puzzle__puzzle_type=Puzzle.META_PUZZLE).order_by() sq1 = sq1.values('team').annotate(c=Count('*')).values('c') sq1 = Subquery(sq1, output_field=PositiveIntegerField()) all_teams = team.hunt.team_set.annotate(metas=sq1, solves=Count('solved')) diff --git a/huntserver/static/huntserver/admin.css b/huntserver/static/huntserver/admin.css index a639be27..3f255093 100644 --- a/huntserver/static/huntserver/admin.css +++ b/huntserver/static/huntserver/admin.css @@ -172,4 +172,11 @@ select { height:inherit; margin: 0 auto; pointer-events: all; +} +.claimed table.table { + background-color: lightpink; +} + +.answered table.table { + background-color: lightgrey; } \ No newline at end of file diff --git a/huntserver/static/huntserver/hunt_base.css b/huntserver/static/huntserver/hunt_base.css index b0db0001..c45345b0 100644 --- a/huntserver/static/huntserver/hunt_base.css +++ b/huntserver/static/huntserver/hunt_base.css @@ -14,8 +14,8 @@ body { font-size: 14px; padding: 10px; padding-top: 0px; - margin-right: 10px; - margin-bottom: 10px; + margin: 10px; + margin-top: 0px; background: white; border: 3px solid black; } @@ -39,6 +39,14 @@ th { overflow: auto; } +.puzzle-title { + display: inline; +} + +.title-number,.title-name { + display: inline-block; +} + .leftinfo { float: left; } @@ -78,4 +86,4 @@ th { width: auto; vertical-align: middle; } -} \ No newline at end of file +} diff --git a/huntserver/static/huntserver/puzzle.js b/huntserver/static/huntserver/puzzle.js index 093e6978..b421cee6 100644 --- a/huntserver/static/huntserver/puzzle.js +++ b/huntserver/static/huntserver/puzzle.js @@ -68,19 +68,12 @@ jQuery(document).ready(function($) { $(this).removeClass("has-warning"); // Check for invalid answers: - var non_alphabetical = /[^a-zA-Z \-_]/; - if(non_alphabetical.test($(this).find(":text").val())) { - $(this).append("" + - "Answers will only contain the letters A-Z."); - $(this).addClass("has-error"); - return; - } - var spacing = /[ \-_]/; - if(spacing.test($(this).find(":text").val())) { - $(this).append("" + - "Spacing characters are automatically removed from responses."); - $(this).addClass("has-warning"); + answer = $(this).find(":text").val(); + + if(upper_case) { + answer = answer.toUpperCase(); } + $.ajax({ url : $(this).attr('action') || window.location.pathname, type: "POST", @@ -92,6 +85,9 @@ jQuery(document).ready(function($) { } else { var response = JSON.parse(jXHR.responseText); if("answer" in response && "message" in response["answer"][0]) { + $("#sub_form").append("" + + response["answer"][0]["message"] + ""); + $("#sub_form").addClass("has-warning"); console.log(response["answer"][0]["message"]); } } @@ -101,6 +97,11 @@ jQuery(document).ready(function($) { ajax_delay = 3; ajax_timeout = setTimeout(get_posts, ajax_delay*1000); response = JSON.parse(response); + if(response.submission != answer) { + $("#sub_form").append("" + + "Submission was automatically filtered to only valid characters."); + $("#sub_form").addClass("has-warning"); + } receiveMessage(response.submission_list[0]); } }); diff --git a/huntserver/static/huntserver/staff_hints.js b/huntserver/static/huntserver/staff_hints.js index 5e28453c..7fe3660d 100644 --- a/huntserver/static/huntserver/staff_hints.js +++ b/huntserver/static/huntserver/staff_hints.js @@ -17,6 +17,12 @@ $(document).ready(function() { e.preventDefault(); team = $(this).attr('data-team'); value = $(this).attr('data-value'); + if(team == "all_teams"){ + var r = confirm("Please confirm you want to add/remove a hint for all teams."); + if (r != true) { + return + } + } $.post("/staff/hints/control/", {action:'update', 'team_pk': team, value: value, csrfmiddlewaretoken: csrf_token}, function( data ) { @@ -65,6 +71,7 @@ $(document).ready(function() { } $('.sub_form').on('submit', formListener); }; + $('.claim-btn').on('click', claimListener); last_date = response.last_date; } }, @@ -88,7 +95,7 @@ $(document).ready(function() { } }); } - setInterval(get_posts, 30000); + setInterval(get_posts, 10000); function formListener(e) { e.preventDefault(); @@ -109,16 +116,43 @@ $(document).ready(function() { $('#formModal').modal('hide'); } + function claimListener(e) { + var hint_id = $(this).data('id') + $.ajax({ + url : window.location.pathname, + type: "POST", + data: {"claim": true, "hint_id": $(this).data('id'), csrfmiddlewaretoken: csrf_token}, + error: function (jXHR, textStatus, errorThrown) { + console.log(jXHR.responseText); + }, + success: function (response) { + response = JSON.parse(response); + $("[data-id=" + hint_id + "]").replaceWith($(response.hint_list[0])) + last_date = response.last_date; + if(response.claim_failed) { + $('#formModal').modal('hide'); + } + } + }); + } + + $('.claim-btn').on('click', claimListener); + $('#formModal').on('show.bs.modal', function (event) { + var regex = //gi; var button = $(event.relatedTarget); var modal = $(this); var hint_request = button.parent().parent().parent(); var title = hint_request.find(".hint-title"); var response = hint_request.find(".hint-response"); var outer_row = hint_request.parent().parent().parent(); - modal.find('.modal-title').html(title.html()); - modal.find('.modal-body #modal-hint-text').text(hint_request.find(".hint-text").text()); - modal.find('.modal-body #id_response').val(response.text()); + if(outer_row.data("status") == 'claimed' && outer_row.data("owner") != staff_user) { + modal.find('.modal-title').html("WARNING: RESPONDING TO OTHER USER'S CLAIMED HINT
" + title.html()); + } else { + modal.find('.modal-title').html(title.html()); + } + modal.find('.modal-body #modal-hint-text').html(hint_request.find(".hint-text").html()); + modal.find('.modal-body #id_response').val(response.html().replace(regex, "\n")); modal.find('.modal-body #id_hint_id').val(outer_row.data("id")); }) diff --git a/huntserver/templates/access_error.html b/huntserver/templates/access_error.html index ab947e80..b7087807 100644 --- a/huntserver/templates/access_error.html +++ b/huntserver/templates/access_error.html @@ -34,7 +34,7 @@

Not Available

{% endif %}
-

For assistance, email puzzlehunt staff..

+

For assistance, email puzzlehunt staff.

{% endblock content %} diff --git a/huntserver/templates/create_account.html b/huntserver/templates/create_account.html index 05e4ed61..7d2f57b9 100644 --- a/huntserver/templates/create_account.html +++ b/huntserver/templates/create_account.html @@ -9,6 +9,7 @@ {% block content %}

Registration

+

None of the information collected here will be displayed publicly.

{% csrf_token %} {{uf|crispy}} diff --git a/huntserver/templates/email.html b/huntserver/templates/email.html index 7e72657d..4035dd91 100644 --- a/huntserver/templates/email.html +++ b/huntserver/templates/email.html @@ -15,12 +15,83 @@ height: 300px; } + + {% endblock includes %} {% block content %} + +

Email

Send email to all hunt competitors:

- + {% csrf_token %} {{ email_form|crispy }}
@@ -34,6 +105,6 @@

Send email to all hunt competitors:

- {{email_list}} + {{email_list|safe}}
{% endblock content %} diff --git a/huntserver/templates/hint_row.html b/huntserver/templates/hint_row.html index 716f60b7..5125ed62 100644 --- a/huntserver/templates/hint_row.html +++ b/huntserver/templates/hint_row.html @@ -1,4 +1,4 @@ - + @@ -16,7 +16,7 @@
- {{ hint.request }} + {{ hint.request|linebreaksbr }}
@@ -26,24 +26,40 @@ {% if hint.response %} {% else %} - + {% endif %} {% else %} {% if hint.response %} - + {% else %} diff --git a/huntserver/templates/hunt_base.html b/huntserver/templates/hunt_base.html index 70c85309..b9a484ac 100644 --- a/huntserver/templates/hunt_base.html +++ b/huntserver/templates/hunt_base.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% load bootstrap_tags %} +{% load hunt_tags %} {% block base_includes %} @@ -10,7 +11,8 @@
  • Puzzles
  • - {% if not tmpl_hunt.is_public %} + {% chat_enabled as chat_enabled_bool %} + {% if not tmpl_hunt.is_public and chat_enabled_bool %}
  • @@ -29,6 +31,9 @@
  • {% endif %}
  • Hunt Info
  • +
  • + Leaderboard +
  • {% endblock %} {% block content_wrapper %} diff --git a/huntserver/templates/leaderboard.html b/huntserver/templates/leaderboard.html new file mode 100644 index 00000000..45b318ca --- /dev/null +++ b/huntserver/templates/leaderboard.html @@ -0,0 +1,49 @@ +{% extends "hunt_base.html" %} +{% block title %} Team Leaderboard {% endblock title %} + +{% block includes %} + + +{% endblock includes %} + +{% block content %} +
    +
    +
    +

    Team Leaderboard

    +
    + +
    +
    {{ hint.response_time|time:"h:i a" }} -
    {{ hint.response }}
    +
    {{ hint.response|linebreaksbr }}
    + {% if hint.responder %} + CLAIMED BY
    {{hint.responder.full_name}}
    + {% endif %} +
    + {% if not hint.responder %} +
    + + {% elif hint.responder.user == request.user %}
    - + {% else %} +
    + + {% endif %}
    {{ hint.response_time|time:"h:i a" }}{{ hint.response }}{{ hint.response|linebreaksbr }} No response yet.
    + + + + + + + + + {% for team in team_data %} + + + + + + + + + {% endfor %} +
    RankTeamFinish TimeMeta SolvesSolvesLast Solve Time
    {{forloop.counter}}{{team.team_name|truncatechars:30}}{{team.finals|date:"M dS, h:i a" }} + {% if team.metas %} + {{team.metas}} + {% else %} + 0 + {% endif %} + {{team.solves}}{{team.last_time|date:"M dS, h:i a" }}
    +
    +
    + +{% endblock content %} diff --git a/huntserver/templates/prepuzzle.html b/huntserver/templates/prepuzzle.html index 972879da..7ecb4bb4 100644 --- a/huntserver/templates/prepuzzle.html +++ b/huntserver/templates/prepuzzle.html @@ -15,7 +15,6 @@ data: {answer: user_answer, csrfmiddlewaretoken: "{{ csrf_token }}" }, error: function (jXHR, textStatus, errorThrown) { - console.log(jXHR.responseText); alert(errorThrown); }, success: function (response) { diff --git a/huntserver/templates/progress.html b/huntserver/templates/progress.html index b3c82e11..876472de 100644 --- a/huntserver/templates/progress.html +++ b/huntserver/templates/progress.html @@ -65,8 +65,9 @@

    Puzzle Progress

    #
    P Last Time {% for puzzle in puzzle_list %} - {{ puzzle.puzzle_name }} @@ -83,15 +84,15 @@

    Puzzle Progress

    {% for puzzle in team_dict.puzzles %} - + class='unavailable'> {% elif puzzle.1 == "unlocked" %} class='available' data-date={{ puzzle.2 |date:"U"}}> - {{ puzzle.3|time:"h:i a" }} + {{ puzzle.3|date:"m/d" }}
    {{ puzzle.3|time:"h:i A" }}
    {% else %} class='solved' data-date={{ puzzle.2 |date:"U"}}> - {{ puzzle.2|time:"h:i a" }} + {{ puzzle.2|date:"m/d" }}
    {{ puzzle.2|time:"h:i A" }} {% endif %} {% endfor %} diff --git a/huntserver/templates/puzzle.html b/huntserver/templates/puzzle.html index fb2fd52f..893e02cc 100644 --- a/huntserver/templates/puzzle.html +++ b/huntserver/templates/puzzle.html @@ -8,10 +8,24 @@ {% if not puzzle.hunt.is_public %} {% endif %} + {% endblock includes %} {% block content %} @@ -19,9 +33,12 @@
    -

    P{{ puzzle.puzzle_number }} - {{ puzzle.puzzle_name}}

    -

    {{ puzzle.solved_for.count }} teams have solved this puzzle so far.

    -
    +
    +

    P{{ puzzle.puzzle_number }} - 

    +

    {{ puzzle.puzzle_name}}

    +
    +

    {{ solve_count }} teams have solved this puzzle so far.

    +
    -

    -
    +

    +
    Submit an answer {% crispy form %}
    -
    {% if not puzzle.hunt.is_public %} @@ -41,6 +44,7 @@

    You currently have {{team.num_available_hints}} ava

    Previous Hint Requests:

    +

    Hint responses will show up below automatically once your hint has been answered.

    {% if hint_list %} {% for hint in hint_list reversed %} diff --git a/huntserver/templates/registration.html b/huntserver/templates/registration.html index 12e582bf..e1c8b20c 100644 --- a/huntserver/templates/registration.html +++ b/huntserver/templates/registration.html @@ -34,7 +34,7 @@

    The code to join this team is {{registered_team.join_code}}

    {% endif %}
    -
    + +
    + + CMU Team: + {% if registered_team.is_local %} + Yes + {% else %} + No + {% endif %} + + [Change] + + {% csrf_token %} + + CMU Team: + {% if registered_team.is_local %} + + {% else %} + + {% endif %} + +

    Team Members: @@ -82,7 +104,7 @@

    Create New Team

    Please enter a new team name:
    - {% if not curr_hunt.in_reg_lockdown %} + + + Check this box if at least half of the team will be composed of CMU students. +
    + Don't worry about getting this precisely correct. This only affects how your team shows up on the leaderboard and does not affect eligibility for prizes. +

    diff --git a/huntserver/templates/shib_register.html b/huntserver/templates/shib_register.html index 2fd586a0..455c4fa7 100644 --- a/huntserver/templates/shib_register.html +++ b/huntserver/templates/shib_register.html @@ -20,7 +20,8 @@

    Register for Puzzle Hunt CMU

    {% csrf_token %}
    -

    By continuing, you agree to release the following attributes to Puzzle Hunt CMU:

    +

    By continuing, you agree to release the following attributes to Puzzle Hunt CMU.

    +

    None of the information collected here will be displayed publicly.

    {{ user_form.as_p }} {{ person_form.as_p }}
    diff --git a/huntserver/templates/staff_hints.html b/huntserver/templates/staff_hints.html index 16dca246..5693981d 100644 --- a/huntserver/templates/staff_hints.html +++ b/huntserver/templates/staff_hints.html @@ -7,6 +7,7 @@ {% endblock includes %} @@ -17,6 +18,24 @@

    Hint Requests

    + + + + {% for team in hunt.team_set.all|dictsort:"team_name.lower" %} @@ -65,8 +84,11 @@

    Hint Requests

    - + diff --git a/huntserver/templates/user_profile.html b/huntserver/templates/user_profile.html index d36684f4..0cc643ce 100644 --- a/huntserver/templates/user_profile.html +++ b/huntserver/templates/user_profile.html @@ -6,6 +6,7 @@

    Change User Details

    {% csrf_token %} +

    None of the information collected here will be displayed publicly.

    {{ user_form|crispy }} {{ person_form|crispy }} diff --git a/huntserver/templatetags/hunt_tags.py b/huntserver/templatetags/hunt_tags.py index f26fe18e..84d55b55 100644 --- a/huntserver/templatetags/hunt_tags.py +++ b/huntserver/templatetags/hunt_tags.py @@ -2,7 +2,8 @@ from django.conf import settings from django.template import Template, Context from huntserver.models import Hunt -from datetime import datetime +from django.utils import timezone +from huntserver.utils import get_puzzle_answer_regex, get_validation_error register = template.Library() @@ -21,6 +22,11 @@ def contact_email(context): return settings.CONTACT_EMAIL +@register.simple_tag(takes_context=True) +def chat_enabled(context): + return settings.CHAT_ENABLED + + @register.filter() def render_with_context(value): return Template(value).render(Context({'curr_hunt': Hunt.objects.get(is_current_hunt=True)})) @@ -44,7 +50,7 @@ def set_hunts(parser, token): class HuntsEventNode(template.Node): def render(self, context): - old_hunts = Hunt.objects.filter(end_date__lt=datetime.now()).exclude(is_current_hunt=True) + old_hunts = Hunt.objects.filter(end_date__lt=timezone.now()).exclude(is_current_hunt=True) context['tmpl_hunts'] = old_hunts.order_by("-hunt_number")[:5] return '' @@ -74,6 +80,16 @@ def hints_open(team, puzzle): return team.hints_open_for_puzzle(puzzle) +@register.simple_tag() +def puzzle_answer_regex(puzzle): + return get_puzzle_answer_regex(puzzle.answer_validation_type) + + +@register.simple_tag() +def puzzle_validation_error(puzzle): + return get_validation_error(puzzle.answer_validation_type) + + @register.simple_tag(takes_context=True) def shib_login_url(context, entityID, next_path): if(context['request'].is_secure()): diff --git a/huntserver/tests.py b/huntserver/tests.py index 005bc82d..32680399 100644 --- a/huntserver/tests.py +++ b/huntserver/tests.py @@ -164,7 +164,7 @@ def test_registration_post_new(self): "Test the registration page's join team functionality" login(self, 'user6') post_context = {"form_type": "new_team", "team_name": "new_team", - "need_room": "need_a_room"} + "need_room": "remote"} response = self.client.post(reverse('huntserver:registration'), post_context) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.context['user'].person.teams.all()), 1) @@ -393,9 +393,9 @@ def test_puzzle_ratelimit(self): login(self, 'user2') post_context = {'answer': "Wrong Answer"} for i in range(20): - response = self.client.post(reverse('huntserver:puzzle', kwargs={"puzzle_id": "101"}), + response = self.client.post(reverse('huntserver:puzzle', kwargs={"puzzle_id": "201"}), post_context) - response = self.client.post(reverse('huntserver:puzzle', kwargs={"puzzle_id": "101"}), + response = self.client.post(reverse('huntserver:puzzle', kwargs={"puzzle_id": "201"}), post_context) self.assertEqual(response.status_code, 403) @@ -636,7 +636,7 @@ def test_staff_emails(self): post_context = {'subject': "test_subject", 'message': "test_message"} response = self.client.post(reverse('huntserver:emails'), post_context) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) def test_staff_management(self): "Test the staff management view" diff --git a/huntserver/urls.py b/huntserver/urls.py index e1d0fda5..cfea6f3e 100644 --- a/huntserver/urls.py +++ b/huntserver/urls.py @@ -44,6 +44,8 @@ # Hunt Pages url(r'^puzzle/(?P[0-9a-fA-F]{3,5})/$', hunt_views.puzzle_view, name='puzzle'), + url(r'^(?Ppuzzle/[0-9a-fA-F]{3,5}/.+)$', hunt_views.protected_static, + name='protected_static_2'), url(r'^hints/(?P[0-9a-fA-F]{3,5})/$', hunt_views.puzzle_hint, name='puzzle_hint'), url(r'^hunt/(?P[0-9]+)/$', hunt_views.hunt, name='hunt'), url(r'^hunt/current/$', hunt_views.current_hunt, name='current_hunt'), @@ -53,6 +55,8 @@ url(r'^hunt/current/prepuzzle/$', hunt_views.current_prepuzzle, name='current_prepuzzle'), url(r'^chat/$', hunt_views.chat, name='chat'), url(r'^chat/status/$', hunt_views.chat_status, name='chat_status'), + url(r'^leaderboard/$', hunt_views.leaderboard, name='leaderboard'), + url(r'^leaderboard/(?P.+)/$', hunt_views.leaderboard, name='leaderboard'), url(r'^objects/$', hunt_views.unlockables, name='unlockables'), url(r'^protected/(?P.+)$', hunt_views.protected_static, name='protected_static'), diff --git a/huntserver/utils.py b/huntserver/utils.py index bedc203c..7b813058 100644 --- a/huntserver/utils.py +++ b/huntserver/utils.py @@ -2,10 +2,13 @@ from django.db.models import F from django.utils import timezone from huey import crontab -from huey.contrib.djhuey import db_periodic_task +from huey.contrib.djhuey import db_periodic_task, db_task from django.core.cache import cache +from django.core.mail import EmailMessage, get_connection +import time +import re -from .models import Hunt, HintUnlockPlan +from .models import Hunt, HintUnlockPlan, Puzzle import logging @@ -37,12 +40,12 @@ def parse_attributes(META): def check_hints(hunt): - num_min = (timezone.now() - hunt.start_date).seconds / 60 + num_min = (timezone.now() - hunt.start_date).seconds // 60 for hup in hunt.hintunlockplan_set.exclude(unlock_type=HintUnlockPlan.SOLVES_UNLOCK): if((hup.unlock_type == hup.TIMED_UNLOCK and hup.num_triggered < 1 and num_min > hup.unlock_parameter) or (hup.unlock_type == hup.INTERVAL_UNLOCK and - num_min / hup.unlock_parameter > hup.num_triggered)): + num_min // hup.unlock_parameter > hup.num_triggered)): hunt.team_set.all().update(num_available_hints=F('num_available_hints') + 1) hup.num_triggered = hup.num_triggered + 1 hup.save() @@ -57,8 +60,8 @@ def check_puzzles(hunt, new_points, teams, team_is_list=False): else: teams.update(num_unlock_points=F('num_unlock_points') + new_points) - for team in teams: - team.unlock_puzzles() + for team in teams: + team.unlock_puzzles() @db_periodic_task(crontab(minute='*/1')) @@ -91,3 +94,56 @@ def update_time_items(): playtesters = [t for t in playtesters if t.playtest_happening] if(len(playtesters) > 0): check_puzzles(hunt, new_points, playtesters, True) + + +@db_task() +def send_mass_email(email_list, subject, message): + result = "" + email_to_chunks = [email_list[x: x + 10] for x in range(0, len(email_list), 10)] + with get_connection() as conn: + for to_chunk in email_to_chunks: + email = EmailMessage(subject, message, settings.EMAIL_FROM, [], to_chunk, + connection=conn) + email.send() + result += "Emailed: " + ", ".join(to_chunk) + "\n" + time.sleep(1) + return result + + +def get_puzzle_answer_regex(validation_type): + if(validation_type == Puzzle.ANSWER_STRICT): + return r"^[A-Z]+$" + elif(validation_type == Puzzle.ANSWER_ANY_CASE): + return r"^[a-zA-Z]+$" + elif(validation_type == Puzzle.ANSWER_CASE_AND_SPACES): + return r"^[a-zA-Z ]+$" + elif(validation_type == Puzzle.ANSWER_ANYTHING): + return r"^.*$" + else: + return r"" + + +def strip_puzzle_answer(answer, validation_type): + if(validation_type == Puzzle.ANSWER_STRICT): + return re.sub(r"[^A-Z]", "", answer) + elif(validation_type == Puzzle.ANSWER_ANY_CASE): + return re.sub(r"[^a-zA-Z]", "", answer) + elif(validation_type == Puzzle.ANSWER_CASE_AND_SPACES): + return re.sub(r"[^a-zA-Z ]", "", answer) + elif(validation_type == Puzzle.ANSWER_ANYTHING): + return answer + else: + return answer + + +def get_validation_error(validation_type): + if(validation_type == Puzzle.ANSWER_STRICT): + return "Answer must only contain the characters A-Z, case doesn't matter." + elif(validation_type == Puzzle.ANSWER_ANY_CASE): + return "Answer must only contain the characters a-z, case matters." + elif(validation_type == Puzzle.ANSWER_CASE_AND_SPACES): + return "Answer must only contain the characters A-Z and spaces, case matters." + elif(validation_type == Puzzle.ANSWER_ANYTHING): + return "I don't know how you triggered this message, any answer SHOULD be valid." + else: + return "Invalid validation type, anything entered will be invalid" diff --git a/puzzlehunt_server/settings/base_settings.py b/puzzlehunt_server/settings/base_settings.py index 78423664..553d8251 100644 --- a/puzzlehunt_server/settings/base_settings.py +++ b/puzzlehunt_server/settings/base_settings.py @@ -161,8 +161,6 @@ CONTACT_EMAIL = 'puzzlehunt-staff@lists.andrew.cmu.edu' EMAIL_USE_TLS = True -EMAIL_HOST = 'smtp.gmail.com' -EMAIL_PORT = 587 # Environment variable overrides if os.environ.get("ENABLE_DEBUG_EMAIL"): @@ -170,6 +168,11 @@ EMAIL_FILE_PATH = '/tmp/test_folder' if os.environ.get("ENABLE_DEBUG_TOOLBAR"): + DEBUG_TOOLBAR_PANELS = ( + 'debug_toolbar.panels.version.VersionDebugPanel', + 'debug_toolbar.panels.timer.TimerDebugPanel', + 'debug_toolbar.panels.profiling.ProfilingDebugPanel', + ) INSTALLED_APPS = INSTALLED_APPS + ('debug_toolbar',) MIDDLEWARE = ('debug_toolbar.middleware.DebugToolbarMiddleware',) + MIDDLEWARE diff --git a/puzzlehunt_server/settings/env_settings.py b/puzzlehunt_server/settings/env_settings.py index fa6a4cd6..559dc7b0 100644 --- a/puzzlehunt_server/settings/env_settings.py +++ b/puzzlehunt_server/settings/env_settings.py @@ -10,8 +10,17 @@ DATABASES['default']['OPTIONS'] = {'charset': 'utf8mb4'} INTERNAL_IPS = ['127.0.0.1', 'localhost'] +EMAIL_HOST = os.environ.get("DJANGO_EMAIL_HOST") +EMAIL_PORT = int(os.environ.get("DJANGO_EMAIL_PORT", default="587")) EMAIL_HOST_USER = os.environ.get("DJANGO_EMAIL_USER") EMAIL_HOST_PASSWORD = os.environ.get("DJANGO_EMAIL_PASSWORD") +EMAIL_FROM = os.environ.get("DJANGO_EMAIL_FROM") +DEFAULT_FROM_EMAIL = EMAIL_FROM +SERVER_EMAIL = EMAIL_FROM DOMAIN = os.getenv("DOMAIN", default="default.com") +CHAT_ENABLED = os.getenv("PUZZLEHUNT_CHAT_ENABLED", default="True").lower() == "true" + +if "SITE_TITLE" in os.environ: + SITE_TITLE = os.getenv("SITE_TITLE") ALLOWED_HOSTS = ['*'] diff --git a/puzzlehunt_server/templates/admin/base_site.html b/puzzlehunt_server/templates/admin/base_site.html index 993b22f7..9a48b202 100644 --- a/puzzlehunt_server/templates/admin/base_site.html +++ b/puzzlehunt_server/templates/admin/base_site.html @@ -1,5 +1,5 @@ {% extends "admin/base_site.html" %} -{% load i18n static bootstrap_admin_template_tags %} +{% load i18n static bootstrap_admin_template_tags hunt_tags %} {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} {% block extrahead %} @@ -66,9 +66,12 @@
  • Queue
  • -
  • - Chat -
  • + {% chat_enabled as chat_enabled_bool %} + {% if chat_enabled_bool %} +
  • + Chat +
  • + {% endif %}
  • Hint Requests
  • diff --git a/puzzlehunt_server/templates/registration/password_reset_email.html b/puzzlehunt_server/templates/registration/password_reset_email.html index 643c7ad0..8a777f9d 100644 --- a/puzzlehunt_server/templates/registration/password_reset_email.html +++ b/puzzlehunt_server/templates/registration/password_reset_email.html @@ -3,7 +3,7 @@ We have received a password reset request for the user: {{ user.get_username }} If this is your account, and you have requested this password reset, you can -click the link below to initiate the password reset process for your Puzzle Hunt CMU account: +click the link below to initiate the password reset process for your {{ site_name }} account: {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} @@ -13,5 +13,5 @@ If you did not request this password reset, just ignore this email. Sincerely, -Puzzle Hunt CMU Staff +{{ site_name }} Staff {% endautoescape %} diff --git a/requirements.txt b/requirements.txt index c494c344..86979b65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -Django==2.2.20 +Django==2.2.24 decorator==4.1.2 bootstrap-admin==0.4.* -django-debug-toolbar==1.8 +django-debug-toolbar==1.11.1 python-dateutil==2.6.1 sqlparse==0.2.3 sphinx diff --git a/sample.env b/sample.env index a056b4cf..07558bb7 100644 --- a/sample.env +++ b/sample.env @@ -3,11 +3,16 @@ DB_PASSWORD=my_password DB_USER=user DJANGO_SECRET_KEY=mysecretkey DOMAIN=your.domain-here.com +SITE_TITLE="Puzzlehunt CMU" CONTACT_EMAIL=sample_email@example.com PROJECT_NAME=puzzlehunt_site +# PUZZLEHUNT_CHAT_ENABLED=True +# DJANGO_EMAIL_HOST=email_host +# DJANGO_EMAIL_PORT=email_host_port # DJANGO_EMAIL_USER=email_user # DJANGO_EMAIL_PASSWORD=email_password +# DJANGO_EMAIL_FROM=email_to_send_as DJANGO_ENABLE_DEBUG=False # DJANGO_USE_SHIBBOLETH=True
    ALL TEAMS (CAUTION) +
    + + + + + + + +
    +
    {{ team.short_name }}