diff --git a/puzzlehunt/admin.py b/puzzlehunt/admin.py index 30d74c6..68674a4 100644 --- a/puzzlehunt/admin.py +++ b/puzzlehunt/admin.py @@ -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 _ @@ -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 @@ -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'))}), @@ -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", { @@ -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') diff --git a/puzzlehunt/forms.py b/puzzlehunt/forms.py index 2495a50..6af0b54 100644 --- a/puzzlehunt/forms.py +++ b/puzzlehunt/forms.py @@ -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}) @@ -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( @@ -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): diff --git a/puzzlehunt/hunt_views.py b/puzzlehunt/hunt_views.py index a1a2009..9aa5f93 100644 --- a/puzzlehunt/hunt_views.py +++ b/puzzlehunt/hunt_views.py @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/puzzlehunt/info_views.py b/puzzlehunt/info_views.py index a3d2a50..b69261a 100644 --- a/puzzlehunt/info_views.py +++ b/puzzlehunt/info_views.py @@ -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}) @@ -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) @@ -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) diff --git a/puzzlehunt/migrations/0019_add_team_data_question_answer.py b/puzzlehunt/migrations/0019_add_team_data_question_answer.py new file mode 100644 index 0000000..d661784 --- /dev/null +++ b/puzzlehunt/migrations/0019_add_team_data_question_answer.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.30 on 2026-07-08 15:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzlehunt', '0018_alter_hunt_display_end_date_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TeamDataQuestion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The label shown to teams and on the leaderboard', max_length=100)), + ('description', models.CharField(blank=True, help_text='Optional help text shown under the field', max_length=255)), + ('question_type', models.CharField(choices=[('TEXT', 'Text'), ('BOOL', 'Yes/No'), ('SEL', 'Select (single choice)')], default='TEXT', help_text='The type of question', max_length=4)), + ('options', models.JSONField(blank=True, default=list, help_text="Option strings; only used when question_type is 'select'")), + ('question_order', models.IntegerField(help_text='The order in which the question is displayed')), + ('required', models.BooleanField(default=False, help_text='Must a team answer this to register/update?')), + ('visible_on_leaderboard', models.BooleanField(default=False, help_text='Show this as a column on the leaderboard')), + ('used_for_grouping', models.BooleanField(default=False, help_text='Split the leaderboard by this question (only one at a time per hunt)')), + ('hunt', models.ForeignKey(help_text='The hunt this question belongs to', on_delete=django.db.models.deletion.CASCADE, to='puzzlehunt.hunt')), + ], + options={ + 'ordering': ['question_order'], + 'unique_together': {('hunt', 'question_order')}, + }, + ), + migrations.CreateModel( + name='TeamDataAnswer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(blank=True, help_text="Stored answer; boolean as 'True'/'False', select as the chosen option text", max_length=200)), + ('question', models.ForeignKey(help_text='The question being answered', on_delete=django.db.models.deletion.CASCADE, to='puzzlehunt.teamdataquestion')), + ('team', models.ForeignKey(help_text='The team that gave this answer', on_delete=django.db.models.deletion.CASCADE, to='puzzlehunt.team')), + ], + options={ + 'verbose_name_plural': 'team data answers', + 'unique_together': {('question', 'team')}, + }, + ), + ] diff --git a/puzzlehunt/migrations/0020_migrate_custom_data_to_team_data_questions.py b/puzzlehunt/migrations/0020_migrate_custom_data_to_team_data_questions.py new file mode 100644 index 0000000..92b396d --- /dev/null +++ b/puzzlehunt/migrations/0020_migrate_custom_data_to_team_data_questions.py @@ -0,0 +1,113 @@ +""" +Backward-compatible data migration for the old global "custom team data" feature. + +LIMITATION: the old feature was configured via a single site-wide django-constance +value (TEAM_CUSTOM_DATA_NAME / _DESCRIPTION / _TYPE), which has no history - only +the value as of the time this migration runs is available. This migration applies +that single value retroactively to every hunt that has at least one team with a +non-empty legacy Team.custom_data value. If the site's custom field's name/type +changed over the life of the site, older hunts may end up mislabeled by this +migration and will need manual correction afterward via the new "Team Data +Questions" staff page. + +Only the constance database backend's own storage (the `constance.Constance` +table) is read here - NOT `from constance import config` (unsafe/live-registry +inside a migration) and NOT `settings.CONSTANCE_CONFIG` (by the time this +migration runs, the settings-file entries for the old keys have already been +removed in this same release, so they can no longer be used as a source of +defaults). Values not present in the `constance.Constance` table are assumed to +still be at their original hardcoded defaults (empty name = feature never +configured). +""" +from django.db import migrations + +# Original defaults from the now-removed CONSTANCE_CONFIG entries. +_DEFAULT_NAME = '' +_DEFAULT_DESCRIPTION = '' +_DEFAULT_TYPE = 'text' + + +def _get_constance_value(apps, key, default): + try: + Constance = apps.get_model('constance', 'Constance') + except LookupError: + return default + + from constance.codecs import loads + + row = Constance.objects.filter(key=key).first() + if row is None or row.value is None: + return default + return loads(row.value) + + +def migrate_custom_data(apps, schema_editor): + Hunt = apps.get_model('puzzlehunt', 'Hunt') + TeamDataQuestion = apps.get_model('puzzlehunt', 'TeamDataQuestion') + TeamDataAnswer = apps.get_model('puzzlehunt', 'TeamDataAnswer') + + name = _get_constance_value(apps, 'TEAM_CUSTOM_DATA_NAME', _DEFAULT_NAME) + description = _get_constance_value(apps, 'TEAM_CUSTOM_DATA_DESCRIPTION', _DEFAULT_DESCRIPTION) + data_type = _get_constance_value(apps, 'TEAM_CUSTOM_DATA_TYPE', _DEFAULT_TYPE) + + if not name: + return + + mapped_type = 'BOOL' if data_type == 'boolean' else 'TEXT' + + # NOTE: a single correlated filter() (not .exclude(), which applies relation-wide + # for multi-valued reverse relations and would drop a hunt entirely if *any* of its + # teams has an empty custom_data value) - this matches hunts with at least one team + # whose custom_data is non-empty. + hunts_with_data = Hunt.objects.filter(team__custom_data__gt='').distinct() + + for hunt in hunts_with_data: + question = TeamDataQuestion.objects.create( + hunt=hunt, + name=name, + description=description or '', + question_type=mapped_type, + options=[], + question_order=1, + required=False, + visible_on_leaderboard=False, + used_for_grouping=False, + ) + TeamDataAnswer.objects.bulk_create([ + TeamDataAnswer(question=question, team=team, value=team.custom_data) + for team in hunt.team_set.exclude(custom_data='') + ]) + + +def reverse_migrate_custom_data(apps, schema_editor): + TeamDataQuestion = apps.get_model('puzzlehunt', 'TeamDataQuestion') + TeamDataAnswer = apps.get_model('puzzlehunt', 'TeamDataAnswer') + + # Only questions this migration itself could have created follow this exact shape: + # question_order=1, not visible/grouping, and no options. Restrict the reverse to those, + # so a manually-added question that happens to share a hunt isn't touched or deleted. + created_questions = TeamDataQuestion.objects.filter( + question_order=1, visible_on_leaderboard=False, used_for_grouping=False, options=[] + ) + + for answer in TeamDataAnswer.objects.filter(question__in=created_questions).select_related('team'): + answer.team.custom_data = answer.value + answer.team.save(update_fields=['custom_data']) + + created_questions.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzlehunt', '0019_add_team_data_question_answer'), + # Needed so `apps.get_model('constance', 'Constance')` resolves in the historical + # apps registry below. Pinned to constance's latest migration as of django-constance + # 4.3.5; if constance ships new migrations later, this only requires 0003_drop_pickle + # to have run first, which remains true regardless of later constance migrations. + ('constance', '0003_drop_pickle'), + ] + + operations = [ + migrations.RunPython(migrate_custom_data, reverse_migrate_custom_data), + ] diff --git a/puzzlehunt/migrations/0021_remove_team_custom_data.py b/puzzlehunt/migrations/0021_remove_team_custom_data.py new file mode 100644 index 0000000..970bab8 --- /dev/null +++ b/puzzlehunt/migrations/0021_remove_team_custom_data.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.30 on 2026-07-08 16:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('puzzlehunt', '0020_migrate_custom_data_to_team_data_questions'), + ] + + operations = [ + migrations.RemoveField( + model_name='team', + name='custom_data', + ), + ] diff --git a/puzzlehunt/models.py b/puzzlehunt/models.py index 9a740c3..1e28f46 100644 --- a/puzzlehunt/models.py +++ b/puzzlehunt/models.py @@ -663,11 +663,6 @@ class Team(models.Model): members = models.ManyToManyField( settings.AUTH_USER_MODEL, help_text="Members of this team") - custom_data = models.CharField( - max_length=100, - blank=True, - help_text=f"A field for custom registration data" - ) join_code = models.CharField( max_length=8, default=team_key_gen, @@ -707,6 +702,9 @@ def is_normal_team(self): """ A boolean indicating whether the team is a normal (non-playtester) team """ return not self.playtester + def get_data_answer(self, question): + return self.teamdataanswer_set.filter(question=question).first() + @property def playtest_started(self): """ A boolean indicating whether the team is currently allowed to be playtesting """ @@ -1435,6 +1433,117 @@ def natural_key(self): return self.hunt.natural_key() + (self.rule_order,) +class TeamDataQuestionManager(models.Manager): + def get_by_natural_key(self, hunt_name, hunt_start_date, question_order): + hunt = Hunt.objects.get_by_natural_key(hunt_name, hunt_start_date) + return self.get(hunt=hunt, question_order=question_order) + + +class TeamDataQuestion(models.Model): + """ A per-hunt custom registration question, optionally shown on the leaderboard """ + + class QuestionType(models.TextChoices): + TEXT = 'TEXT', 'Text' + BOOLEAN = 'BOOL', 'Yes/No' + SELECT = 'SEL', 'Select (single choice)' + + class Meta: + unique_together = ('hunt', 'question_order') + ordering = ['question_order'] + + objects = TeamDataQuestionManager() + + hunt = models.ForeignKey( + Hunt, + on_delete=models.CASCADE, + help_text="The hunt this question belongs to") + + name = models.CharField( + max_length=100, + help_text="The label shown to teams and on the leaderboard") + + description = models.CharField( + max_length=255, + blank=True, + help_text="Optional help text shown under the field") + + question_type = models.CharField( + max_length=4, + choices=QuestionType.choices, + default=QuestionType.TEXT, + help_text="The type of question") + + options = models.JSONField( + default=list, + blank=True, + help_text="Option strings; only used when question_type is 'select'") + + question_order = models.IntegerField( + help_text="The order in which the question is displayed") + + required = models.BooleanField( + default=False, + help_text="Must a team answer this to register/update?") + + visible_on_leaderboard = models.BooleanField( + default=False, + help_text="Show this as a column on the leaderboard") + + used_for_grouping = models.BooleanField( + default=False, + help_text="Split the leaderboard by this question (only one at a time per hunt)") + + def __str__(self): + return f"{self.hunt.name}: {self.name}" + + def natural_key(self): + return self.hunt.natural_key() + (self.question_order,) + + def clean(self): + super().clean() + if self.question_type == self.QuestionType.SELECT and not self.options: + raise ValidationError({'options': "Select questions must have at least one option."}) + if self.question_type != self.QuestionType.SELECT and self.options: + raise ValidationError({'options': "Options are only used for select questions."}) + + +class TeamDataAnswer(models.Model): + """ A team's answer to one TeamDataQuestion (mirrors the PuzzleStatus team/puzzle through-table) """ + + class Meta: + verbose_name_plural = "team data answers" + unique_together = ('question', 'team') + + # The single canonical placeholder for "no answer" - used both below for a blank stored + # value and by callers (e.g. leaderboard/participant-info rendering) for a team that has + # no TeamDataAnswer row at all, so there's only one place defining what that looks like. + NO_ANSWER_DISPLAY = "—" + + question = models.ForeignKey( + TeamDataQuestion, + on_delete=models.CASCADE, + help_text="The question being answered") + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + help_text="The team that gave this answer") + + value = models.CharField( + max_length=200, + blank=True, + help_text="Stored answer; boolean as 'True'/'False', select as the chosen option text") + + def __str__(self): + return f"{self.team.name} -> {self.question.name}: {self.value}" + + @property + def display_value(self): + if self.question.question_type == TeamDataQuestion.QuestionType.BOOLEAN: + return "Yes" if self.value == "True" else "No" + return self.value or self.NO_ANSWER_DISPLAY + + class Update(models.Model): """ A class to represent puzzle/hunt updates """ hunt = models.ForeignKey( diff --git a/puzzlehunt/staff_views.py b/puzzlehunt/staff_views.py index e8c124d..83cccaa 100644 --- a/puzzlehunt/staff_views.py +++ b/puzzlehunt/staff_views.py @@ -17,6 +17,7 @@ from django.forms import ValidationError from django.views.decorators.http import require_GET, require_POST from django.shortcuts import redirect, render, get_object_or_404 +from django.db import transaction from django.db.models import F, Max, Count, Subquery, OuterRef, PositiveIntegerField, Min from django.db.models.functions import Coalesce from django.http import HttpResponse @@ -29,8 +30,10 @@ from django_sendfile import sendfile from .utils import create_media_files, get_media_file_model, get_media_file_parent_model, create_hunt_export_zip, import_hunt_from_zip, import_hunt_from_zip, validate_hunt_zip -from .hunt_views import protected_static -from .models import Hunt, Team, Event, PuzzleStatus, Submission, Hint, User, Puzzle, SolutionFile, HuntFile +from .hunt_views import protected_static, prefetch_data_answers, set_data_answer_values +from .models import Hunt, Team, Event, PuzzleStatus, Submission, Hint, User, Puzzle, SolutionFile, HuntFile, \ + TeamDataQuestion +from .forms import TeamDataQuestionForm from .tasks import import_hunt_background from .config_parser import parse_config, process_config_rules @@ -240,11 +243,17 @@ def participant_info(request, hunt): # Generate email string for clipboard emails_string = ', '.join([user.email for user in regular_participants]) - + + data_questions = list(hunt.teamdataquestion_set.order_by("question_order")) + all_teams = list(prefetch_data_answers(hunt.team_set.order_by('name'), data_questions)) + set_data_answer_values(all_teams, data_questions) + context = { 'hunt': hunt, 'stats': stats, - 'emails_string': emails_string + 'emails_string': emails_string, + 'data_questions': data_questions, + 'teams': all_teams, } return render(request, "staff_participant_info.html", context) @@ -795,6 +804,104 @@ def hunt_puzzles(request, hunt): return render(request, "staff_hunt_puzzles.html", context) +def _questions_with_edit_forms(hunt): + """ The hunt's questions, each carrying a bound-to-instance TeamDataQuestionForm for inline editing. """ + questions = list(hunt.teamdataquestion_set.order_by('question_order')) + for question in questions: + question.edit_form = TeamDataQuestionForm(instance=question) + return questions + + +@staff_member_required +def team_data_questions(request, hunt): + """ Staff page for configuring the per-hunt custom team data registration questions. """ + context = { + 'hunt': hunt, + 'questions': _questions_with_edit_forms(hunt), + 'create_form': TeamDataQuestionForm(hunt=hunt), + } + return render(request, "staff_team_data_questions.html", context) + + +@require_POST +@staff_member_required +def team_data_question_create(request, hunt): + next_order = (hunt.teamdataquestion_set.aggregate(Max('question_order'))['question_order__max'] or 0) + 1 + form = TeamDataQuestionForm( + request.POST, hunt=hunt, instance=TeamDataQuestion(hunt=hunt, question_order=next_order) + ) + if form.is_valid(): + question = form.save() + if question.used_for_grouping: + hunt.teamdataquestion_set.exclude(pk=question.pk).update(used_for_grouping=False) + messages.success(request, "Question added.") + else: + messages.error(request, "; ".join(f"{field}: {', '.join(errors)}" for field, errors in form.errors.items())) + + if request.htmx: + return render(request, "partials/_team_data_question_list.html", + {'hunt': hunt, 'questions': _questions_with_edit_forms(hunt)}) + return redirect('puzzlehunt:staff:team_data_questions', hunt.pk) + + +@require_POST +@staff_member_required +def team_data_question_update(request, hunt, pk): + question = get_object_or_404(TeamDataQuestion, pk=pk, hunt=hunt) + form = TeamDataQuestionForm(request.POST, instance=question) + if form.is_valid(): + question = form.save() + if question.used_for_grouping: + hunt.teamdataquestion_set.exclude(pk=question.pk).update(used_for_grouping=False) + messages.success(request, "Question updated.") + else: + messages.error(request, "; ".join(f"{field}: {', '.join(errors)}" for field, errors in form.errors.items())) + + if request.htmx: + return render(request, "partials/_team_data_question_list.html", + {'hunt': hunt, 'questions': _questions_with_edit_forms(hunt)}) + return redirect('puzzlehunt:staff:team_data_questions', hunt.pk) + + +@require_POST +@staff_member_required +def team_data_question_delete(request, hunt, pk): + question = get_object_or_404(TeamDataQuestion, pk=pk, hunt=hunt) + question.delete() + messages.success(request, "Question deleted.") + + if request.htmx: + return render(request, "partials/_team_data_question_list.html", + {'hunt': hunt, 'questions': _questions_with_edit_forms(hunt)}) + return redirect('puzzlehunt:staff:team_data_questions', hunt.pk) + + +@require_POST +@staff_member_required +def team_data_question_move(request, hunt, pk): + question = get_object_or_404(TeamDataQuestion, pk=pk, hunt=hunt) + direction = request.POST.get('direction') + questions = list(hunt.teamdataquestion_set.order_by('question_order')) + index = next((i for i, q in enumerate(questions) if q.pk == question.pk), None) + + if index is not None: + neighbor_index = index - 1 if direction == 'up' else index + 1 + if 0 <= neighbor_index < len(questions): + neighbor = questions[neighbor_index] + with transaction.atomic(): + # Swap through a placeholder value to avoid violating unique_together mid-update + TeamDataQuestion.objects.filter(pk=question.pk).update(question_order=-1) + TeamDataQuestion.objects.filter(pk=neighbor.pk).update(question_order=question.question_order) + TeamDataQuestion.objects.filter(pk=question.pk).update(question_order=neighbor.question_order) + + if request.htmx: + return render(request, "partials/_team_data_question_list.html", + {'hunt': hunt, 'questions': _questions_with_edit_forms(hunt)}) + return redirect('puzzlehunt:staff:team_data_questions', hunt.pk) + + return _question_list_response(request, hunt) + + @require_POST @staff_member_required def file_set_main(request, parent_type, pk): diff --git a/puzzlehunt/templates/leaderboard.html b/puzzlehunt/templates/leaderboard.html index 4759728..a86c780 100644 --- a/puzzlehunt/templates/leaderboard.html +++ b/puzzlehunt/templates/leaderboard.html @@ -11,7 +11,10 @@ @context: hunt: The Hunt object being displayed ruleset: List of LeaderboardRule objects defining the columns and ranking rules + data_questions: List of TeamDataQuestion objects visible on the leaderboard team_data: List of Team objects with their ranking data + grouping_question: TeamDataQuestion used to split the leaderboard into groups, or None + leaderboard_groups: List of {label, teams} dicts, present when grouping_question is set {% endcomment %} {% block title_meta_elements %} @@ -35,29 +38,29 @@
{{ question.description }}
{% endif %} + {% if question.question_type == "SEL" %} +Options: {{ question.options|join:", " }}
+ {% endif %} +No custom questions configured for this hunt.
+ {% endif %} diff --git a/puzzlehunt/templates/staff_hunt_base.html b/puzzlehunt/templates/staff_hunt_base.html index 6a55717..4cf4339 100644 --- a/puzzlehunt/templates/staff_hunt_base.html +++ b/puzzlehunt/templates/staff_hunt_base.html @@ -116,6 +116,10 @@ href="{% url 'puzzlehunt:staff:hunt_puzzles' hunt.id %}"> Puzzles +Enter search terms to find participants or teams
| Team | + {% for question in data_questions %} +{{ question.name }} | + {% endfor %} +
|---|---|
| {{ team.name }} | + {% for value in team.data_answer_values %} +{{ value }} | + {% endfor %} +
+ These questions are shown to teams when they register or update their team, and can optionally be + shown as leaderboard columns or used to split the leaderboard into groups. +
+ +Add Question
+ +