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 @@

Leaderboard - {{hunt.name}}



- {% if split_leaderboard %} -
-
- + {% if grouping_question %} +
+
+
+
+ Grouped by {{ grouping_question.name }} +
+
+
+ +
-
+
{% include "partials/_leaderboard_table.html" with teams=team_data %}
-
- {% include "partials/_leaderboard_table.html" with teams=team_data_true %} -
-
- {% include "partials/_leaderboard_table.html" with teams=team_data_false %} +
+ {% for group in leaderboard_groups %} +

{{ group.label }}

+ {% include "partials/_leaderboard_table.html" with teams=group.teams %} + {% endfor %}
{% else %} diff --git a/puzzlehunt/templates/partials/_leaderboard_table.html b/puzzlehunt/templates/partials/_leaderboard_table.html index 748d416..081252e 100644 --- a/puzzlehunt/templates/partials/_leaderboard_table.html +++ b/puzzlehunt/templates/partials/_leaderboard_table.html @@ -2,8 +2,9 @@ @template: _leaderboard_table.html @description: Reusable leaderboard table partial for displaying team rankings. @context: - teams: List of Team objects with computed_rank attribute + teams: List of Team objects with computed_rank and data_answer_values attributes ruleset: List of LeaderboardRule objects defining the columns and ranking rules + data_questions: List of TeamDataQuestion objects visible on the leaderboard hunt: The Hunt object being displayed {% endcomment %} @@ -21,6 +22,9 @@ {{ rule.display_name }} {% endif %} {% endfor %} + {% for question in data_questions %} + {{ question.name }} + {% endfor %} {% for team in teams %} @@ -43,6 +47,9 @@ {% endif %} {% endif %} {% endfor %} + {% for value in team.data_answer_values %} + {{ value }} + {% endfor %} {% endfor %} diff --git a/puzzlehunt/templates/partials/_team_data_question_list.html b/puzzlehunt/templates/partials/_team_data_question_list.html new file mode 100644 index 0000000..04dad8f --- /dev/null +++ b/puzzlehunt/templates/partials/_team_data_question_list.html @@ -0,0 +1,76 @@ +{% load crispy_forms_tags %} +{% comment %} +@template: _team_data_question_list.html +@description: Renders the ordered list of TeamDataQuestion rows for a hunt, each editable/deletable/movable via HTMX. +@context: + hunt: The Hunt object + questions: List of TeamDataQuestion objects for the hunt, ordered by question_order, each with an edit_form +{% endcomment %} + {% if questions %} + {% for question in questions %} +
+
+
+
+
+ + +
+
+
+ {% if question.question_type == "TEXT" %} + Text + {% elif question.question_type == "BOOL" %} + Yes/No + {% else %} + Select + {% endif %} +
+
+ {{ question.name }} + {% if question.description %}

{{ question.description }}

{% endif %} + {% if question.question_type == "SEL" %} +

Options: {{ question.options|join:", " }}

