Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Add spectator to contests #1869

Merged
merged 1 commit into from
Feb 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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