Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions puzzlehunt/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

from puzzlehunt.models import Hunt, Puzzle, Prepuzzle, Team, PuzzleStatus, Submission, User, Event,\
Response, Hint, Update, TeamRankingRule, PuzzleFile, SolutionFile, HuntFile,\
PrepuzzleFile, DisplayOnlyHunt, NotificationPlatform, NotificationSubscription, CannedHint
PrepuzzleFile, DisplayOnlyHunt, NotificationPlatform, NotificationSubscription, CannedHint,\
TeamDataQuestion, TeamDataAnswer
from puzzlehunt.utils import create_media_files
from admin_interface.models import Theme
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -66,6 +67,13 @@ class TeamRankingRuleInline(admin.TabularInline):
fields = ['rule_order', 'rule_type', 'visible']


class TeamDataQuestionInline(admin.TabularInline):
model = TeamDataQuestion
extra = 1
ordering = ('question_order',)
fields = ['question_order', 'name', 'question_type', 'options', 'required', 'visible_on_leaderboard', 'used_for_grouping']


class HuntAdminForm(forms.ModelForm):
model = Hunt

Expand All @@ -82,7 +90,7 @@ def __init__(self, *args, **kwargs):
class HuntAdmin(admin.ModelAdmin):
form = HuntAdminForm
list_display = ['name', 'team_size_limit', 'start_date', 'is_current_hunt']
inlines = (TeamRankingRuleInline,)
inlines = (TeamRankingRuleInline, TeamDataQuestionInline)
fieldsets = (
('Basic Info', {'fields': ('name', 'is_current_hunt', 'team_size_limit', 'location',
('start_date', 'display_start_date'), ('end_date', 'display_end_date'))}),
Expand Down Expand Up @@ -268,7 +276,7 @@ class TeamAdmin(admin.ModelAdmin):
fieldsets = (
("Basic Info",
{
'fields': ['name', 'hunt', 'members', 'custom_data', 'join_code'],
'fields': ['name', 'hunt', 'members', 'join_code'],
}),
("Playtesting",
{
Expand Down Expand Up @@ -315,6 +323,13 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
return super().formfield_for_foreignkey(db_field, request, **kwargs)


@admin.register(TeamDataAnswer)
class TeamDataAnswerAdmin(admin.ModelAdmin):
list_display = [short_team_name, 'question', 'value']
list_filter = ['question__hunt', 'question']
autocomplete_fields = ['team']


@admin.register(Hint)
class HintAdmin(admin.ModelAdmin):
list_filter = ('puzzle__hunt', 'puzzle')
Expand Down
166 changes: 121 additions & 45 deletions puzzlehunt/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@
from crispy_forms.layout import Layout, Field, Div
from django.conf import settings
from django.forms import (
ModelForm, Form, CharField, FileField,
ModelForm, Form, CharField, FileField, ChoiceField,
TextInput, MultipleChoiceField, CheckboxSelectMultiple,
BooleanField, CheckboxInput
)
from django.contrib.auth.forms import ValidationError
from django.urls import reverse
from constance import config

from .models import Team, User, NotificationSubscription, Event
from .models import Team, User, NotificationSubscription, Event, TeamDataQuestion, TeamDataAnswer
from .notifications import NotificationHandler

class TeamForm(ModelForm):
class Meta:
model = Team
fields = ['name', 'custom_data']
fields = ['name']

def __init__(self, *args, **kwargs):
def __init__(self, *args, hunt=None, **kwargs):
super().__init__(*args, **kwargs)
self.hunt = hunt or (self.instance.hunt if self.instance.hunt_id else None)
self.helper = FormHelper()
if self.instance.pk:
url = reverse("puzzlehunt:team_update", kwargs={'pk': self.instance.pk})
Expand All @@ -34,47 +34,60 @@ def __init__(self, *args, **kwargs):
self.helper.form_class = 'block'
self.helper.form_action = url

# Hide custom_data field if no name is set
if not config.TEAM_CUSTOM_DATA_NAME:
self.fields['custom_data'].widget = TextInput(attrs={'style': 'display: none;'})
layout_fields = ['name']
else:
self.fields['custom_data'].label = config.TEAM_CUSTOM_DATA_NAME
self.fields['custom_data'].help_text = config.TEAM_CUSTOM_DATA_DESCRIPTION

# Handle different field types
if config.TEAM_CUSTOM_DATA_TYPE == 'boolean':
# Convert the field to a BooleanField
self.fields['custom_data'] = BooleanField(
required=False,
label=config.TEAM_CUSTOM_DATA_NAME,
help_text=config.TEAM_CUSTOM_DATA_DESCRIPTION
self.questions = (
list(self.hunt.teamdataquestion_set.order_by('question_order')) if self.hunt else []
)
existing_answers = (
{a.question_id: a.value for a in self.instance.teamdataanswer_set.all()}
if self.instance.pk else {}
)

layout_fields = ['name']
for question in self.questions:
field_name = f'question_{question.pk}'
if question.question_type == TeamDataQuestion.QuestionType.BOOLEAN:
self.fields[field_name] = BooleanField(
required=question.required,
label=question.name,
help_text=question.description,
)
# Convert stored string value to boolean if it exists
if self.instance.custom_data:
self.initial['custom_data'] = self.instance.custom_data.lower() == 'true'

# Create a custom template for the switch
if question.pk in existing_answers:
self.initial[field_name] = existing_answers[question.pk] == 'True'
switch_field = BulmaField(
'custom_data',
field_name,
template='components/_bulma_switch_field.html',
wrapper_class='field'
)
layout_fields = [
'name',
Div(
Div(switch_field, css_class="level-left"),
css_class="level"
),
layout_fields.append(
Div(Div(switch_field, css_class="level-left"), css_class="level")
)
elif question.question_type == TeamDataQuestion.QuestionType.SELECT:
choices = ([('', '---')] if not question.required else []) + [
(opt, opt) for opt in question.options
]
self.fields[field_name] = ChoiceField(
required=question.required,
label=question.name,
help_text=question.description,
choices=choices,
)
if question.pk in existing_answers:
self.initial[field_name] = existing_answers[question.pk]
layout_fields.append(
Div(Div(Field(field_name), css_class="level-left"), css_class="level")
)
else:
layout_fields = [
'name',
Div(
Div(Field('custom_data'), css_class="level-left"),
css_class="level"
),
]
self.fields[field_name] = CharField(
required=question.required,
label=question.name,
help_text=question.description,
max_length=200,
)
if question.pk in existing_answers:
self.initial[field_name] = existing_answers[question.pk]
layout_fields.append(
Div(Div(Field(field_name), css_class="level-left"), css_class="level")
)

layout_fields.append(
Div(
Expand All @@ -100,12 +113,75 @@ def clean_name(self):

return name

def clean_custom_data(self):
data = self.cleaned_data['custom_data']
# Convert boolean values to string for storage
if config.TEAM_CUSTOM_DATA_TYPE == 'boolean':
return str(data)
return data
def save(self, commit=True):
team = super().save(commit=commit)
if commit:
for question in self.questions:
raw = self.cleaned_data.get(f'question_{question.pk}')
if question.question_type == TeamDataQuestion.QuestionType.BOOLEAN:
value = str(bool(raw))
else:
value = raw or ''
TeamDataAnswer.objects.update_or_create(
team=team, question=question, defaults={'value': value}
)
return team


class TeamDataQuestionForm(ModelForm):
options = CharField(
required=False,
help_text='Comma-separated list of choices. Only used when Type is "Select".',
)

class Meta:
model = TeamDataQuestion
fields = ['name', 'description', 'question_type', 'required', 'visible_on_leaderboard', 'used_for_grouping']

def __init__(self, *args, hunt=None, **kwargs):
super().__init__(*args, **kwargs)
self.hunt = hunt or (self.instance.hunt if self.instance.hunt_id else None)

if self.instance.pk:
self.initial['options'] = ', '.join(self.instance.options)
url = reverse('puzzlehunt:staff:team_data_question_update', args=[self.hunt.pk, self.instance.pk])
else:
url = reverse('puzzlehunt:staff:team_data_question_create', args=[self.hunt.pk])

self.helper = FormHelper()
self.helper.form_class = 'block'
self.helper.attrs = {'hx-post': url, 'hx-target': '#team-data-question-list', 'hx-swap': 'innerHTML'}
self.helper.form_action = url
self.helper.layout = Layout(
Field('name'),
Field('description'),
Field('question_type'),
Field('options'),
Field('required'),
Field('visible_on_leaderboard'),
Field('used_for_grouping'),
Div(Div(Submit('save', 'Save', css_class="is-primary"), css_class="level-right"), css_class="level"),
)

def clean_options(self):
raw = self.cleaned_data.get('options', '')
return [opt.strip() for opt in raw.replace('\n', ',').split(',') if opt.strip()]

def clean(self):
cleaned_data = super().clean()
# Non-select questions never store options, regardless of stray text left in the field
if cleaned_data.get('question_type') != TeamDataQuestion.QuestionType.SELECT:
cleaned_data['options'] = []
return cleaned_data

def _post_clean(self):
# `options` is a plain CharField proxying the model's JSONField, so it isn't in
# Meta.fields and Django's ModelForm machinery won't copy it onto the instance for
# us. Set it before super()._post_clean() runs instance.full_clean(), so
# TeamDataQuestion.clean()'s select/options cross-check sees the right value and can
# attach its error to this form's `options` field.
self.instance.options = self.cleaned_data.get('options', [])
super()._post_clean()


class UserEditForm(ModelForm):
Expand Down
67 changes: 43 additions & 24 deletions puzzlehunt/hunt_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from crispy_forms.utils import render_crispy_form
from collections import OrderedDict
from pathlib import Path

from django.conf import settings
Expand All @@ -9,7 +10,7 @@
from django.urls import reverse_lazy
from django.views.decorators.http import require_POST
from django.contrib import messages
from django.db.models import F
from django.db.models import F, Prefetch
from django.db import transaction

from django_htmx.http import retarget, reswap
Expand All @@ -18,7 +19,7 @@
from constance import config

from .forms import AnswerForm
from .models import Puzzle, Submission, Prepuzzle, Hint, PuzzleStatus, Update
from .models import Puzzle, Submission, Prepuzzle, Hint, PuzzleStatus, Update, TeamDataAnswer
from .utils import get_media_file_model

import logging
Expand Down Expand Up @@ -365,43 +366,61 @@ def _process_teams_for_leaderboard(teams_queryset, ruleset):
return processed_teams


def prefetch_data_answers(teams_queryset, questions):
""" Attaches a filtered, select_related prefetch of TeamDataAnswer rows to `teams_queryset`,
so that later access to each team's `.prefetched_data_answers` costs one query total instead
of one per team. Must be called before the queryset is evaluated. """
if not questions:
return teams_queryset
return teams_queryset.prefetch_related(Prefetch(
'teamdataanswer_set',
queryset=TeamDataAnswer.objects.filter(question__in=questions).select_related('question'),
to_attr='prefetched_data_answers',
))


def set_data_answer_values(teams, questions):
""" For each team (already prefetched via prefetch_data_answers), attaches
`data_answer_values`: a list of display strings positionally aligned with `questions`,
using TeamDataAnswer.NO_ANSWER_DISPLAY for any question the team hasn't answered. """
for team in teams:
by_question = {a.question_id: a.display_value for a in getattr(team, 'prefetched_data_answers', [])}
team.data_answer_values = [by_question.get(q.id, TeamDataAnswer.NO_ANSWER_DISPLAY) for q in questions]


def hunt_leaderboard(request, hunt):
ruleset = hunt.teamrankingrule_set.order_by("rule_order").all()
data_questions = list(hunt.teamdataquestion_set.filter(visible_on_leaderboard=True).order_by("question_order"))

base_teams = hunt.team_set.exclude(playtester=True)
for rule in ruleset:
base_teams = rule.annotate_query(base_teams)
base_teams = prefetch_data_answers(base_teams, data_questions)

# Check if we should split the leaderboard by custom data
split_leaderboard = (
config.SPLIT_LEADERBOARD_BY_CUSTOM_DATA and
config.TEAM_CUSTOM_DATA_TYPE == 'boolean'
)
grouping_question = hunt.teamdataquestion_set.filter(used_for_grouping=True).first()

# Only split if there are teams in both categories
if split_leaderboard:
has_true_teams = base_teams.filter(custom_data="True").exists()
has_false_teams = base_teams.exclude(custom_data="True").exists()
split_leaderboard = has_true_teams and has_false_teams
processed_teams = _process_teams_for_leaderboard(base_teams, ruleset)
set_data_answer_values(processed_teams, data_questions)

context = {
'ruleset': ruleset,
'hunt': hunt,
'split_leaderboard': split_leaderboard,
'data_questions': data_questions,
}

if split_leaderboard:
# Process all three team lists
context['team_data'] = _process_teams_for_leaderboard(base_teams, ruleset)
context['team_data_true'] = _process_teams_for_leaderboard(
base_teams.filter(custom_data="True"), ruleset
)
context['team_data_false'] = _process_teams_for_leaderboard(
base_teams.exclude(custom_data="True"), ruleset
)
context['custom_data_name'] = config.TEAM_CUSTOM_DATA_NAME or "Custom Field"
if grouping_question:
group_answers = {
a.team_id: a.display_value
for a in TeamDataAnswer.objects.filter(question=grouping_question, team__in=base_teams)
}
groups = OrderedDict()
for team in processed_teams:
groups.setdefault(group_answers.get(team.id) or "Unspecified", []).append(team)
context['leaderboard_groups'] = [{'label': label, 'teams': teams} for label, teams in groups.items()]
context['grouping_question'] = grouping_question
context['team_data'] = processed_teams
else:
context['team_data'] = _process_teams_for_leaderboard(base_teams, ruleset)
context['team_data'] = processed_teams

return render(request, 'leaderboard.html', context)

Expand Down
6 changes: 3 additions & 3 deletions puzzlehunt/info_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def team_create(request):
messages.success(request, f"You have joined {team.name}")
return redirect('puzzlehunt:team_view', 'current')
else:
team_form = TeamForm()
team_form = TeamForm(hunt=current_hunt)

return render(request, "team_registration.html", {'form': team_form, 'current_hunt': current_hunt})

Expand All @@ -133,7 +133,7 @@ def team_join(request, pk=None):
current_hunt = Hunt.objects.get(is_current_hunt=True)
if not join_code:
error = "Join code cannot be empty."
return render(request, "team_registration.html", {"form": TeamForm(), 'errors': error, 'current_hunt': current_hunt})
return render(request, "team_registration.html", {"form": TeamForm(hunt=current_hunt), 'errors': error, 'current_hunt': current_hunt})

if pk is None:
current_team = current_hunt.team_from_user(request.user)
Expand All @@ -144,7 +144,7 @@ def team_join(request, pk=None):
team = current_hunt.team_set.get(join_code=join_code.upper())
except Team.DoesNotExist:
error = "No team with that join code exists."
return render(request, "team_registration.html", {"form": TeamForm(), 'errors': error, 'current_hunt': current_hunt})
return render(request, "team_registration.html", {"form": TeamForm(hunt=current_hunt), 'errors': error, 'current_hunt': current_hunt})
else:
team = get_object_or_404(Team, pk=pk)
possible_current_team = team.hunt.team_from_user(request.user)
Expand Down
Loading
Loading