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.