from django.db import models, transaction
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.utils.dateformat import DateFormat
from django.utils.encoding import python_2_unicode_compatible
from dateutil import tz
from django.conf import settings
from django.core.files.storage import FileSystemStorage
import os
import re
import zipfile
import shutil
import logging
logger = logging.getLogger(__name__)
time_zone = tz.gettz(settings.TIME_ZONE)
# TODO: cleanup duplicate functions
def get_puzzle_file_path(puzzle, filename):
return "puzzles/" + puzzle.puzzle_id + "." + filename.split('.')[-1]
def get_solution_file_path(puzzle, filename):
return "solutions/" + puzzle.puzzle_id + "_sol." + filename.split('.')[-1]
def get_prepuzzle_file_path(prepuzzle, filename):
return "prepuzzles/" + str(prepuzzle.pk) + "." + filename.split('.')[-1]
def get_hunt_file_path(hunt, filename):
return "hunt/" + str(hunt.hunt_number) + "." + filename.split('.')[-1]
[docs]class PuzzleOverwriteStorage(FileSystemStorage):
""" A custom storage class that just overwrites existing files rather than erroring """
[docs] def get_available_name(self, name, max_length=None):
# If the filename already exists, remove it as if it was a true file system
if self.exists(name):
os.remove(os.path.join(settings.MEDIA_ROOT, name))
extension = name.split('.')[-1]
folder = "".join(name.split('.')[:-1])
if(extension == "zip"):
shutil.rmtree(os.path.join(settings.MEDIA_ROOT, folder), ignore_errors=True)
return name
[docs] def url(self, name):
return settings.PROTECTED_URL + name
def _save(self, name, content):
rc = super(PuzzleOverwriteStorage, self)._save(name, content)
extension = name.split('.')[-1]
folder = "".join(name.split('.')[:-1])
if(extension == "zip"):
with zipfile.ZipFile(os.path.join(settings.MEDIA_ROOT, name), "r") as zip_ref:
zip_ref.extractall(path=os.path.join(settings.MEDIA_ROOT, folder))
return rc
[docs]@python_2_unicode_compatible
class Hunt(models.Model):
""" Base class for a hunt. Contains basic details about a puzzlehunt. """
class Meta:
indexes = [
models.Index(fields=['hunt_number']),
]
hunt_name = models.CharField(
max_length=200,
help_text="The name of the hunt as the public will see it")
hunt_number = models.IntegerField(
unique=True,
help_text="A number used internally for hunt sorting, must be unique")
team_size = models.IntegerField()
start_date = models.DateTimeField(
help_text="The date/time at which a hunt will become visible to registered users")
end_date = models.DateTimeField(
help_text="The date/time at which a hunt will be archived and available to the public")
display_start_date = models.DateTimeField(
help_text="The start date/time displayed to users")
display_end_date = models.DateTimeField(
help_text="The end date/time displayed to users")
location = models.CharField(
max_length=100,
help_text="Starting location of the puzzlehunt")
resource_file = models.FileField(
upload_to=get_hunt_file_path,
storage=PuzzleOverwriteStorage(),
blank=True,
help_text="Hunt resources, MUST BE A ZIP FILE.")
is_current_hunt = models.BooleanField(
default=False)
extra_data = models.CharField(
max_length=200,
blank=True,
help_text="A misc. field for any extra data to be stored with the hunt.")
template = models.TextField(
default="",
help_text="The template string to be rendered to HTML on the hunt page")
hint_lockout = models.IntegerField(
default=settings.DEFAULT_HINT_LOCKOUT,
help_text="The number of minutes before a hint can be used on a newly unlocked puzzle")
points_per_minute = models.IntegerField(
default=0,
help_text="The number of points granted per minute during the hunt")
[docs] def clean(self, *args, **kwargs):
""" Overrides the standard clean method to ensure that only one hunt is the current hunt """
if(not self.is_current_hunt):
try:
old_obj = Hunt.objects.get(pk=self.pk)
if(old_obj.is_current_hunt):
raise ValidationError({'is_current_hunt':
["There must always be one current hunt", ]})
except ObjectDoesNotExist:
pass
super(Hunt, self).clean(*args, **kwargs)
[docs] @transaction.atomic
def save(self, *args, **kwargs):
""" Overrides the standard save method to ensure that only one hunt is the current hunt """
self.full_clean()
if self.is_current_hunt:
Hunt.objects.filter(is_current_hunt=True).update(is_current_hunt=False)
super(Hunt, self).save(*args, **kwargs)
@property
def is_locked(self):
""" A boolean indicating whether or not the hunt is locked """
return timezone.now() < self.start_date
@property
def is_open(self):
""" A boolean indicating whether or not the hunt is open to registered participants """
return timezone.now() >= self.start_date and timezone.now() < self.end_date
@property
def is_public(self):
""" A boolean indicating whether or not the hunt is open to the public """
return timezone.now() > self.end_date
@property
def is_day_of_hunt(self):
""" A boolean indicating whether or not today is the day of the hunt """
return timezone.now().date() == self.start_date.date()
@property
def in_reg_lockdown(self):
""" A boolean indicating whether or not registration has locked for this hunt """
return (self.start_date - timezone.now()).days <= settings.HUNT_REGISTRATION_LOCKOUT
@property
def season(self):
""" Gets a season string from the hunt dates """
if(self.start_date.month >= 1 and self.start_date.month <= 5):
return "Spring"
elif(self.start_date.month >= 8 and self.start_date.month <= 12):
return "Fall"
else:
return "Summer"
@property
def real_teams(self):
""" A queryset of all non-dummy teams in the hunt """
return self.team_set.exclude(location="DUMMY").all()
@property
def dummy_team(self):
""" The dummy team for the hunt """
try:
team = self.team_set.get(location="DUMMY")
except Team.DoesNotExist:
team = Team.objects.create(team_name=self.hunt_name + "_DUMMY", hunt=self,
location="DUMMY", join_code="WRONG")
return team
def __str__(self):
if(self.is_current_hunt):
return self.hunt_name + " (c)"
else:
return self.hunt_name
[docs] def team_from_user(self, user):
""" Takes a user and a hunt and returns either the user's team for that hunt or None """
if(not user.is_authenticated):
return None
teams = get_object_or_404(Person, user=user).teams.filter(hunt=self)
return teams[0] if (len(teams) > 0) else None
[docs]@python_2_unicode_compatible
class Puzzle(models.Model):
""" A class representing a puzzle within a hunt """
class Meta:
indexes = [
models.Index(fields=['puzzle_id']),
]
ordering = ['-hunt', 'puzzle_number']
SOLVES_UNLOCK = 'SOL'
POINTS_UNLOCK = 'POT'
EITHER_UNLOCK = 'ETH'
BOTH_UNLOCK = 'BTH'
puzzle_unlock_type_choices = [
(SOLVES_UNLOCK, 'Solves Based Unlock'),
(POINTS_UNLOCK, 'Points Based Unlock'),
(EITHER_UNLOCK, 'Either (OR) Unlocking Method'),
(BOTH_UNLOCK, 'Both (AND) Unlocking Methods'),
]
PDF_PUZZLE = 'PDF'
LINK_PUZZLE = 'LNK'
WEB_PUZZLE = 'WEB'
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'),
]
hunt = models.ForeignKey(
Hunt,
on_delete=models.CASCADE,
help_text="The hunt that this puzzle is a part of")
puzzle_name = models.CharField(
max_length=200,
help_text="The name of the puzzle as it will be seen by hunt participants")
puzzle_number = models.IntegerField(
help_text="The number of the puzzle within the hunt, for sorting purposes")
puzzle_id = models.CharField(
max_length=8,
unique=True, # hex only please
help_text="A 3-5 character hex string that uniquely identifies the puzzle")
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_page_type = models.CharField(
max_length=3,
choices=puzzle_page_type_choices,
default=WEB_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(),
blank=True,
help_text="Puzzle file. MUST BE A PDF")
resource_file = models.FileField(
upload_to=get_puzzle_file_path,
storage=PuzzleOverwriteStorage(),
blank=True,
help_text="Puzzle resources, MUST BE A ZIP FILE.")
solution_is_webpage = models.BooleanField(
default=False,
help_text="Is this solution an html webpage?")
solution_file = models.FileField(
upload_to=get_solution_file_path,
storage=PuzzleOverwriteStorage(),
blank=True,
help_text="Puzzle solution. MUST BE A PDF.")
solution_resource_file = models.FileField(
upload_to=get_solution_file_path,
storage=PuzzleOverwriteStorage(),
blank=True,
help_text="Puzzle solution resources, MUST BE A ZIP FILE.")
extra_data = models.CharField(
max_length=200,
blank=True,
help_text="A misc. field for any extra data to be stored with the puzzle.")
# Unlocking:
unlock_type = models.CharField(
max_length=3,
choices=puzzle_unlock_type_choices,
default=SOLVES_UNLOCK,
blank=False,
help_text="The type of puzzle unlocking scheme"
)
num_required_to_unlock = models.IntegerField(
default=1,
help_text="Number of prerequisite puzzles that need to be solved to unlock this puzzle")
unlocks = models.ManyToManyField(
"self",
blank=True,
symmetrical=False,
help_text="Puzzles that this puzzle is a possible prerequisite for")
points_cost = models.IntegerField(
default=0,
help_text="The number of points needed to unlock this puzzle.")
points_value = models.IntegerField(
default=0,
help_text="The number of points this puzzle grants upon solving.")
# Overridden to delete old files on clear
[docs] def save(self, *args, **kwargs):
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)
super(Puzzle, self).save(*args, **kwargs)
[docs] def serialize_for_ajax(self):
""" Serializes the ID, puzzle_number and puzzle_name fields for ajax transmission """
message = dict()
message['id'] = self.puzzle_id
message['number'] = self.puzzle_number
message['name'] = self.puzzle_name
return message
@property
def safename(self):
name = self.puzzle_name.lower().replace(" ", "_")
return re.sub(r'[^a-z_]', '', name)
def __str__(self):
return str(self.puzzle_number) + "-" + str(self.puzzle_id) + " " + self.puzzle_name
[docs]@python_2_unicode_compatible
class Prepuzzle(models.Model):
""" A class representing a pre-puzzle within a hunt """
puzzle_name = models.CharField(
max_length=200,
help_text="The name of the puzzle as it will be seen by hunt participants")
released = models.BooleanField(
default=False)
hunt = models.OneToOneField(
Hunt,
on_delete=models.CASCADE,
blank=True,
null=True,
help_text="The hunt that this puzzle is a part of, leave blank for no associated hunt.")
answer = models.CharField(
max_length=100,
help_text="The answer to the puzzle, not case sensitive")
template = models.TextField(
default='{% extends "prepuzzle.html" %}\r\n{% load prepuzzle_tags %}\r\n' +
'\r\n{% block content %}\r\n{% endblock content %}',
help_text="The template string to be rendered to HTML on the hunt page")
resource_file = models.FileField(
upload_to=get_prepuzzle_file_path,
storage=PuzzleOverwriteStorage(),
blank=True,
help_text="Prepuzzle resources, MUST BE A ZIP FILE.")
response_string = models.TextField(
default="",
help_text="Data returned to the webpage for use upon solving.")
def __str__(self):
if(self.hunt):
return "prepuzzle " + str(self.pk) + " (" + str(self.hunt.hunt_name) + ")"
else:
return "prepuzzle " + str(self.pk)
# Overridden to delete old files on clear
[docs] def save(self, *args, **kwargs):
if(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]
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(Prepuzzle, self).save(*args, **kwargs)
class TeamManager(models.Manager):
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = (models.Q(team_name__icontains=query) |
models.Q(location__icontains=query))
qs = qs.filter(or_lookup).distinct()
return qs
[docs]@python_2_unicode_compatible
class Team(models.Model):
""" A class representing a team within a hunt """
team_name = models.CharField(
max_length=200,
help_text="The team name as it will be shown to hunt participants")
solved = models.ManyToManyField(
Puzzle,
blank=True,
related_name='solved_for',
through="Solve",
help_text="The puzzles the team has solved")
unlocked = models.ManyToManyField(
Puzzle,
blank=True,
related_name='unlocked_for',
through="Unlock",
help_text="The puzzles the team has unlocked")
unlockables = models.ManyToManyField(
"Unlockable",
blank=True,
help_text="The unlockables the team has earned")
hunt = models.ForeignKey(
Hunt,
on_delete=models.CASCADE,
help_text="The hunt that the team is a part of")
location = models.CharField(
max_length=80,
blank=True,
help_text="The physical location that the team is solving at")
join_code = models.CharField(
max_length=5,
help_text="The 5 character random alphanumeric password needed for a user to join a team")
playtester = models.BooleanField(
default=False,
help_text="A boolean to indicate if the team is a playtest team and will get early access")
playtest_start_date = models.DateTimeField(
null=True,
blank=True,
help_text="The date/time at which a hunt will become to the playtesters")
playtest_end_date = models.DateTimeField(
null=True,
blank=True,
help_text="The date/time at which a hunt will no longer be available to playtesters")
num_waiting_messages = models.IntegerField(
default=0,
help_text="The number of unseen messages a team has waiting")
num_available_hints = models.IntegerField(
default=0,
help_text="The number of hints the team has available to use")
num_unlock_points = models.IntegerField(
default=0,
help_text="The number of points the team has earned")
objects = TeamManager()
@property
def is_playtester_team(self):
""" A boolean indicating whether or not the team is a playtesting team """
return self.playtester
@property
def playtest_started(self):
""" A boolean indicating whether or not the team is currently allowed to be playtesting """
if(self.playtest_start_date is None or self.playtest_end_date is None):
return False
return (timezone.now() >= self.playtest_start_date)
@property
def playtest_over(self):
""" A boolean indicating whether or not the team's playtest slot has passed """
if(self.playtest_start_date is None or self.playtest_end_date is None):
return False
return timezone.now() >= self.playtest_end_date
@property
def playtest_happening(self):
""" A boolean indicating whether or not the team's playtest slot is currently happening """
return self.playtest_started and not self.playtest_over
@property
def is_normal_team(self):
""" A boolean indicating whether or not the team is a normal (non-playtester) team """
return (not self.playtester)
@property
def short_name(self):
""" Team name shortened to 30 characters for more consistent display """
if(len(self.team_name) > 30):
return self.team_name[:30] + "..."
else:
return self.team_name
@property
def size(self):
""" The number of people on the team """
return self.person_set.count()
[docs] def hints_open_for_puzzle(self, puzzle):
""" Takes a puzzle and returns whether or not the team may use hints on the puzzle """
if(self.num_available_hints > 0 or self.hint_set.count() > 0):
try:
unlock = Unlock.objects.get(team=self, puzzle=puzzle)
except Unlock.DoesNotExist:
return False
return (timezone.now() - unlock.time).total_seconds() > 60 * self.hunt.hint_lockout
else:
return False
[docs] def unlock_puzzles(self):
""" Unlocks all puzzles a team is currently supposed to have unlocked """
puzzles = self.hunt.puzzle_set.all().order_by('puzzle_number')
numbers = []
numbers = puzzles.values_list('puzzle_number', flat=True)
# make an array for how many points a team has towards unlocking each puzzle
mapping = [0 for i in range(max(numbers) + 1)]
# go through each solved puzzle and add to the list for each puzzle it unlocks
for puzzle in self.solved.all():
for num in puzzle.unlocks.values_list('puzzle_number', flat=True):
mapping[num] += 1
# See if the number of points is enough to unlock any given puzzle
puzzles = puzzles.difference(self.unlocked.all())
for puzzle in puzzles:
s_unlock = (puzzle.num_required_to_unlock <= mapping[puzzle.puzzle_number])
p_unlock = (self.num_unlock_points >= puzzle.points_cost)
if(puzzle.unlock_type == Puzzle.SOLVES_UNLOCK and s_unlock):
logger.info("Team %s unlocked puzzle %s with solves" % (str(self.team_name),
str(puzzle.puzzle_id)))
Unlock.objects.create(team=self, puzzle=puzzle, time=timezone.now())
elif(puzzle.unlock_type == Puzzle.POINTS_UNLOCK and p_unlock):
logger.info("Team %s unlocked puzzle %s with points" % (str(self.team_name),
str(puzzle.puzzle_id)))
Unlock.objects.create(team=self, puzzle=puzzle, time=timezone.now())
elif(puzzle.unlock_type == Puzzle.EITHER_UNLOCK and (s_unlock or p_unlock)):
logger.info("Team %s unlocked puzzle %s with either" % (str(self.team_name),
str(puzzle.puzzle_id)))
Unlock.objects.create(team=self, puzzle=puzzle, time=timezone.now())
elif(puzzle.unlock_type == Puzzle.BOTH_UNLOCK and (s_unlock and p_unlock)):
logger.info("Team %s unlocked puzzle %s with both" % (str(self.team_name),
str(puzzle.puzzle_id)))
Unlock.objects.create(team=self, puzzle=puzzle, time=timezone.now())
[docs] def unlock_hints(self):
""" Gives teams the appropriate number of hints based on "Solves" HintUnlockPlans """
# The way this works currently sucks, it should only be called once per solve
# Right now that one place is Submission.respond()
# It also only does # of solves based unlocks. Time based unlocks are done in run_updates
num_solved = self.solved.count()
plans = self.hunt.hintunlockplan_set
num_hints = plans.filter(unlock_type=HintUnlockPlan.SOLVES_UNLOCK,
unlock_parameter=num_solved).count()
self.num_available_hints = models.F('num_available_hints') + num_hints
self.save()
[docs] def reset(self):
""" Resets/deletes all of the team's progress """
self.unlock_set.all().delete()
self.unlocked.clear()
self.solve_set.all().delete()
self.solved.clear()
self.submission_set.all().delete()
self.num_available_hints = 0
self.num_unlock_points = 0
self.save()
def __str__(self):
return str(self.size) + " (" + self.location + ") " + self.short_name
class PersonManager(models.Manager):
def search(self, query=None):
qs = self.get_queryset()
if query is not None:
or_lookup = (models.Q(user__username__icontains=query) |
models.Q(user__first_name__icontains=query) |
models.Q(user__last_name__icontains=query) |
models.Q(user__email__icontains=query))
qs = qs.filter(or_lookup).distinct()
return qs
[docs]@python_2_unicode_compatible
class Person(models.Model):
""" A class to associate more personal information with the default django auth user class """
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
help_text="The corresponding user to this person")
phone = models.CharField(
max_length=20,
blank=True,
help_text="Person's phone number, no particular formatting")
allergies = models.CharField(
max_length=400,
blank=True,
help_text="Allergy information for the person")
comments = models.CharField(
max_length=400,
blank=True,
help_text="Comments or other notes about the person")
teams = models.ManyToManyField(
Team,
blank=True,
help_text="Teams that the person is on")
is_shib_acct = models.BooleanField(
help_text="A boolean to indicate if the person uses shibboleth authentication for login")
objects = PersonManager()
def __str__(self):
name = self.user.first_name + " " + self.user.last_name + " (" + self.user.username + ")"
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)
if(match):
return match.expand("(\\1)-\\2-\\3")
return self.phone
[docs]@python_2_unicode_compatible
class Submission(models.Model):
""" A class representing a submission to a given puzzle from a given team """
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
help_text="The team that made the submission")
submission_time = models.DateTimeField()
submission_text = models.CharField(
max_length=100)
response_text = models.CharField(
blank=True,
max_length=400,
help_text="Response to the given answer. Empty string indicates human response needed")
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that this submission is in response to")
modified_date = models.DateTimeField(
help_text="Last date/time of response modification")
[docs] 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['puzzle'] = self.puzzle.serialize_for_ajax()
message['team_pk'] = self.team.pk
message['status_type'] = "submission"
return message
@property
def is_correct(self):
""" A boolean indicating if the submission given is exactly correct """
return self.submission_text.upper() == self.puzzle.answer.upper()
@property
def convert_markdown_response(self):
""" The response with all markdown links converted to HTML links """
return re.sub(r'\[(.*?)\]\((.*?)\)', '<a href="\\2">\\1</a>', self.response_text)
[docs] def save(self, *args, **kwargs):
""" Overrides the default save function to update the modified date on save """
self.modified_date = timezone.now()
super(Submission, self).save(*args, **kwargs)
[docs] 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)))
# Automatic submission response system
# Returning an empty string means that huntstaff should respond via the queue
# Order of response importance: Regex, Defaults, Staff response.
[docs] 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
# Check against regexes
for resp in self.puzzle.response_set.all():
if(re.match(resp.regex, self.submission_text, re.IGNORECASE)):
response = resp.text
break
else: # Give a default response if no regex matches
if(self.is_correct):
response = "Correct"
else:
# Current philosphy is to auto-can wrong answers: If it's not right, it's wrong
response = "Wrong Answer."
logger.info("Team %s incorrectly guessed %s for puzzle %s" %
(str(self.team.team_name), str(self.submission_text),
str(self.puzzle.puzzle_id)))
self.response_text = response
self.save()
[docs] def update_response(self, text):
""" Updates the response with the given text """
self.response_text = text
self.modified_date = timezone.now() # TODO: I think this line is useless because of ^ save
self.save()
def __str__(self):
return self.submission_text
[docs]@python_2_unicode_compatible
class Solve(models.Model):
""" A class that links a team and a puzzle to indicate that the team has solved the puzzle """
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that this is a solve for")
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
help_text="The team that this solve is from")
submission = models.ForeignKey(
Submission,
blank=True,
on_delete=models.CASCADE,
help_text="The submission object that the team submitted to solve the puzzle")
class Meta:
unique_together = ('puzzle', 'team',)
[docs] def serialize_for_ajax(self):
""" Serializes the puzzle, team, time, and status fields for ajax transmission """
message = dict()
message['puzzle'] = self.puzzle.serialize_for_ajax()
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['status_type'] = "solve"
return message
def __str__(self):
return self.team.short_name + " => " + self.puzzle.puzzle_name
[docs]@python_2_unicode_compatible
class Unlock(models.Model):
""" A class that links a team and a puzzle to indicate that the team has unlocked the puzzle """
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that this is an unlock for")
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
help_text="The team that this unlocked puzzle is for")
time = models.DateTimeField(
help_text="The time this puzzle was unlocked for this team")
class Meta:
unique_together = ('puzzle', 'team',)
[docs] def serialize_for_ajax(self):
""" Serializes the puzzle, team, and status fields for ajax transmission """
message = dict()
message['puzzle'] = self.puzzle.serialize_for_ajax()
message['team_pk'] = self.team.pk
message['status_type'] = "unlock"
return message
def __str__(self):
return self.team.short_name + ": " + self.puzzle.puzzle_name
[docs]@python_2_unicode_compatible
class Message(models.Model):
""" A class that represents a message sent using the chat functionality """
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
help_text="The team that this message is being sent to/from")
is_response = models.BooleanField(
help_text="A boolean representing whether or not the message is from the staff")
text = models.CharField(
max_length=400,
help_text="Message text")
time = models.DateTimeField(
help_text="Message send time")
def __str__(self):
return self.team.short_name + ": " + self.text
[docs]@python_2_unicode_compatible
class Unlockable(models.Model):
""" A class that represents an object to be unlocked after solving a puzzle """
TYPE_CHOICES = (
('IMG', 'Image'),
('PDF', 'PDF'),
('TXT', 'Text'),
('WEB', 'Link'),
)
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that needs to be solved to unlock this object")
content_type = models.CharField(
max_length=3,
choices=TYPE_CHOICES,
default='TXT',
help_text="The type of object that is to be unlocked, can be 'IMG', 'PDF', 'TXT', or 'WEB'")
content = models.CharField(
max_length=500,
help_text="The link to the content, files must be externally hosted.")
def __str__(self):
return "%s (%s)" % (self.puzzle.puzzle_name, self.content_type)
[docs]@python_2_unicode_compatible
class Response(models.Model):
""" A class to represent an automated response regex """
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that this automated response is related to")
regex = models.CharField(
max_length=400,
help_text="The python-style regex that will be checked against the user's response")
text = models.CharField(
max_length=400,
help_text="The text to use in the submission response if the regex matched")
def __str__(self):
return self.regex + " => " + self.text
[docs]@python_2_unicode_compatible
class Hint(models.Model):
""" A class to represent a hint to a puzzle """
puzzle = models.ForeignKey(
Puzzle,
on_delete=models.CASCADE,
help_text="The puzzle that this hint is related to")
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
help_text="The team that requested the hint")
request = models.TextField(
max_length=1000,
help_text="The text of the request for the hint")
request_time = models.DateTimeField(
help_text="Hint request time")
response = models.TextField(
max_length=1000,
blank=True,
help_text="The text of the response to the hint request")
response_time = models.DateTimeField(
null=True,
blank=True,
help_text="Hint response time")
last_modified_time = models.DateTimeField(
help_text="Last time of modification")
def __str__(self):
return (self.team.short_name + ": " + self.puzzle.puzzle_name +
" (" + str(self.request_time) + ")")
[docs]@python_2_unicode_compatible
class HintUnlockPlan(models.Model):
""" A class to represent when Teams are given hints """
hunt = models.ForeignKey(
Hunt,
on_delete=models.CASCADE,
help_text="The hunt that this hint unlock plan refers to")
TIMED_UNLOCK = 'TIM'
INTERVAL_UNLOCK = 'INT'
SOLVES_UNLOCK = 'SOL'
hint_unlock_type_choices = [
(TIMED_UNLOCK, 'Exact Time Unlock'),
(INTERVAL_UNLOCK, 'Interval Based Unlock'),
(SOLVES_UNLOCK, 'Solves Based Unlock'),
]
unlock_type = models.CharField(
max_length=3,
choices=hint_unlock_type_choices,
default=TIMED_UNLOCK,
blank=False,
help_text="The type of hint unlock plan"
)
unlock_parameter = models.IntegerField(
help_text="Parameter (Time / Interval / Solves)")
num_triggered = models.IntegerField(
default=0,
help_text="Number of times this Unlock Plan has given a hint")
[docs] def reset_plan(self):
""" Resets the HintUnlockPlan """
self.num_triggered = 0
def __str__(self):
return "Nope"
[docs]class OverwriteStorage(FileSystemStorage):
""" A custom storage class that just overwrites existing files rather than erroring """
[docs] def get_available_name(self, name, max_length=None):
# If the filename already exists, remove it as if it was a true file system
if self.exists(name):
os.remove(os.path.join(settings.MEDIA_ROOT, name))
return name
[docs]@python_2_unicode_compatible
class HuntAssetFile(models.Model):
""" A class to represent an asset file for a puzzlehunt """
file = models.FileField(upload_to='hunt/assets/', storage=OverwriteStorage())
def __str__(self):
return os.path.basename(self.file.name)