+ {% endif %} +
+ {% if question.required %}Required{% endif %} + {% if question.visible_on_leaderboard %}On Leaderboard{% endif %} + {% if question.used_for_grouping %}Groups Leaderboard{% endif %} +
+
+
+
+ + +
+
+
+
+
+ {% crispy question.edit_form %} + +
+
+ {% endfor %} + {% else %} +

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 +
  • + Team Data Questions +
  • Hunt Template diff --git a/puzzlehunt/templates/staff_participant_info.html b/puzzlehunt/templates/staff_participant_info.html index 46697e2..734abbb 100644 --- a/puzzlehunt/templates/staff_participant_info.html +++ b/puzzlehunt/templates/staff_participant_info.html @@ -115,6 +115,31 @@

    Search Participants

    Enter search terms to find participants or teams

  • + + + {% if data_questions %} +
    +

    Team Data

    +
    + + + + {% for question in data_questions %} + + {% endfor %} + + {% for team in teams %} + + + {% for value in team.data_answer_values %} + + {% endfor %} + + {% endfor %} +
    Team{{ question.name }}
    {{ team.name }}{{ value }}
    +
    +
    + {% endif %}
    -{% endblock %} \ No newline at end of file +{% endblock %} \ No newline at end of file diff --git a/puzzlehunt/templates/staff_team_data_questions.html b/puzzlehunt/templates/staff_team_data_questions.html new file mode 100644 index 0000000..1777679 --- /dev/null +++ b/puzzlehunt/templates/staff_team_data_questions.html @@ -0,0 +1,56 @@ +{% extends 'staff_hunt_base.html' %} +{% load crispy_forms_tags %} +{% comment %} +@template: staff_team_data_questions.html +@description: Staff interface for configuring per-hunt custom team data registration questions. +@extends: staff_hunt_base.html +@blocks: + staff_content: Displays the ordered question list and an "add question" modal +@context: + hunt: The current Hunt object + questions: List of TeamDataQuestion objects for the hunt, ordered by question_order, each with an edit_form + create_form: TeamDataQuestionForm for adding a new question +{% endcomment %} + +{% block title_meta_elements %} + {% with title="Staff Team Data Questions" %} {{ block.super }} {% endwith %} +{% endblock title_meta_elements %} + +{% block staff_content %} +
    +
    +
    +
    +

    Team Data Questions

    +
    +
    + +
    +
    +

    + 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. +

    + +
    + {% include "partials/_team_data_question_list.html" %} +
    + + +
    +
    +{% endblock %} diff --git a/puzzlehunt/tests/test_team_data_questions.py b/puzzlehunt/tests/test_team_data_questions.py new file mode 100644 index 0000000..cace721 --- /dev/null +++ b/puzzlehunt/tests/test_team_data_questions.py @@ -0,0 +1,271 @@ +import pytest +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils import timezone + +from puzzlehunt.models import Hunt, Team, TeamDataAnswer, TeamDataQuestion + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def other_hunt(): + """A second hunt, independent from basic_hunt, used for isolation checks.""" + return Hunt.objects.create( + name="Other Hunt", + is_current_hunt=False, + team_size_limit=4, + start_date=timezone.now(), + end_date=timezone.now() + timezone.timedelta(days=1), + ) + + +# --- Model behavior --- + +def test_question_creation(basic_hunt): + question = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Fun Fact", question_type=TeamDataQuestion.QuestionType.TEXT, question_order=1 + ) + assert question.hunt == basic_hunt + assert question.question_type == "TEXT" + + +def test_question_order_unique_per_hunt(basic_hunt): + TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + with pytest.raises(ValidationError): + q2 = TeamDataQuestion(hunt=basic_hunt, name="Q2", question_order=1) + q2.full_clean() + + +def test_question_order_can_repeat_across_hunts(basic_hunt, other_hunt): + TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + # Should not raise - question_order is only unique within a hunt + TeamDataQuestion.objects.create(hunt=other_hunt, name="Q1", question_order=1) + + +def test_select_question_requires_options(basic_hunt): + question = TeamDataQuestion( + hunt=basic_hunt, name="Size", question_type=TeamDataQuestion.QuestionType.SELECT, + question_order=1, options=[] + ) + with pytest.raises(ValidationError): + question.full_clean() + + +def test_non_select_question_rejects_options(basic_hunt): + question = TeamDataQuestion( + hunt=basic_hunt, name="Fun Fact", question_type=TeamDataQuestion.QuestionType.TEXT, + question_order=1, options=["A", "B"] + ) + with pytest.raises(ValidationError): + question.full_clean() + + +def test_answer_unique_per_question_and_team(basic_hunt): + question = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + team = Team.objects.create(name="Team A", hunt=basic_hunt) + TeamDataAnswer.objects.create(question=question, team=team, value="hello") + with pytest.raises(ValidationError): + answer = TeamDataAnswer(question=question, team=team, value="world") + answer.full_clean() + + +def test_deleting_question_cascades_to_answers(basic_hunt): + question = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + team = Team.objects.create(name="Team A", hunt=basic_hunt) + TeamDataAnswer.objects.create(question=question, team=team, value="hello") + question.delete() + assert TeamDataAnswer.objects.count() == 0 + + +def test_deleting_team_cascades_to_answers(basic_hunt): + question = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + team = Team.objects.create(name="Team A", hunt=basic_hunt) + TeamDataAnswer.objects.create(question=question, team=team, value="hello") + team.delete() + assert TeamDataAnswer.objects.count() == 0 + + +def test_boolean_answer_display_value(basic_hunt): + question = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="First timer?", question_type=TeamDataQuestion.QuestionType.BOOLEAN, question_order=1 + ) + team = Team.objects.create(name="Team A", hunt=basic_hunt) + yes = TeamDataAnswer.objects.create(question=question, team=team, value="True") + assert yes.display_value == "Yes" + + +# --- Registration form / per-hunt isolation --- + +def test_registration_form_only_shows_questions_for_current_hunt(client, basic_hunt, other_hunt, basic_user): + TeamDataQuestion.objects.create(hunt=basic_hunt, name="Current Hunt Question", question_order=1) + TeamDataQuestion.objects.create(hunt=other_hunt, name="Other Hunt Question", question_order=1) + + client.force_login(basic_user) + response = client.get(reverse('puzzlehunt:team_create')) + content = response.content.decode() + assert "Current Hunt Question" in content + assert "Other Hunt Question" not in content + + +def test_registration_rejects_invalid_select_option(client, basic_hunt, basic_user): + question = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Shirt Size", question_type=TeamDataQuestion.QuestionType.SELECT, + options=["S", "M", "L"], question_order=1, required=True + ) + client.force_login(basic_user) + response = client.post(reverse('puzzlehunt:team_create'), { + 'name': 'Test Team', + f'question_{question.pk}': 'XL', # not a valid option + }) + assert response.status_code == 200 + assert not Team.objects.filter(name='Test Team').exists() + + +def test_registration_saves_multiple_question_types(client, basic_hunt, basic_user): + q_text = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Motto", question_order=1) + q_bool = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="First timer?", question_type=TeamDataQuestion.QuestionType.BOOLEAN, question_order=2 + ) + q_sel = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Size", question_type=TeamDataQuestion.QuestionType.SELECT, + options=["S", "M", "L"], question_order=3 + ) + + client.force_login(basic_user) + response = client.post(reverse('puzzlehunt:team_create'), { + 'name': 'Multi Team', + f'question_{q_text.pk}': 'Puzzles are fun', + f'question_{q_bool.pk}': 'on', + f'question_{q_sel.pk}': 'M', + }) + assert response.status_code == 302 + + team = Team.objects.get(name='Multi Team') + answers = {a.question_id: a.value for a in team.teamdataanswer_set.all()} + assert answers[q_text.pk] == 'Puzzles are fun' + assert answers[q_bool.pk] == 'True' + assert answers[q_sel.pk] == 'M' + + +# --- Staff CRUD views --- + +def test_staff_can_create_question(client, basic_hunt, staff_user): + client.force_login(staff_user) + response = client.post( + reverse('puzzlehunt:staff:team_data_question_create', args=[basic_hunt.pk]), + {'name': 'Dietary Restrictions', 'question_type': 'TEXT'}, + ) + assert response.status_code in (200, 302) + assert TeamDataQuestion.objects.filter(hunt=basic_hunt, name='Dietary Restrictions').exists() + + +def test_non_staff_cannot_create_question(client, basic_hunt, basic_user): + client.force_login(basic_user) + response = client.post( + reverse('puzzlehunt:staff:team_data_question_create', args=[basic_hunt.pk]), + {'name': 'Dietary Restrictions', 'question_type': 'TEXT'}, + ) + assert response.status_code in (302, 403) + assert not TeamDataQuestion.objects.filter(hunt=basic_hunt).exists() + + +def test_staff_create_select_without_options_is_rejected(client, basic_hunt, staff_user): + client.force_login(staff_user) + client.post( + reverse('puzzlehunt:staff:team_data_question_create', args=[basic_hunt.pk]), + {'name': 'Size', 'question_type': 'SEL', 'options': ''}, + ) + assert not TeamDataQuestion.objects.filter(hunt=basic_hunt, name='Size').exists() + + +def test_staff_update_question(client, basic_hunt, staff_user): + question = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Old Name", question_order=1) + client.force_login(staff_user) + client.post( + reverse('puzzlehunt:staff:team_data_question_update', args=[basic_hunt.pk, question.pk]), + {'name': 'New Name', 'question_type': 'TEXT'}, + ) + question.refresh_from_db() + assert question.name == 'New Name' + + +def test_staff_delete_question(client, basic_hunt, staff_user): + question = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + client.force_login(staff_user) + client.post(reverse('puzzlehunt:staff:team_data_question_delete', args=[basic_hunt.pk, question.pk])) + assert not TeamDataQuestion.objects.filter(pk=question.pk).exists() + + +def test_staff_move_question_swaps_order(client, basic_hunt, staff_user): + q1 = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1) + q2 = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q2", question_order=2) + + client.force_login(staff_user) + client.post( + reverse('puzzlehunt:staff:team_data_question_move', args=[basic_hunt.pk, q2.pk]), + {'direction': 'up'}, + ) + q1.refresh_from_db() + q2.refresh_from_db() + assert q2.question_order == 1 + assert q1.question_order == 2 + + +def test_used_for_grouping_is_exclusive_per_hunt(client, basic_hunt, staff_user): + q1 = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q1", question_order=1, used_for_grouping=True) + q2 = TeamDataQuestion.objects.create(hunt=basic_hunt, name="Q2", question_order=2) + + client.force_login(staff_user) + client.post( + reverse('puzzlehunt:staff:team_data_question_update', args=[basic_hunt.pk, q2.pk]), + {'name': 'Q2', 'question_type': 'TEXT', 'used_for_grouping': 'on'}, + ) + q1.refresh_from_db() + q2.refresh_from_db() + assert q1.used_for_grouping is False + assert q2.used_for_grouping is True + + +# --- Leaderboard --- + +def test_leaderboard_shows_visible_columns_only(client, basic_hunt, basic_user): + TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Shown Question", question_order=1, visible_on_leaderboard=True + ) + TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Hidden Question", question_order=2, visible_on_leaderboard=False + ) + Team.objects.create(name="Team A", hunt=basic_hunt) + + client.force_login(basic_user) + response = client.get(reverse('puzzlehunt:hunt_leaderboard', args=[basic_hunt.pk])) + content = response.content.decode() + assert "Shown Question" in content + assert "Hidden Question" not in content + + +def test_leaderboard_groups_by_select_question(client, basic_hunt, basic_user): + question = TeamDataQuestion.objects.create( + hunt=basic_hunt, name="Division", question_type=TeamDataQuestion.QuestionType.SELECT, + options=["Rookie", "Veteran"], question_order=1, used_for_grouping=True + ) + t1 = Team.objects.create(name="Team A", hunt=basic_hunt) + t2 = Team.objects.create(name="Team B", hunt=basic_hunt) + TeamDataAnswer.objects.create(question=question, team=t1, value="Rookie") + TeamDataAnswer.objects.create(question=question, team=t2, value="Veteran") + + client.force_login(basic_user) + response = client.get(reverse('puzzlehunt:hunt_leaderboard', args=[basic_hunt.pk])) + content = response.content.decode() + assert "Grouped by Division" in content + assert "Rookie" in content + assert "Veteran" in content + + +def test_leaderboard_no_grouping_when_no_question_configured(client, basic_hunt, basic_user): + Team.objects.create(name="Team A", hunt=basic_hunt) + client.force_login(basic_user) + response = client.get(reverse('puzzlehunt:hunt_leaderboard', args=[basic_hunt.pk])) + content = response.content.decode() + assert "Grouped by" not in content diff --git a/puzzlehunt/tests/test_team_views.py b/puzzlehunt/tests/test_team_views.py index a4ea82c..ae735b8 100644 --- a/puzzlehunt/tests/test_team_views.py +++ b/puzzlehunt/tests/test_team_views.py @@ -10,8 +10,7 @@ def test_team_create_view_success(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_create') response = client.post(url, { - 'name': 'View Test Team', - 'custom_data': '' + 'name': 'View Test Team' }) assert response.status_code == 302 # Redirect after success @@ -31,8 +30,7 @@ def test_team_create_view_duplicate_name(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_create') response = client.post(url, { - 'name': 'Existing Team', - 'custom_data': '' + 'name': 'Existing Team' }) assert response.status_code == 200 # Returns to form with errors assert Team.objects.count() == 1 # No new team created @@ -44,8 +42,7 @@ def test_team_create_view_invalid_name(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_create') response = client.post(url, { - 'name': '!@#$%', - 'custom_data': '' + 'name': '!@#$%' }) assert response.status_code == 200 # Returns to form with errors assert Team.objects.count() == 0 @@ -63,8 +60,7 @@ def test_team_update_view_success(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_update', kwargs={'pk': team.pk}) response = client.post(url, { - 'name': 'Updated Via View', - 'custom_data': '' + 'name': 'Updated Via View' }, HTTP_HX_REQUEST='true') assert response.status_code == 200 @@ -88,8 +84,7 @@ def test_team_update_view_duplicate_name(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_update', kwargs={'pk': team.pk}) response = client.post(url, { - 'name': 'Existing Team', - 'custom_data': '' + 'name': 'Existing Team' }, HTTP_HX_REQUEST='true') assert response.status_code == 200 @@ -113,8 +108,7 @@ def test_team_update_view_unauthorized(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_update', kwargs={'pk': team.pk}) response = client.post(url, { - 'name': 'Should Not Update', - 'custom_data': '' + 'name': 'Should Not Update' }) assert response.status_code == 403 @@ -358,7 +352,6 @@ def test_team_update_view_invalid_non_htmx(client, basic_hunt, basic_user): response = client.post(url, { 'name': '!@#$%', # Invalid name - 'custom_data': '' }) # The view redirects even with invalid data - this appears to be a bug in the view @@ -379,8 +372,7 @@ def test_team_update_view_success_non_htmx(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_update', kwargs={'pk': team.pk}) response = client.post(url, { - 'name': 'Updated Via View', - 'custom_data': '' + 'name': 'Updated Via View' }) assert response.status_code == 302 # Redirect after success @@ -400,8 +392,7 @@ def test_team_create_view_already_has_team(client, basic_hunt, basic_user): url = reverse('puzzlehunt:team_create') response = client.post(url, { - 'name': 'New Team', - 'custom_data': '' + 'name': 'New Team' }) assert response.status_code == 302 # Redirects to existing team assert Team.objects.count() == 1 # No new team created diff --git a/puzzlehunt/tests/test_user_flows.py b/puzzlehunt/tests/test_user_flows.py index 414ec3f..32d21ce 100644 --- a/puzzlehunt/tests/test_user_flows.py +++ b/puzzlehunt/tests/test_user_flows.py @@ -90,7 +90,6 @@ def test_hunt_experience(client, setup_puzzles, normal_user): # Test 3: Create and join team response = client.post(reverse('puzzlehunt:team_create'), { 'name': 'Test Team', - 'custom_data': '' }) assert response.status_code == 302 team = Team.objects.get(name='Test Team') diff --git a/puzzlehunt/urls.py b/puzzlehunt/urls.py index 36e9692..fa9bb8c 100644 --- a/puzzlehunt/urls.py +++ b/puzzlehunt/urls.py @@ -88,6 +88,11 @@ path('hunt//template/preview/', staff_views.preview_template, name='preview_template'), path('hunt//config/', staff_views.hunt_config, name='hunt_config'), path('hunt//config-tester/', staff_views.config_tester, name='config_tester'), + path('hunt//team-data/', staff_views.team_data_questions, name='team_data_questions'), + path('hunt//team-data/create/', staff_views.team_data_question_create, name='team_data_question_create'), + path('hunt//team-data//update/', staff_views.team_data_question_update, name='team_data_question_update'), + path('hunt//team-data//delete/', staff_views.team_data_question_delete, name='team_data_question_delete'), + path('hunt//team-data//move/', staff_views.team_data_question_move, name='team_data_question_move'), path('hunt//puzzles/', staff_views.hunt_puzzles, name='hunt_puzzles'), path('hunt//set_current/', staff_views.hunt_set_current, name='hunt_set_current'), path('hunt//export/', staff_views.export_hunt, name='hunt_export'), diff --git a/puzzlehunt/utils.py b/puzzlehunt/utils.py index 4013abc..da7cb7f 100644 --- a/puzzlehunt/utils.py +++ b/puzzlehunt/utils.py @@ -15,7 +15,7 @@ from django.core.files import File from django_eventstream.channelmanager import DefaultChannelManager from .models import PuzzleFile, SolutionFile, HuntFile, PrepuzzleFile, Puzzle, Hunt, Prepuzzle, Team, TeamRankingRule, \ - CannedHint, Response, Hint, Update, PuzzleStatus, Submission, User + CannedHint, Response, Hint, Update, PuzzleStatus, Submission, User, TeamDataQuestion, TeamDataAnswer from django.core.files.storage import default_storage @@ -196,6 +196,7 @@ def create_hunt_export_zip(hunt, zip_path, include_activity=False): models_to_export = { 'hunt.json': Hunt.objects.filter(pk=hunt.pk), 'ranking_rules.json': TeamRankingRule.objects.filter(hunt=hunt), + 'team_data_questions.json': TeamDataQuestion.objects.filter(hunt=hunt), 'puzzles.json': Puzzle.objects.filter(hunt=hunt), 'puzzle_files.json': PuzzleFile.objects.filter(parent__hunt=hunt).select_related('parent'), 'solution_files.json': SolutionFile.objects.filter(parent__hunt=hunt).select_related('parent'), @@ -213,6 +214,7 @@ def create_hunt_export_zip(hunt, zip_path, include_activity=False): 'updates.json': Update.objects.filter(hunt=hunt).select_related('hunt', 'puzzle'), 'puzzle_statuses.json': PuzzleStatus.objects.filter(puzzle__hunt=hunt).select_related('puzzle', 'team', 'team__hunt'), 'submissions.json': Submission.objects.filter(puzzle__hunt=hunt).select_related('puzzle', 'team', 'team__hunt', 'puzzle__hunt'), + 'team_data_answers.json': TeamDataAnswer.objects.filter(team__hunt=hunt).select_related('team', 'team__hunt', 'question'), } models_to_export.update(activity_models) @@ -291,6 +293,7 @@ def validate_hunt_zip(zip_path: str | Path, include_activity: bool = False) -> N 'file_references.json', 'hunt.json', 'ranking_rules.json', + 'team_data_questions.json', 'puzzles.json', 'puzzle_files.json', 'solution_files.json', @@ -306,7 +309,8 @@ def validate_hunt_zip(zip_path: str | Path, include_activity: bool = False) -> N 'hints.json', 'updates.json', 'puzzle_statuses.json', - 'submissions.json' + 'submissions.json', + 'team_data_answers.json' } with zipfile.ZipFile(zip_path, 'r') as zip_file: @@ -468,7 +472,7 @@ def import_hunt_from_zip(zip_path: str | Path, include_activity: bool = False) - print(f"Prepuzzle file {file_obj.relative_name} not found in zip") # 5. Import remaining models that don't need special handling - for filename in ['responses.json', 'canned_hints.json', 'ranking_rules.json']: + for filename in ['responses.json', 'canned_hints.json', 'ranking_rules.json', 'team_data_questions.json']: for obj in serializers.deserialize('json', zip_file.read(filename)): obj.save() @@ -479,7 +483,8 @@ def import_hunt_from_zip(zip_path: str | Path, include_activity: bool = False) - 'updates.json', # Updates depend on Hunt and optionally Puzzle 'puzzle_statuses.json', # PuzzleStatus depends on Team and Puzzle 'submissions.json', # Submissions depend on Team and Puzzle - 'hints.json' # Hints depend on Team and Puzzle + 'hints.json', # Hints depend on Team and Puzzle + 'team_data_answers.json' # TeamDataAnswer depends on Team and TeamDataQuestion ] for filename in activity_order: for obj in serializers.deserialize('json', zip_file.read(filename)): diff --git a/server/settings/base_settings.py b/server/settings/base_settings.py index a7bdfba..7c2a7c5 100644 --- a/server/settings/base_settings.py +++ b/server/settings/base_settings.py @@ -223,10 +223,6 @@ CONSTANCE_CONFIG = { # Site Settings 'SITE_TITLE': ('PuzzleSpring', 'The title of the site'), - 'TEAM_CUSTOM_DATA_NAME': ('', 'The name of the team custom data field'), - 'TEAM_CUSTOM_DATA_DESCRIPTION': ('', 'The description of the team custom data field'), - 'TEAM_CUSTOM_DATA_TYPE': ('text', 'The type of the team custom data field', 'team_data_type'), - 'SPLIT_LEADERBOARD_BY_CUSTOM_DATA': (False, "If enabled and TEAM_CUSTOM_DATA_TYPE is boolean,\n the leaderboard will show separate tabs"), # Hunt Display Settings 'SINGLE_HUNT_MODE': (False, 'If enabled, only one hunt will be visible and accessible'), # Not yet implemented @@ -259,10 +255,6 @@ 'widget': 'puzzlehunt.widgets.ImageWidget', 'required': False, }], - 'team_data_type': ['django.forms.fields.ChoiceField', { - 'widget': 'django.forms.Select', - 'choices': (('text', 'Text Input'), ('boolean', 'Checkbox')), - }], } CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' CONSTANCE_DATABASE_CACHE_BACKEND = 'default'