Skip to content

Commit

Permalink
Add spectator to contests
Browse files Browse the repository at this point in the history
Closes #1750. Only additional change is the renaming of related names
for authors, curators and testers on the Contest model, to better match
the Problem model.
  • Loading branch information
Riolku authored and quantum5 committed Feb 26, 2022
1 parent d1c41e0 commit 2367b81
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 12 deletions.
3 changes: 2 additions & 1 deletion judge/admin/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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')}),
Expand Down
33 changes: 33 additions & 0 deletions judge/migrations/0131_spectate_contests.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
20 changes: 17 additions & 3 deletions judge/models/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
55 changes: 55 additions & 0 deletions judge/models/tests/test_contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions judge/models/tests/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
28 changes: 21 additions & 7 deletions judge/views/contests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions resources/wpadmin/css/wpadmin.site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion templates/contest/contest.html
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
{% endcache %}
</div>

{% 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) %}
<hr>
<div class="contest-problems">
<h2 style="margin-bottom: 0.2em"><i class="fa fa-fw fa-question-circle"></i>{{ _('Problems') }} </h2>
Expand Down

0 comments on commit 2367b81

Please # to comment.