diff --git a/judge/admin/contest.py b/judge/admin/contest.py index 69e3ec10df..0e4bad4e68 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -99,6 +99,7 @@ class Meta: 'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'curators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'testers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), + 'spectators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2', attrs={'style': 'width: 100%'}), 'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'), @@ -117,7 +118,7 @@ class Meta: class ContestAdmin(NoBatchDeleteMixin, VersionAdmin): fieldsets = ( - (None, {'fields': ('key', 'name', 'authors', 'curators', 'testers', 'tester_see_submissions')}), + (None, {'fields': ('key', 'name', 'authors', 'curators', 'testers', 'tester_see_submissions', 'spectators')}), (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'hide_problem_authors', 'show_short_display', 'run_pretests_only', 'locked_after', 'scoreboard_visibility', 'points_precision')}), diff --git a/judge/migrations/0131_spectate_contests.py b/judge/migrations/0131_spectate_contests.py new file mode 100644 index 0000000000..6cfc95354b --- /dev/null +++ b/judge/migrations/0131_spectate_contests.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.27 on 2022-02-25 07:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0130_blogpost_change_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='spectators', + field=models.ManyToManyField(blank=True, help_text='These users will be able to spectate the contest, but not see the problems ahead of time.', related_name='spectated_contests', to='judge.Profile'), + ), + migrations.AlterField( + model_name='contest', + name='authors', + field=models.ManyToManyField(help_text='These users will be able to edit the contest.', related_name='authored_contests', to='judge.Profile'), + ), + migrations.AlterField( + model_name='contest', + name='curators', + field=models.ManyToManyField(blank=True, help_text='These users will be able to edit the contest, but will not be listed as authors.', related_name='curated_contests', to='judge.Profile'), + ), + migrations.AlterField( + model_name='contest', + name='testers', + field=models.ManyToManyField(blank=True, help_text='These users will be able to view the contest, but not edit it.', related_name='tested_contests', to='judge.Profile'), + ), + ] diff --git a/judge/models/contest.py b/judge/models/contest.py index 6403be398f..769c366ba3 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -68,17 +68,20 @@ class Contest(models.Model): validators=[RegexValidator('^[a-z0-9]+$', _('Contest id must be ^[a-z0-9]+$'))]) name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True) authors = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest.'), - related_name='authors+') + related_name='authored_contests') curators = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest, ' 'but will not be listed as authors.'), - related_name='curators+', blank=True) + related_name='curated_contests', blank=True) testers = models.ManyToManyField(Profile, help_text=_('These users will be able to view the contest, ' 'but not edit it.'), - blank=True, related_name='testers+') + blank=True, related_name='tested_contests') tester_see_scoreboard = models.BooleanField(verbose_name=_('testers see scoreboard'), default=False, help_text=_('If testers can see the scoreboard.')) tester_see_submissions = models.BooleanField(verbose_name=_('testers see submissions'), default=False, help_text=_('If testers can see in-contest submissions.')) + spectators = models.ManyToManyField(Profile, help_text=_('These users will be able to spectate the contest, ' + 'but not see the problems ahead of time.'), + blank=True, related_name='spectated_contests') description = models.TextField(verbose_name=_('description'), blank=True) problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem') start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True) @@ -230,6 +233,8 @@ def can_see_full_scoreboard(self, user): return True if self.tester_see_scoreboard and user.profile.id in self.tester_ids: return True + if self.started and user.profile.id in self.spectator_ids: + return True if self.view_contest_scoreboard.filter(id=user.profile.id).exists(): return True if self.scoreboard_visibility == self.SCOREBOARD_AFTER_PARTICIPATION and self.has_completed_contest(user): @@ -296,6 +301,10 @@ def editor_ids(self): def tester_ids(self): return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True) + @cached_property + def spectator_ids(self): + return Contest.spectators.through.objects.filter(contest=self).values_list('profile_id', flat=True) + def __str__(self): return self.name @@ -336,6 +345,10 @@ def access_check(self, user): if user.profile.id in self.tester_ids: return + # User is spectator for contest + if user.profile.id in self.spectator_ids: + return + # Contest is not publicly visible if not self.is_visible: raise self.Inaccessible() @@ -437,6 +450,7 @@ def get_visible_contests(cls, user): q |= Q(authors=user.profile) q |= Q(curators=user.profile) q |= Q(testers=user.profile) + q |= Q(spectators=user.profile) queryset = queryset.filter(q) return queryset.distinct() diff --git a/judge/models/tests/test_contest.py b/judge/models/tests/test_contest.py index 9d88a37a53..5be197e22b 100644 --- a/judge/models/tests/test_contest.py +++ b/judge/models/tests/test_contest.py @@ -47,6 +47,10 @@ def setUpTestData(self): username='normal_open_org', is_staff=False, ), + 'non_staff_spectator': create_user( + username='non_staff_spectator', + is_staff=False, + ), }) self.users['normal_open_org'].profile.organizations.add(self.organizations['open']) @@ -72,6 +76,7 @@ def setUpTestData(self): return tostring(math.floor(n)) end """, + spectators=('non_staff_spectator',), ) self.hidden_scoreboard_non_staff_author = create_contest( @@ -124,6 +129,20 @@ def setUpTestData(self): authors=('non_staff_author',), curators=('staff_contest_edit_own',), testers=('non_staff_tester',), + spectators=('non_staff_spectator',), + ) + + self.future_contest = create_contest( + key='future_contest', + start_time=_now + timezone.timedelta(days=3), + end_time=_now + timezone.timedelta(days=5), + time_limit=timezone.timedelta(days=1), + is_visible=True, + scoreboard_visibility=Contest.SCOREBOARD_AFTER_CONTEST, + authors=('non_staff_author',), + curators=('staff_contest_edit_own',), + testers=('non_staff_tester',), + spectators=('non_staff_spectator',), ) self.tester_see_scoreboard_contest = create_contest( @@ -387,6 +406,13 @@ def test_hidden_scoreboard_contest_methods(self): 'is_editable_by': self.assertTrue, 'is_in_contest': self.assertFalse, }, + 'non_staff_spectator': { + 'can_see_own_scoreboard': self.assertTrue, + 'can_see_full_scoreboard': self.assertTrue, + 'is_accessible_by': self.assertTrue, + 'is_editable_by': self.assertFalse, + 'is_in_contest': self.assertFalse, + }, 'normal': { 'can_see_own_scoreboard': self.assertTrue, 'can_see_full_scoreboard': self.assertFalse, @@ -592,6 +618,35 @@ def test_public_limit_organization_join_contest(self): } self._test_object_methods_with_users(self.public_limit_organization_join_contest, data) + def test_future_contest_methods(self): + data = { + 'non_staff_spectator': { + 'can_see_own_scoreboard': self.assertFalse, + 'can_see_full_scoreboard': self.assertFalse, + }, + 'non_staff_tester': { + 'can_see_own_scoreboard': self.assertFalse, + 'can_see_full_scoreboard': self.assertFalse, + }, + 'non_staff_author': { + 'can_see_own_scoreboard': self.assertTrue, + 'can_see_full_scoreboard': self.assertTrue, + }, + 'staff_contest_edit_own': { + 'can_see_own_scoreboard': self.assertTrue, + 'can_see_full_scoreboard': self.assertTrue, + }, + 'staff_contest_edit_all': { + 'can_see_own_scoreboard': self.assertTrue, + 'can_see_full_scoreboard': self.assertTrue, + }, + 'normal': { + 'can_see_own_scoreboard': self.assertFalse, + 'can_see_full_scoreboard': self.assertFalse, + }, + } + self._test_object_methods_with_users(self.future_contest, data) + def test_private_contest_methods(self): # User must be in org and in private user list with self.assertRaises(Contest.PrivateContest): diff --git a/judge/models/tests/util.py b/judge/models/tests/util.py index 50dbad6f76..953308d7c3 100644 --- a/judge/models/tests/util.py +++ b/judge/models/tests/util.py @@ -190,6 +190,7 @@ class CreateContest(CreateModel): 'authors': (Profile, 'user__username'), 'curators': (Profile, 'user__username'), 'testers': (Profile, 'user__username'), + 'spectators': (Profile, 'user__username'), 'problems': (Problem, 'code'), 'view_contest_scoreboard': (Profile, 'user__username'), 'rate_exclude': (Profile, 'user__username'), diff --git a/judge/views/contests.py b/judge/views/contests.py index 66ad949a28..551afc1b82 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -80,7 +80,14 @@ def _now(self): return timezone.now() def _get_queryset(self): - return super().get_queryset().prefetch_related('tags', 'organizations', 'authors', 'curators', 'testers') + return super().get_queryset().prefetch_related( + 'tags', + 'organizations', + 'authors', + 'curators', + 'testers', + 'spectators', + ) def get_queryset(self): return self._get_queryset().order_by(self.order, 'key').filter(end_time__lt=self._now) @@ -96,11 +103,12 @@ def get_context_data(self, **kwargs): present.append(contest) if self.request.user.is_authenticated: - for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile, - contest_id__in=present) \ - .select_related('contest') \ - .prefetch_related('contest__authors', 'contest__curators', 'contest__testers') \ - .annotate(key=F('contest__key')): + for participation in ( + ContestParticipation.objects.filter(virtual=0, user=self.request.profile, contest_id__in=present) + .select_related('contest') + .prefetch_related('contest__authors', 'contest__curators', 'contest__testers', 'contest__spectators') + .annotate(key=F('contest__key')) + ): if participation.ended: finished.add(participation.contest.key) else: @@ -149,6 +157,12 @@ def is_tester(self): return False return self.request.profile.id in self.object.tester_ids + @cached_property + def is_spectator(self): + if not self.request.user.is_authenticated: + return False + return self.request.profile.id in self.object.spectator_ids + @cached_property def can_edit(self): return self.object.is_editable_by(self.request.user) @@ -175,6 +189,7 @@ def get_context_data(self, **kwargs): context['now'] = timezone.now() context['is_editor'] = self.is_editor context['is_tester'] = self.is_tester + context['is_spectator'] = self.is_spectator context['can_edit'] = self.can_edit if not self.object.og_image or not self.object.summary: @@ -374,7 +389,6 @@ def join_contest(self, request, access_code=None): else: return generic_message(request, _('Cannot enter'), _('You are not able to join this contest.')) - try: participation = ContestParticipation.objects.get( contest=contest, user=profile, virtual=participation_type, diff --git a/resources/wpadmin/css/wpadmin.site.css b/resources/wpadmin/css/wpadmin.site.css index 26afe226c3..d2fe84e5b1 100644 --- a/resources/wpadmin/css/wpadmin.site.css +++ b/resources/wpadmin/css/wpadmin.site.css @@ -53,6 +53,7 @@ select[id^=id_contest_problems] { select#id_authors.django-select2, select#id_curators.django-select2, select#id_testers.django-select2, +select#id_spectators.django-select2, select#id_organizations.django-select2, select#id_join_organizations.django-select2, select#id_tags.django-select2 { diff --git a/templates/contest/contest.html b/templates/contest/contest.html index b0dd1b14cd..f30e8ed8b7 100644 --- a/templates/contest/contest.html +++ b/templates/contest/contest.html @@ -184,7 +184,7 @@ {% endcache %} - {% if contest.ended or request.user.is_superuser or is_editor or is_tester %} + {% if contest.ended or request.user.is_superuser or is_editor or is_tester or (is_spectator and contest.started) %}