Add contest tester/curator (DMOJ)

This commit is contained in:
cuom1999 2021-05-24 15:00:36 -05:00
parent 06318a97e5
commit 297b8a2a36
25 changed files with 611 additions and 230 deletions

View file

@ -7,10 +7,12 @@ from django.forms import ModelForm, ModelMultipleChoiceField
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _, ungettext from django.utils.translation import gettext_lazy as _, ungettext
from reversion.admin import VersionAdmin from reversion.admin import VersionAdmin
from django_ace import AceWidget
from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating
from judge.ratings import rate_contest from judge.ratings import rate_contest
from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminPagedownWidget, \ from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminPagedownWidget, \
@ -94,7 +96,9 @@ class ContestForm(ModelForm):
class Meta: class Meta:
widgets = { widgets = {
'organizers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
'curators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
'testers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2', 'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2',
attrs={'style': 'width: 100%'}), attrs={'style': 'width: 100%'}),
'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'), 'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'),
@ -111,18 +115,19 @@ class ContestForm(ModelForm):
class ContestAdmin(VersionAdmin): class ContestAdmin(VersionAdmin):
fieldsets = ( fieldsets = (
(None, {'fields': ('key', 'name', 'organizers')}), (None, {'fields': ('key', 'name', 'authors', 'curators', 'testers')}),
(_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'hide_scoreboard', (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'scoreboard_visibility',
'run_pretests_only', 'points_precision')}), 'run_pretests_only', 'points_precision')}),
(_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}), (_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}),
(_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}), (_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}),
(_('Format'), {'fields': ('format_name', 'format_config')}), (_('Format'), {'fields': ('format_name', 'format_config', 'problem_label_script')}),
(_('Rating'), {'fields': ('is_rated', 'rate_all', 'rating_floor', 'rating_ceiling', 'rate_exclude')}), (_('Rating'), {'fields': ('is_rated', 'rate_all', 'rating_floor', 'rating_ceiling', 'rate_exclude')}),
(_('Access'), {'fields': ('access_code', 'is_private', 'private_contestants', 'is_organization_private', (_('Access'), {'fields': ('access_code', 'is_private', 'private_contestants', 'is_organization_private',
'organizations', 'view_contest_scoreboard')}), 'organizations', 'view_contest_scoreboard')}),
(_('Justice'), {'fields': ('banned_users',)}), (_('Justice'), {'fields': ('banned_users',)}),
) )
list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count') list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count')
search_fields = ('key', 'name')
inlines = [ContestProblemInline] inlines = [ContestProblemInline]
actions_on_top = True actions_on_top = True
actions_on_bottom = True actions_on_bottom = True
@ -146,7 +151,7 @@ class ContestAdmin(VersionAdmin):
if request.user.has_perm('judge.edit_all_contest'): if request.user.has_perm('judge.edit_all_contest'):
return queryset return queryset
else: else:
return queryset.filter(organizers__id=request.profile.id) return queryset.filter(Q(authors=request.profile) | Q(curators=request.profile)).distinct()
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
readonly = [] readonly = []
@ -158,6 +163,8 @@ class ContestAdmin(VersionAdmin):
readonly += ['is_private', 'private_contestants', 'is_organization_private', 'organizations'] readonly += ['is_private', 'private_contestants', 'is_organization_private', 'organizations']
if not request.user.has_perm('judge.change_contest_visibility'): if not request.user.has_perm('judge.change_contest_visibility'):
readonly += ['is_visible'] readonly += ['is_visible']
if not request.user.has_perm('judge.contest_problem_label'):
readonly += ['problem_label_script']
return readonly return readonly
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
@ -185,9 +192,9 @@ class ContestAdmin(VersionAdmin):
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
if not request.user.has_perm('judge.edit_own_contest'): if not request.user.has_perm('judge.edit_own_contest'):
return False return False
if request.user.has_perm('judge.edit_all_contest') or obj is None: if obj is None:
return True return True
return obj.organizers.filter(id=request.profile.id).exists() return obj.is_editable_by(request.user)
def _rescore(self, contest_key): def _rescore(self, contest_key):
from judge.tasks import rescore_contest from judge.tasks import rescore_contest
@ -232,14 +239,10 @@ class ContestAdmin(VersionAdmin):
if not request.user.has_perm('judge.contest_rating'): if not request.user.has_perm('judge.contest_rating'):
raise PermissionDenied() raise PermissionDenied()
with transaction.atomic(): with transaction.atomic():
if connection.vendor == 'sqlite': with connection.cursor() as cursor:
Rating.objects.all().delete()
else:
cursor = connection.cursor()
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table) cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
cursor.close()
Profile.objects.update(rating=None) Profile.objects.update(rating=None)
for contest in Contest.objects.filter(is_rated=True).order_by('end_time'): for contest in Contest.objects.filter(is_rated=True, end_time__lte=timezone.now()).order_by('end_time'):
rate_contest(contest) rate_contest(contest)
return HttpResponseRedirect(reverse('admin:judge_contest_changelist')) return HttpResponseRedirect(reverse('admin:judge_contest_changelist'))
@ -247,16 +250,21 @@ class ContestAdmin(VersionAdmin):
if not request.user.has_perm('judge.contest_rating'): if not request.user.has_perm('judge.contest_rating'):
raise PermissionDenied() raise PermissionDenied()
contest = get_object_or_404(Contest, id=id) contest = get_object_or_404(Contest, id=id)
if not contest.is_rated: if not contest.is_rated or not contest.ended:
raise Http404() raise Http404()
with transaction.atomic(): with transaction.atomic():
contest.rate() contest.rate()
return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('admin:judge_contest_changelist'))) return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('admin:judge_contest_changelist')))
def get_form(self, *args, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super(ContestAdmin, self).get_form(*args, **kwargs) form = super(ContestAdmin, self).get_form(request, obj, **kwargs)
if 'problem_label_script' in form.base_fields:
# form.base_fields['problem_label_script'] does not exist when the user has only view permission
# on the model.
form.base_fields['problem_label_script'].widget = AceWidget('lua', request.profile.ace_theme)
perms = ('edit_own_contest', 'edit_all_contest') perms = ('edit_own_contest', 'edit_all_contest')
form.base_fields['organizers'].queryset = Profile.objects.filter( form.base_fields['curators'].queryset = Profile.objects.filter(
Q(user__is_superuser=True) | Q(user__is_superuser=True) |
Q(user__groups__permissions__codename__in=perms) | Q(user__groups__permissions__codename__in=perms) |
Q(user__user_permissions__codename__in=perms), Q(user__user_permissions__codename__in=perms),

View file

@ -82,6 +82,14 @@ class BaseContestFormat(six.with_metaclass(ABCMeta)):
""" """
raise NotImplementedError() raise NotImplementedError()
@abstractmethod
def get_contest_problem_label_script(self):
"""
Returns the default Lua script to generate contest problem labels.
:return: A string, the Lua script.
"""
raise NotImplementedError()
@classmethod @classmethod
def best_solution_state(cls, points, total): def best_solution_state(cls, points, total):
if not points: if not points:

View file

@ -68,3 +68,10 @@ class DefaultContestFormat(BaseContestFormat):
def get_problem_breakdown(self, participation, contest_problems): def get_problem_breakdown(self, participation, contest_problems):
return [(participation.format_data or {}).get(str(contest_problem.id)) for contest_problem in contest_problems] return [(participation.format_data or {}).get(str(contest_problem.id)) for contest_problem in contest_problems]
def get_contest_problem_label_script(self):
return '''
function(n)
return tostring(math.floor(n + 1))
end
'''

View file

@ -0,0 +1,129 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import connection
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy
from judge.contest_format.default import DefaultContestFormat
from judge.contest_format.registry import register_contest_format
from judge.timezone import from_database_time
from judge.utils.timedelta import nice_repr
@register_contest_format('icpc')
class ICPCContestFormat(DefaultContestFormat):
name = gettext_lazy('ICPC')
config_defaults = {'penalty': 20}
config_validators = {'penalty': lambda x: x >= 0}
'''
penalty: Number of penalty minutes each incorrect submission adds. Defaults to 20.
'''
@classmethod
def validate(cls, config):
if config is None:
return
if not isinstance(config, dict):
raise ValidationError('ICPC-styled contest expects no config or dict as config')
for key, value in config.items():
if key not in cls.config_defaults:
raise ValidationError('unknown config key "%s"' % key)
if not isinstance(value, type(cls.config_defaults[key])):
raise ValidationError('invalid type for config key "%s"' % key)
if not cls.config_validators[key](value):
raise ValidationError('invalid value "%s" for config key "%s"' % (value, key))
def __init__(self, contest, config):
self.config = self.config_defaults.copy()
self.config.update(config or {})
self.contest = contest
def update_participation(self, participation):
cumtime = 0
last = 0
penalty = 0
score = 0
format_data = {}
with connection.cursor() as cursor:
cursor.execute('''
SELECT MAX(cs.points) as `points`, (
SELECT MIN(csub.date)
FROM judge_contestsubmission ccs LEFT OUTER JOIN
judge_submission csub ON (csub.id = ccs.submission_id)
WHERE ccs.problem_id = cp.id AND ccs.participation_id = %s AND ccs.points = MAX(cs.points)
) AS `time`, cp.id AS `prob`
FROM judge_contestproblem cp INNER JOIN
judge_contestsubmission cs ON (cs.problem_id = cp.id AND cs.participation_id = %s) LEFT OUTER JOIN
judge_submission sub ON (sub.id = cs.submission_id)
GROUP BY cp.id
''', (participation.id, participation.id))
for points, time, prob in cursor.fetchall():
time = from_database_time(time)
dt = (time - participation.start).total_seconds()
# Compute penalty
if self.config['penalty']:
# An IE can have a submission result of `None`
subs = participation.submissions.exclude(submission__result__isnull=True) \
.exclude(submission__result__in=['IE', 'CE']) \
.filter(problem_id=prob)
if points:
prev = subs.filter(submission__date__lte=time).count() - 1
penalty += prev * self.config['penalty'] * 60
else:
# We should always display the penalty, even if the user has a score of 0
prev = subs.count()
else:
prev = 0
if points:
cumtime += dt
last = max(last, dt)
format_data[str(prob)] = {'time': dt, 'points': points, 'penalty': prev}
score += points
participation.cumtime = cumtime + penalty
participation.score = score
participation.tiebreaker = last # field is sorted from least to greatest
participation.format_data = format_data
participation.save()
def display_user_problem(self, participation, contest_problem):
format_data = (participation.format_data or {}).get(str(contest_problem.id))
if format_data:
penalty = format_html('<small style="color:red"> ({penalty})</small>',
penalty=floatformat(format_data['penalty'])) if format_data['penalty'] else ''
return format_html(
'<td class="{state}"><a href="{url}">{points}{penalty}<div class="solving-time">{time}</div></a></td>',
state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') +
self.best_solution_state(format_data['points'], contest_problem.points)),
url=reverse('contest_user_submissions',
args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]),
points=floatformat(format_data['points']),
penalty=penalty,
time=nice_repr(timedelta(seconds=format_data['time']), 'noday'),
)
else:
return mark_safe('<td></td>')
def get_contest_problem_label_script(self):
return '''
function(n)
n = n + 1
ret = ""
while n > 0 do
ret = string.char((n - 1) % 26 + 65) .. ret
n = math.floor((n - 1) / 26)
end
return ret
end
'''

View file

@ -0,0 +1,63 @@
# Generated by Django 2.2.17 on 2021-05-24 19:22
from django.db import migrations, models
def hide_scoreboard_eq_true(apps, schema_editor):
Contest = apps.get_model('judge', 'Contest')
Contest.objects.filter(hide_scoreboard=True).update(scoreboard_visibility='C')
def scoreboard_visibility_eq_contest(apps, schema_editor):
Contest = apps.get_model('judge', 'Contest')
Contest.objects.filter(scoreboard_visibility__in=('C', 'P')).update(hide_scoreboard=True)
class Migration(migrations.Migration):
dependencies = [
('judge', '0114_auto_20201228_1041'),
]
operations = [
migrations.AlterModelOptions(
name='contest',
options={'permissions': (('see_private_contest', 'See private contests'), ('edit_own_contest', 'Edit own contests'), ('edit_all_contest', 'Edit all contests'), ('clone_contest', 'Clone contest'), ('moss_contest', 'MOSS contest'), ('contest_rating', 'Rate contests'), ('contest_access_code', 'Contest access codes'), ('create_private_contest', 'Create private contests'), ('change_contest_visibility', 'Change contest visibility'), ('contest_problem_label', 'Edit contest problem label script')), 'verbose_name': 'contest', 'verbose_name_plural': 'contests'},
),
migrations.RemoveField(
model_name='contest',
name='hide_scoreboard',
),
migrations.RemoveField(
model_name='contest',
name='organizers',
),
migrations.AddField(
model_name='contest',
name='authors',
field=models.ManyToManyField(help_text='These users will be able to edit the contest.', related_name='_contest_authors_+', to='judge.Profile'),
),
migrations.AddField(
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='_contest_curators_+', to='judge.Profile'),
),
migrations.AddField(
model_name='contest',
name='problem_label_script',
field=models.TextField(blank=True, help_text='A custom Lua function to generate problem labels. Requires a single function with an integer parameter, the zero-indexed contest problem index, and returns a string, the label.', verbose_name='contest problem label script'),
),
migrations.AddField(
model_name='contest',
name='scoreboard_visibility',
field=models.CharField(choices=[('V', 'Visible'), ('C', 'Hidden for duration of contest'), ('P', 'Hidden for duration of participation')], default='V', help_text='Scoreboard visibility through the duration of the contest', max_length=1, verbose_name='scoreboard visibility'),
),
migrations.AddField(
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='_contest_testers_+', to='judge.Profile'),
),
migrations.AddField(
model_name='contestparticipation',
name='tiebreaker',
field=models.FloatField(default=0.0, verbose_name='tie-breaking field'),
),
]

View file

@ -1,12 +1,13 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import CASCADE from django.db.models import CASCADE, Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from jsonfield import JSONField from jsonfield import JSONField
from lupa import LuaRuntime
from moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON, MOSS_LANG_PASCAL from moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON, MOSS_LANG_PASCAL
from judge import contest_format from judge import contest_format
@ -48,11 +49,25 @@ class ContestTag(models.Model):
class Contest(models.Model): class Contest(models.Model):
SCOREBOARD_VISIBLE = 'V'
SCOREBOARD_AFTER_CONTEST = 'C'
SCOREBOARD_AFTER_PARTICIPATION = 'P'
SCOREBOARD_VISIBILITY = (
(SCOREBOARD_VISIBLE, _('Visible')),
(SCOREBOARD_AFTER_CONTEST, _('Hidden for duration of contest')),
(SCOREBOARD_AFTER_PARTICIPATION, _('Hidden for duration of participation')),
)
key = models.CharField(max_length=20, verbose_name=_('contest id'), unique=True, key = models.CharField(max_length=20, verbose_name=_('contest id'), unique=True,
validators=[RegexValidator('^[a-z0-9]+$', _('Contest id must be ^[a-z0-9]+$'))]) 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) name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True)
organizers = models.ManyToManyField(Profile, help_text=_('These people will be able to edit the contest.'), authors = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest.'),
related_name='organizers+') related_name='authors+')
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)
testers = models.ManyToManyField(Profile, help_text=_('These users will be able to view the contest, '
'but not edit it.'),
blank=True, related_name='testers+')
description = models.TextField(verbose_name=_('description'), blank=True) description = models.TextField(verbose_name=_('description'), blank=True)
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem') problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True) start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
@ -64,10 +79,9 @@ class Contest(models.Model):
'specified organizations.')) 'specified organizations.'))
is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'), is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'),
default=False) default=False)
hide_scoreboard = models.BooleanField(verbose_name=_('hide scoreboard'), scoreboard_visibility = models.CharField(verbose_name=_('scoreboard visibility'), default=SCOREBOARD_VISIBLE,
help_text=_('Whether the scoreboard should remain hidden for the duration ' max_length=1, help_text=_('Scoreboard visibility through the duration '
'of the contest.'), 'of the contest'), choices=SCOREBOARD_VISIBILITY)
default=False)
view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True, view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True,
related_name='view_contest_scoreboard', related_name='view_contest_scoreboard',
help_text=_('These users will be able to view the scoreboard.')) help_text=_('These users will be able to view the scoreboard.'))
@ -116,6 +130,10 @@ class Contest(models.Model):
help_text=_('A JSON object to serve as the configuration for the chosen contest format ' help_text=_('A JSON object to serve as the configuration for the chosen contest format '
'module. Leave empty to use None. Exact format depends on the contest format ' 'module. Leave empty to use None. Exact format depends on the contest format '
'selected.')) 'selected.'))
problem_label_script = models.TextField(verbose_name='contest problem label script', blank=True,
help_text='A custom Lua function to generate problem labels. Requires a '
'single function with an integer parameter, the zero-indexed '
'contest problem index, and returns a string, the label.')
points_precision = models.IntegerField(verbose_name=_('precision points'), default=2, points_precision = models.IntegerField(verbose_name=_('precision points'), default=2,
validators=[MinValueValidator(0), MaxValueValidator(10)], validators=[MinValueValidator(0), MaxValueValidator(10)],
help_text=_('Number of digits to round points to.')) help_text=_('Number of digits to round points to.'))
@ -128,30 +146,72 @@ class Contest(models.Model):
def format(self): def format(self):
return self.format_class(self, self.format_config) return self.format_class(self, self.format_config)
@cached_property
def get_label_for_problem(self):
def DENY_ALL(obj, attr_name, is_setting):
raise AttributeError()
lua = LuaRuntime(attribute_filter=DENY_ALL, register_eval=False, register_builtins=False)
return lua.eval(self.problem_label_script or self.format.get_contest_problem_label_script())
def clean(self): def clean(self):
# Django will complain if you didn't fill in start_time or end_time, so we don't have to. # Django will complain if you didn't fill in start_time or end_time, so we don't have to.
if self.start_time and self.end_time and self.start_time >= self.end_time: if self.start_time and self.end_time and self.start_time >= self.end_time:
raise ValidationError('What is this? A contest that ended before it starts?') raise ValidationError('What is this? A contest that ended before it starts?')
self.format_class.validate(self.format_config) self.format_class.validate(self.format_config)
try:
# a contest should have at least one problem, with contest problem index 0
# so test it to see if the script returns a valid label.
label = self.get_label_for_problem(0)
except Exception as e:
raise ValidationError('Contest problem label script: %s' % e)
else:
if not isinstance(label, str):
raise ValidationError('Contest problem label script: script should return a string.')
def is_in_contest(self, user): def is_in_contest(self, user):
if user.is_authenticated: if user.is_authenticated:
profile = user.profile profile = user.profile
return profile and profile.current_contest is not None and profile.current_contest.contest == self return profile and profile.current_contest is not None and profile.current_contest.contest == self
return False return False
def can_see_scoreboard(self, user): def can_see_own_scoreboard(self, user):
if user.has_perm('judge.see_private_contest'): if self.can_see_full_scoreboard(user):
return True return True
if user.is_authenticated and self.organizers.filter(id=user.profile.id).exists(): if not self.can_join:
return True
if user.is_authenticated and self.view_contest_scoreboard.filter(id=user.profile.id).exists():
return True
if not self.is_visible:
return False return False
if self.start_time is not None and self.start_time > timezone.now(): if not self.show_scoreboard and not self.is_in_contest(user):
return False return False
if self.hide_scoreboard and not self.is_in_contest(user) and self.end_time > timezone.now(): return True
def can_see_full_scoreboard(self, user):
if self.show_scoreboard:
return True
if not user.is_authenticated:
return False
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
return True
if user.profile.id in self.editor_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):
return True
return False
def has_completed_contest(self, user):
if user.is_authenticated:
participation = self.users.filter(virtual=ContestParticipation.LIVE, user=user.profile).first()
if participation and participation.ended:
return True
return False
@cached_property
def show_scoreboard(self):
if not self.can_join:
return False
if (self.scoreboard_visibility in (self.SCOREBOARD_AFTER_CONTEST, self.SCOREBOARD_AFTER_PARTICIPATION) and
not self.ended):
return False return False
return True return True
@ -186,6 +246,19 @@ class Contest(models.Model):
def ended(self): def ended(self):
return self.end_time < self._now return self.end_time < self._now
@cached_property
def author_ids(self):
return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True)
@cached_property
def editor_ids(self):
return self.author_ids.union(
Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True))
@cached_property
def tester_ids(self):
return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -198,50 +271,111 @@ class Contest(models.Model):
update_user_count.alters_data = True update_user_count.alters_data = True
@cached_property class Inaccessible(Exception):
def show_scoreboard(self): pass
if self.hide_scoreboard and not self.ended:
return False class PrivateContest(Exception):
return True pass
def access_check(self, user):
# Do unauthenticated check here so we can skip authentication checks later on.
if not user.is_authenticated:
# Unauthenticated users can only see visible, non-private contests
if not self.is_visible:
raise self.Inaccessible()
if self.is_private or self.is_organization_private:
raise self.PrivateContest()
return
# If the user can view or edit all contests
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
return
# User is organizer or curator for contest
if user.profile.id in self.editor_ids:
return
# User is tester for contest
if user.profile.id in self.tester_ids:
return
# Contest is not publicly visible
if not self.is_visible:
raise self.Inaccessible()
def is_accessible_by(self, user):
# Contest is publicly visible
if self.is_visible:
# Contest is not private # Contest is not private
if not self.is_private and not self.is_organization_private: if not self.is_private and not self.is_organization_private:
return True return
if user.is_authenticated:
# User is in the organizations it is private to
if self.organizations.filter(id__in=user.profile.organizations.all()).exists():
return True
# User is in the group of private contestants
if self.private_contestants.filter(id=user.profile.id).exists():
return True
if self.view_contest_scoreboard.filter(id=user.profile.id).exists(): if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
return True return
# If the user can view all contests in_org = self.organizations.filter(id__in=user.profile.organizations.all()).exists()
if user.has_perm('judge.see_private_contest'): in_users = self.private_contestants.filter(id=user.profile.id).exists()
return True
# User can edit the contest if not self.is_private and self.is_organization_private:
return self.is_editable_by(user) if in_org:
return
raise self.PrivateContest()
if self.is_private and not self.is_organization_private:
if in_users:
return
raise self.PrivateContest()
if self.is_private and self.is_organization_private:
if in_org and in_users:
return
raise self.PrivateContest()
def is_accessible_by(self, user):
try:
self.access_check(user)
except (self.Inaccessible, self.PrivateContest):
return False
else:
return True
def is_editable_by(self, user): def is_editable_by(self, user):
# If the user can edit all contests # If the user can edit all contests
if user.has_perm('judge.edit_all_contest'): if user.has_perm('judge.edit_all_contest'):
return True return True
# If the user is a contest organizer # If the user is a contest organizer or curator
if user.has_perm('judge.edit_own_contest') and \ if user.has_perm('judge.edit_own_contest') and user.profile.id in self.editor_ids:
self.organizers.filter(id=user.profile.id).exists():
return True return True
return False return False
@classmethod
def get_visible_contests(cls, user):
if not user.is_authenticated:
return cls.objects.filter(is_visible=True, is_organization_private=False, is_private=False) \
.defer('description').distinct()
queryset = cls.objects.defer('description')
if not (user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest')):
q = Q(is_visible=True)
q &= (
Q(view_contest_scoreboard=user.profile) |
Q(is_organization_private=False, is_private=False) |
Q(is_organization_private=False, is_private=True, private_contestants=user.profile) |
Q(is_organization_private=True, is_private=False, organizations__in=user.profile.organizations.all()) |
Q(is_organization_private=True, is_private=True, organizations__in=user.profile.organizations.all(),
private_contestants=user.profile)
)
q |= Q(authors=user.profile)
q |= Q(curators=user.profile)
q |= Q(testers=user.profile)
queryset = queryset.filter(q)
return queryset.distinct()
def rate(self): def rate(self):
Rating.objects.filter(contest__end_time__gte=self.end_time).delete() Rating.objects.filter(contest__end_time__range=(self.end_time, self._now)).delete()
for contest in Contest.objects.filter(is_rated=True, end_time__gte=self.end_time).order_by('end_time'): for contest in Contest.objects.filter(
is_rated=True, end_time__range=(self.end_time, self._now),
).order_by('end_time'):
rate_contest(contest) rate_contest(contest)
class Meta: class Meta:
@ -255,6 +389,7 @@ class Contest(models.Model):
('contest_access_code', _('Contest access codes')), ('contest_access_code', _('Contest access codes')),
('create_private_contest', _('Create private contests')), ('create_private_contest', _('Create private contests')),
('change_contest_visibility', _('Change contest visibility')), ('change_contest_visibility', _('Change contest visibility')),
('contest_problem_label', _('Edit contest problem label script')),
) )
verbose_name = _('contest') verbose_name = _('contest')
verbose_name_plural = _('contests') verbose_name_plural = _('contests')
@ -271,6 +406,7 @@ class ContestParticipation(models.Model):
cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0) cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0)
is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False, is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False,
help_text=_('Whether this participation is disqualified.')) help_text=_('Whether this participation is disqualified.'))
tiebreaker = models.FloatField(verbose_name=_('tie-breaking field'), default=0.0)
virtual = models.IntegerField(verbose_name=_('virtual participation id'), default=LIVE, virtual = models.IntegerField(verbose_name=_('virtual participation id'), default=LIVE,
help_text=_('0 means non-virtual, otherwise the n-th virtual participation.')) help_text=_('0 means non-virtual, otherwise the n-th virtual participation.'))
format_data = JSONField(verbose_name=_('contest format specific data'), null=True, blank=True) format_data = JSONField(verbose_name=_('contest format specific data'), null=True, blank=True)

View file

@ -5,7 +5,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models from django.db import models
from django.db.models import CASCADE, F, QuerySet, SET_NULL from django.db.models import CASCADE, F, Q, QuerySet, SET_NULL
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.urls import reverse from django.urls import reverse
@ -220,6 +220,43 @@ class Problem(models.Model):
def is_subs_manageable_by(self, user): def is_subs_manageable_by(self, user):
return user.is_staff and user.has_perm('judge.rejudge_submission') and self.is_editable_by(user) return user.is_staff and user.has_perm('judge.rejudge_submission') and self.is_editable_by(user)
@classmethod
def get_visible_problems(cls, user):
# Do unauthenticated check here so we can skip authentication checks later on.
if not user.is_authenticated:
return cls.get_public_problems()
# Conditions for visible problem:
# - `judge.edit_all_problem` or `judge.see_private_problem`
# - otherwise
# - not is_public problems
# - author or curator or tester
# - is_public problems
# - not is_organization_private or in organization or `judge.see_organization_problem`
# - author or curator or tester
queryset = cls.objects.defer('description')
if not (user.has_perm('judge.see_private_problem') or user.has_perm('judge.edit_all_problem')):
q = Q(is_public=True)
if not user.has_perm('judge.see_organization_problem'):
# Either not organization private or in the organization.
q &= (
Q(is_organization_private=False) |
Q(is_organization_private=True, organizations__in=user.profile.organizations.all())
)
# Authors, curators, and testers should always have access, so OR at the very end.
q |= Q(authors=user.profile)
q |= Q(curators=user.profile)
q |= Q(testers=user.profile)
queryset = queryset.filter(q)
return queryset.distinct()
@classmethod
def get_public_problems(cls):
return cls.objects.filter(is_public=True, is_organization_private=False).defer('description').distinct()
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -136,12 +136,15 @@ class Profile(models.Model):
def calculate_points(self, table=_pp_table): def calculate_points(self, table=_pp_table):
from judge.models import Problem from judge.models import Problem
data = (Problem.objects.filter(submission__user=self, submission__points__isnull=False, is_public=True, public_problems = Problem.get_public_problems()
is_organization_private=False) data = (
public_problems.filter(submission__user=self, submission__points__isnull=False)
.annotate(max_points=Max('submission__points')).order_by('-max_points') .annotate(max_points=Max('submission__points')).order_by('-max_points')
.values_list('max_points', flat=True).filter(max_points__gt=0)) .values_list('max_points', flat=True).filter(max_points__gt=0)
extradata = Problem.objects.filter(submission__user=self, submission__result='AC', is_public=True) \ )
.values('id').distinct().count() extradata = (
public_problems.filter(submission__user=self, submission__result='AC').values('id').count()
)
bonus_function = settings.DMOJ_PP_BONUS_FUNCTION bonus_function = settings.DMOJ_PP_BONUS_FUNCTION
points = sum(data) points = sum(data)
problems = len(data) problems = len(data)

View file

@ -11,7 +11,7 @@ class ProblemSitemap(Sitemap):
priority = 0.8 priority = 0.8
def items(self): def items(self):
return Problem.objects.filter(is_public=True, is_organization_private=False).values_list('code') return Problem.get_public_problems().values_list('code')
def location(self, obj): def location(self, obj):
return reverse('problem_detail', args=obj) return reverse('problem_detail', args=obj)

View file

@ -1,3 +1,4 @@
from judge.tasks.contest import *
from judge.tasks.demo import * from judge.tasks.demo import *
from judge.tasks.contest import * from judge.tasks.contest import *
from judge.tasks.submission import * from judge.tasks.submission import *

View file

@ -112,8 +112,8 @@ def hot_problems(duration, limit):
cache_key = 'hot_problems:%d:%d' % (duration.total_seconds(), limit) cache_key = 'hot_problems:%d:%d' % (duration.total_seconds(), limit)
qs = cache.get(cache_key) qs = cache.get(cache_key)
if qs is None: if qs is None:
qs = Problem.objects.filter(is_public=True, is_organization_private=False, qs = Problem.get_public_problems() \
submission__date__gt=timezone.now() - duration, points__gt=3, points__lt=25) .filter(submission__date__gt=timezone.now() - duration, points__gt=3, points__lt=25)
qs0 = qs.annotate(k=Count('submission__user', distinct=True)).order_by('-k').values_list('k', flat=True) qs0 = qs.annotate(k=Count('submission__user', distinct=True)).order_by('-k').values_list('k', flat=True)
if not qs0: if not qs0:

View file

@ -16,9 +16,8 @@ def sane_time_repr(delta):
def api_v1_contest_list(request): def api_v1_contest_list(request):
queryset = Contest.objects.filter(is_visible=True, is_private=False, queryset = Contest.get_visible_contests(request.user).prefetch_related(
is_organization_private=False).prefetch_related( Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list'))
Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list')).defer('description')
return JsonResponse({c.key: { return JsonResponse({c.key: {
'name': c.name, 'name': c.name,
@ -33,10 +32,7 @@ def api_v1_contest_detail(request, contest):
contest = get_object_or_404(Contest, key=contest) contest = get_object_or_404(Contest, key=contest)
in_contest = contest.is_in_contest(request.user) in_contest = contest.is_in_contest(request.user)
can_see_rankings = contest.can_see_scoreboard(request.user) can_see_rankings = contest.can_see_full_scoreboard(request.user)
if contest.hide_scoreboard and in_contest:
can_see_rankings = False
problems = list(contest.contest_problems.select_related('problem') problems = list(contest.contest_problems.select_related('problem')
.defer('problem__description').order_by('order')) .defer('problem__description').order_by('order'))
participations = (contest.users.filter(virtual=0, user__is_unlisted=False) participations = (contest.users.filter(virtual=0, user__is_unlisted=False)

View file

@ -82,16 +82,13 @@ class PostList(ListView):
.order_by('-latest') .order_by('-latest')
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT]) [:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
visible_contests = Contest.objects.filter(is_visible=True).order_by('start_time') visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True) \
q = Q(is_private=False, is_organization_private=False) .order_by('start_time')
if self.request.user.is_authenticated:
q |= Q(is_organization_private=True, organizations__in=user.organizations.all())
q |= Q(is_private=True, private_contestants=user)
q |= Q(view_contest_scoreboard=user)
visible_contests = visible_contests.filter(q)
context['current_contests'] = visible_contests.filter(start_time__lte=now, end_time__gt=now).distinct()
context['future_contests'] = visible_contests.filter(start_time__gt=now).distinct()
context['current_contests'] = visible_contests.filter(start_time__lte=now, end_time__gt=now)
context['future_contests'] = visible_contests.filter(start_time__gt=now)
visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True)
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
profile = self.request.profile profile = self.request.profile
context['own_open_tickets'] = (Ticket.objects.filter(Q(user=profile) | Q(assignees__in=[profile]), is_open=True).order_by('-id') context['own_open_tickets'] = (Ticket.objects.filter(Q(user=profile) | Q(assignees__in=[profile]), is_open=True).order_by('-id')

View file

@ -12,7 +12,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Case, Count, FloatField, IntegerField, Max, Min, Q, Sum, Value, When from django.db.models import Case, Count, F, FloatField, IntegerField, Max, Min, Q, Sum, Value, When
from django.db.models.expressions import CombinedExpression from django.db.models.expressions import CombinedExpression
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
@ -59,20 +59,7 @@ def _find_contest(request, key, private_check=True):
class ContestListMixin(object): class ContestListMixin(object):
def get_queryset(self): def get_queryset(self):
queryset = Contest.objects.all() return Contest.get_visible_contests(self.request.user)
if not self.request.user.has_perm('judge.see_private_contest'):
q = Q(is_visible=True)
if self.request.user.is_authenticated:
q |= Q(organizers=self.request.profile)
queryset = queryset.filter(q)
if not self.request.user.has_perm('judge.edit_all_contest'):
q = Q(is_private=False, is_organization_private=False)
if self.request.user.is_authenticated:
q |= Q(is_organization_private=True, organizations__in=self.request.profile.organizations.all())
q |= Q(is_private=True, private_contestants=self.request.profile)
q |= Q(view_contest_scoreboard=self.request.profile)
queryset = queryset.filter(q)
return queryset.distinct()
class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView): class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
@ -101,7 +88,7 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
def _get_queryset(self): def _get_queryset(self):
queryset = super(ContestList, self).get_queryset() \ queryset = super(ContestList, self).get_queryset() \
.order_by('-start_time', 'key').prefetch_related('tags', 'organizations', 'organizers') .order_by('-start_time', 'key').prefetch_related('tags', 'organizations', 'authors', 'curators', 'testers')
if 'contest' in self.request.GET: if 'contest' in self.request.GET:
self.contest_query = query = ' '.join(self.request.GET.getlist('contest')).strip() self.contest_query = query = ' '.join(self.request.GET.getlist('contest')).strip()
@ -128,7 +115,9 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile, for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile,
contest_id__in=present) \ contest_id__in=present) \
.select_related('contest').prefetch_related('contest__organizers'): .select_related('contest') \
.prefetch_related('contest__authors', 'contest__curators', 'contest__testers')\
.annotate(key=F('contest__key')):
if not participation.ended: if not participation.ended:
active.append(participation) active.append(participation)
present.remove(participation.contest) present.remove(participation.contest)
@ -164,37 +153,44 @@ class ContestMixin(object):
slug_url_kwarg = 'contest' slug_url_kwarg = 'contest'
@cached_property @cached_property
def is_organizer(self): def is_editor(self):
return self.check_organizer() if not self.request.user.is_authenticated:
return False
return self.request.profile.id in self.object.editor_ids
def check_organizer(self, contest=None, user=None): @cached_property
if user is None: def is_tester(self):
user = self.request.user if not self.request.user.is_authenticated:
return (contest or self.object).is_editable_by(user) return False
return self.request.profile.id in self.object.tester_ids
@cached_property
def can_edit(self):
return self.object.is_editable_by(self.request.user)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(ContestMixin, self).get_context_data(**kwargs) context = super(ContestMixin, self).get_context_data(**kwargs)
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
profile = self.request.profile
in_contest = context['in_contest'] = (profile.current_contest is not None and
profile.current_contest.contest == self.object)
if in_contest:
context['participation'] = profile.current_contest
context['participating'] = True
else:
try: try:
context['participation'] = profile.contest_history.get(contest=self.object, virtual=0) context['live_participation'] = (
self.request.profile.contest_history.get(
contest=self.object,
virtual=ContestParticipation.LIVE,
)
)
except ContestParticipation.DoesNotExist: except ContestParticipation.DoesNotExist:
context['participating'] = False context['live_participation'] = None
context['participation'] = None context['has_joined'] = False
else: else:
context['participating'] = True context['has_joined'] = True
else: else:
context['participating'] = False context['live_participation'] = None
context['participation'] = None context['has_joined'] = False
context['in_contest'] = False
context['now'] = timezone.now() context['now'] = timezone.now()
context['is_organizer'] = self.is_organizer context['is_editor'] = self.is_editor
context['is_tester'] = self.is_tester
context['can_edit'] = self.can_edit
if not self.object.og_image or not self.object.summary: if not self.object.og_image or not self.object.summary:
metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id, metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id,
@ -210,17 +206,21 @@ class ContestMixin(object):
def get_object(self, queryset=None): def get_object(self, queryset=None):
contest = super(ContestMixin, self).get_object(queryset) contest = super(ContestMixin, self).get_object(queryset)
user = self.request.user
profile = self.request.profile profile = self.request.profile
if (profile is not None and if (profile is not None and
ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()): ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()):
return contest return contest
if not contest.is_visible and not user.has_perm('judge.see_private_contest') and ( try:
not user.has_perm('judge.edit_own_contest') or contest.access_check(self.request.user)
not self.check_organizer(contest, user)): except Contest.PrivateContest:
raise PrivateContestError(contest.name, contest.is_private, contest.is_organization_private,
contest.organizations.all())
except Contest.Inaccessible:
raise Http404() raise Http404()
else:
return contest
if contest.is_private or contest.is_organization_private: if contest.is_private or contest.is_organization_private:
private_contest_error = PrivateContestError(contest.name, contest.is_private, private_contest_error = PrivateContestError(contest.name, contest.is_private,
@ -297,7 +297,7 @@ class ContestClone(ContestMixin, PermissionRequiredMixin, TitleMixin, SingleObje
contest.organizations.set(organizations) contest.organizations.set(organizations)
contest.private_contestants.set(private_contestants) contest.private_contestants.set(private_contestants)
contest.view_contest_scoreboard.set(view_contest_scoreboard) contest.view_contest_scoreboard.set(view_contest_scoreboard)
contest.organizers.add(self.request.profile) contest.authors.add(self.request.profile)
for problem in contest_problems: for problem in contest_problems:
problem.contest = contest problem.contest = contest
@ -337,7 +337,7 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
def join_contest(self, request, access_code=None): def join_contest(self, request, access_code=None):
contest = self.object contest = self.object
if not contest.can_join and not self.is_organizer: if not contest.can_join and not (self.is_editor or self.is_tester):
return generic_message(request, _('Contest not ongoing'), return generic_message(request, _('Contest not ongoing'),
_('"%s" is not currently ongoing.') % contest.name) _('"%s" is not currently ongoing.') % contest.name)
@ -351,8 +351,7 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
_('You have been declared persona non grata for this contest. ' _('You have been declared persona non grata for this contest. '
'You are permanently barred from joining this contest.')) 'You are permanently barred from joining this contest.'))
requires_access_code = (not (request.user.is_superuser or self.is_organizer) and requires_access_code = (not self.can_edit and contest.access_code and access_code != contest.access_code)
contest.access_code and access_code != contest.access_code)
if contest.ended: if contest.ended:
if requires_access_code: if requires_access_code:
raise ContestAccessDenied() raise ContestAccessDenied()
@ -371,22 +370,24 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
else: else:
break break
else: else:
SPECTATE = ContestParticipation.SPECTATE
LIVE = ContestParticipation.LIVE
try: try:
participation = ContestParticipation.objects.get( participation = ContestParticipation.objects.get(
contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0), contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE),
) )
except ContestParticipation.DoesNotExist: except ContestParticipation.DoesNotExist:
if requires_access_code: if requires_access_code:
raise ContestAccessDenied() raise ContestAccessDenied()
participation = ContestParticipation.objects.create( participation = ContestParticipation.objects.create(
contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0), contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE),
real_start=timezone.now(), real_start=timezone.now(),
) )
else: else:
if participation.ended: if participation.ended:
participation = ContestParticipation.objects.get_or_create( participation = ContestParticipation.objects.get_or_create(
contest=contest, user=profile, virtual=-1, contest=contest, user=profile, virtual=SPECTATE,
defaults={'real_start': timezone.now()}, defaults={'real_start': timezone.now()},
)[0] )[0]
@ -449,7 +450,7 @@ class ContestCalendar(TitleMixin, ContestListMixin, TemplateView):
def get_contest_data(self, start, end): def get_contest_data(self, start, end):
end += timedelta(days=1) end += timedelta(days=1)
contests = self.get_queryset().filter(Q(start_time__gte=start, start_time__lt=end) | contests = self.get_queryset().filter(Q(start_time__gte=start, start_time__lt=end) |
Q(end_time__gte=start, end_time__lt=end)).defer('description') Q(end_time__gte=start, end_time__lt=end))
starts, ends, oneday = (defaultdict(list) for i in range(3)) starts, ends, oneday = (defaultdict(list) for i in range(3))
for contest in contests: for contest in contests:
start_date = timezone.localtime(contest.start_time).date() start_date = timezone.localtime(contest.start_time).date()
@ -530,7 +531,7 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if not (self.object.ended or self.object.is_editable_by(self.request.user)): if not (self.object.ended or self.can_edit):
raise Http404() raise Http404()
queryset = Submission.objects.filter(contest_object=self.object) queryset = Submission.objects.filter(contest_object=self.object)
@ -542,9 +543,10 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
queryset.values('problem__code', 'result').annotate(count=Count('result')) queryset.values('problem__code', 'result').annotate(count=Count('result'))
.values_list('problem__code', 'result', 'count'), .values_list('problem__code', 'result', 'count'),
) )
labels, codes = zip( labels, codes = [], []
*self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code'), contest_problems = self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code')
) if contest_problems:
labels, codes = zip(*contest_problems)
num_problems = len(labels) num_problems = len(labels)
status_counts = [[] for i in range(num_problems)] status_counts = [[] for i in range(num_problems)]
for problem_code, result, count in status_count_queryset: for problem_code, result, count in status_count_queryset:
@ -630,10 +632,6 @@ def get_contest_ranking_list(request, contest, participation=None, ranking_list=
show_current_virtual=True, ranker=ranker): show_current_virtual=True, ranker=ranker):
problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order')) problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order'))
if contest.hide_scoreboard and contest.is_in_contest(request.user):
return ([(_('???'), make_contest_ranking_profile(contest, request.profile.current_contest, problems))],
problems)
users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime')) users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime'))
if show_current_virtual: if show_current_virtual:
@ -651,7 +649,7 @@ def contest_ranking_ajax(request, contest, participation=None):
if not exists: if not exists:
return HttpResponseBadRequest('Invalid contest', content_type='text/plain') return HttpResponseBadRequest('Invalid contest', content_type='text/plain')
if not contest.can_see_scoreboard(request.user): if not contest.can_see_full_scoreboard(request.user):
raise Http404() raise Http404()
users, problems = get_contest_ranking_list(request, contest, participation) users, problems = get_contest_ranking_list(request, contest, participation)
@ -679,7 +677,7 @@ class ContestRankingBase(ContestMixin, TitleMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if not self.object.can_see_scoreboard(self.request.user): if not self.object.can_see_own_scoreboard(self.request.user):
raise Http404() raise Http404()
users, problems = self.get_ranking_list() users, problems = self.get_ranking_list()
@ -697,6 +695,14 @@ class ContestRanking(ContestRankingBase):
return _('%s Rankings') % self.object.name return _('%s Rankings') % self.object.name
def get_ranking_list(self): def get_ranking_list(self):
if not self.object.can_see_full_scoreboard(self.request.user):
queryset = self.object.users.filter(user=self.request.profile, virtual=ContestParticipation.LIVE)
return get_contest_ranking_list(
self.request, self.object,
ranking_list=partial(base_contest_ranking_list, queryset=queryset),
ranker=lambda users, key: ((_('???'), user) for user in users),
)
return get_contest_ranking_list(self.request, self.object) return get_contest_ranking_list(self.request, self.object)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -714,6 +720,9 @@ class ContestParticipationList(LoginRequiredMixin, ContestRankingBase):
return _("%s's participation in %s") % (self.profile.username, self.object.name) return _("%s's participation in %s") % (self.profile.username, self.object.name)
def get_ranking_list(self): def get_ranking_list(self):
if not self.object.can_see_full_scoreboard(self.request.user) and self.profile != self.request.profile:
raise Http404()
queryset = self.object.users.filter(user=self.profile, virtual__gte=0).order_by('-virtual') queryset = self.object.users.filter(user=self.profile, virtual__gte=0).order_by('-virtual')
live_link = format_html('<a href="{2}#!{1}">{0}</a>', _('Live'), self.profile.username, live_link = format_html('<a href="{2}#!{1}">{0}</a>', _('Live'), self.profile.username,
reverse('contest_ranking', args=[self.object.key])) reverse('contest_ranking', args=[self.object.key]))
@ -741,7 +750,7 @@ class ContestParticipationList(LoginRequiredMixin, ContestRankingBase):
class ContestParticipationDisqualify(ContestMixin, SingleObjectMixin, View): class ContestParticipationDisqualify(ContestMixin, SingleObjectMixin, View):
def get_object(self, queryset=None): def get_object(self, queryset=None):
contest = super().get_object(queryset) contest = super().get_object(queryset)
if not contest.is_editable_by(self.request.user): if not self.can_edit:
raise Http404() raise Http404()
return contest return contest
@ -762,7 +771,7 @@ class ContestMossMixin(ContestMixin, PermissionRequiredMixin):
def get_object(self, queryset=None): def get_object(self, queryset=None):
contest = super().get_object(queryset) contest = super().get_object(queryset)
if settings.MOSS_API_KEY is None: if settings.MOSS_API_KEY is None or not self.can_edit:
raise Http404() raise Http404()
if not contest.is_editable_by(self.request.user): if not contest.is_editable_by(self.request.user):
raise Http404() raise Http404()

View file

@ -438,7 +438,8 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView
else: else:
context['hot_problems'] = None context['hot_problems'] = None
context['point_start'], context['point_end'], context['point_values'] = 0, 0, {} context['point_start'], context['point_end'], context['point_values'] = 0, 0, {}
context['hide_contest_scoreboard'] = self.contest.hide_scoreboard context['hide_contest_scoreboard'] = self.contest.scoreboard_visibility in \
(self.contest.SCOREBOARD_AFTER_CONTEST, self.contest.SCOREBOARD_AFTER_PARTICIPATION)
return context return context
def get_noui_slider_points(self): def get_noui_slider_points(self):

View file

@ -54,29 +54,14 @@ class OrganizationSelect2View(Select2View):
class ProblemSelect2View(Select2View): class ProblemSelect2View(Select2View):
def get_queryset(self): def get_queryset(self):
queryset = Problem.objects.filter(Q(code__icontains=self.term) | Q(name__icontains=self.term)) return Problem.get_visible_problems(self.request.user) \
if not self.request.user.has_perm('judge.see_private_problem'): .filter(Q(code__icontains=self.term) | Q(name__icontains=self.term))
filter = Q(is_public=True)
if self.request.user.is_authenticated:
filter |= Q(authors=self.request.profile) | Q(curators=self.request.profile)
queryset = queryset.filter(filter).distinct()
return queryset.distinct()
class ContestSelect2View(Select2View): class ContestSelect2View(Select2View):
def get_queryset(self): def get_queryset(self):
queryset = Contest.objects.filter(Q(key__icontains=self.term) | Q(name__icontains=self.term)) return Contest.get_visible_contests(self.request.user) \
if not self.request.user.has_perm('judge.see_private_contest'): .filter(Q(key__icontains=self.term) | Q(name__icontains=self.term))
queryset = queryset.filter(is_visible=True)
if not self.request.user.has_perm('judge.edit_all_contest'):
q = Q(is_private=False, is_organization_private=False)
if self.request.user.is_authenticated:
q |= Q(is_organization_private=True,
organizations__in=self.request.profile.organizations.all())
q |= Q(is_private=True, private_contestants=self.request.profile)
q |= Q(view_contest_scoreboard=self.request.profile)
queryset = queryset.filter(q)
return queryset
class CommentSelect2View(Select2View): class CommentSelect2View(Select2View):
@ -119,8 +104,7 @@ class UserSearchSelect2View(BaseListView):
class ContestUserSearchSelect2View(UserSearchSelect2View): class ContestUserSearchSelect2View(UserSearchSelect2View):
def get_queryset(self): def get_queryset(self):
contest = get_object_or_404(Contest, key=self.kwargs['contest']) contest = get_object_or_404(Contest, key=self.kwargs['contest'])
if not contest.can_see_scoreboard(self.request.user) or \ if not contest.is_accessible_by(self.request.user) or not contest.can_see_full_scoreboard(self.request.user):
contest.hide_scoreboard and contest.is_in_contest(self.request.user):
raise Http404() raise Http404()
return Profile.objects.filter(contest_history__contest=contest, return Profile.objects.filter(contest_history__contest=contest,

View file

@ -43,7 +43,7 @@ from judge.utils.problems import get_result_data
from judge.utils.problems import user_authored_ids from judge.utils.problems import user_authored_ids
from judge.utils.problems import user_completed_ids from judge.utils.problems import user_completed_ids
from judge.utils.problems import user_editable_ids from judge.utils.problems import user_editable_ids
from judge.utils.raw_sql import use_straight_join from judge.utils.raw_sql import join_sql_subquery, use_straight_join
from judge.utils.views import DiggPaginatorMixin from judge.utils.views import DiggPaginatorMixin
from judge.utils.views import TitleMixin from judge.utils.views import TitleMixin
@ -292,11 +292,9 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
queryset=ProblemTranslation.objects.filter( queryset=ProblemTranslation.objects.filter(
language=self.request.LANGUAGE_CODE), to_attr='_trans')) language=self.request.LANGUAGE_CODE), to_attr='_trans'))
if self.in_contest: if self.in_contest:
queryset = queryset.filter( queryset = queryset.filter(contest_object=self.contest)
contest__participation__contest_id=self.contest.id) if not self.contest.can_see_full_scoreboard(self.request.user):
if self.contest.hide_scoreboard and self.contest.is_in_contest(self.request.user): queryset = queryset.filter(user=self.request.profile)
queryset = queryset.filter(
contest__participation__user=self.request.profile)
else: else:
queryset = queryset.select_related( queryset = queryset.select_related(
'contest_object').defer('contest_object__description') 'contest_object').defer('contest_object__description')
@ -304,12 +302,18 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
# This is not technically correct since contest organizers *should* see these, but # This is not technically correct since contest organizers *should* see these, but
# the join would be far too messy # the join would be far too messy
if not self.request.user.has_perm('judge.see_private_contest'): if not self.request.user.has_perm('judge.see_private_contest'):
queryset = queryset.exclude( # Show submissions for any contest you can edit or visible scoreboard
contest_object_id__in=Contest.objects.filter(hide_scoreboard=True)) contest_queryset = Contest.objects.filter(Q(authors=self.request.profile) |
Q(curators=self.request.profile) |
Q(scoreboard_visibility=Contest.SCOREBOARD_VISIBLE) |
Q(end_time__lt=timezone.now())).distinct()
queryset = queryset.filter(Q(user=self.request.profile) |
Q(contest_object__in=contest_queryset) |
Q(contest_object__isnull=True))
if self.selected_languages: if self.selected_languages:
queryset = queryset.filter( queryset = queryset.filter(
language_id__in=Language.objects.filter(key__in=self.selected_languages)) language__in=Language.objects.filter(key__in=self.selected_languages))
if self.selected_statuses: if self.selected_statuses:
queryset = queryset.filter(result__in=self.selected_statuses) queryset = queryset.filter(result__in=self.selected_statuses)
@ -318,14 +322,13 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
def get_queryset(self): def get_queryset(self):
queryset = self._get_queryset() queryset = self._get_queryset()
if not self.in_contest: if not self.in_contest:
if not self.request.user.has_perm('judge.see_private_problem'): join_sql_subquery(
queryset = queryset.filter(problem__is_public=True) queryset,
if not self.request.user.has_perm('judge.see_organization_problem'): subquery=str(Problem.get_visible_problems(self.request.user).distinct().only('id').query),
filter = Q(problem__is_organization_private=False) params=[],
if self.request.user.is_authenticated: join_fields=[('problem_id', 'id')],
filter |= Q( alias='visible_problems',
problem__organizations__in=self.request.profile.organizations.all()) )
queryset = queryset.filter(filter)
return queryset return queryset
def get_my_submissions_page(self): def get_my_submissions_page(self):
@ -452,7 +455,7 @@ class ProblemSubmissionsBase(SubmissionsListBase):
reverse('problem_detail', args=[self.problem.code])) reverse('problem_detail', args=[self.problem.code]))
def access_check_contest(self, request): def access_check_contest(self, request):
if self.in_contest and not self.contest.can_see_scoreboard(request.user): if self.in_contest and not self.contest.can_see_own_scoreboard(request.user):
raise Http404() raise Http404()
def access_check(self, request): def access_check(self, request):

View file

@ -37,3 +37,4 @@ django-newsletter
python-memcached python-memcached
netaddr netaddr
redis redis
lupa

View file

@ -50,7 +50,9 @@ select[id^=id_contest_problems] {
width: 20em !important; width: 20em !important;
} }
select#id_organizers.django-select2, select#id_authors.django-select2,
select#id_curators.django-select2,
select#id_testers.django-select2,
select#id_organizations.django-select2, select#id_organizations.django-select2,
select#id_tags.django-select2 { select#id_tags.django-select2 {
width: 20em; width: 20em;

View file

@ -13,7 +13,7 @@
{% endblock extrahead %} {% endblock extrahead %}
{% block after_field_sets %}{{ block.super }} {% block after_field_sets %}{{ block.super }}
{% if original and original.is_rated and perms.judge.contest_rating %} {% if original and original.is_rated and original.ended and perms.judge.contest_rating %}
<a style="display: none" title="{% trans "Rate" %}" href="{% url 'admin:judge_contest_rate' original.pk %}" <a style="display: none" title="{% trans "Rate" %}" href="{% url 'admin:judge_contest_rate' original.pk %}"
class="button rerate-link"> class="button rerate-link">
<i class="fa fa-lg fa-signal"></i> <i class="fa fa-lg fa-signal"></i>

View file

@ -2,21 +2,21 @@
{% block tabs %} {% block tabs %}
{{ make_tab('detail', 'fa-info-circle', url('contest_view', contest.key), _('Info')) }} {{ make_tab('detail', 'fa-info-circle', url('contest_view', contest.key), _('Info')) }}
{% if contest.ended or contest.is_editable_by(request.user) %} {% if contest.ended or can_edit %}
{{ make_tab('stats', 'fa-pie-chart', url('contest_stats', contest.key), _('Statistics')) }} {{ make_tab('stats', 'fa-pie-chart', url('contest_stats', contest.key), _('Statistics')) }}
{% endif %} {% endif %}
{% if contest.start_time <= now or perms.judge.see_private_contest %} {% if contest.start_time <= now or perms.judge.see_private_contest %}
{% if contest.show_scoreboard or contest.can_see_scoreboard(request.user) %} {% if contest.can_see_own_scoreboard(request.user) %}
{{ make_tab('ranking', 'fa-bar-chart', url('contest_ranking', contest.key), _('Rankings')) }} {{ make_tab('ranking', 'fa-bar-chart', url('contest_ranking', contest.key), _('Rankings')) }}
{% if request.user.is_authenticated %}
{{ make_tab('participation', 'fa-users', url('contest_participation_own', contest.key), _('Participation')) }}
{% endif %}
{% else %} {% else %}
{{ make_tab('ranking', 'fa-bar-chart', None, _('Hidden Rankings')) }} {{ make_tab('ranking', 'fa-bar-chart', None, _('Hidden Rankings')) }}
{% endif %} {% endif %}
{% if contest.show_scoreboard and request.user.is_authenticated %}
{{ make_tab('participation', 'fa-users', url('contest_participation_own', contest.key), _('Participation')) }}
{% endif %} {% endif %}
{% endif %} {% if can_edit %}
{% if contest.is_editable_by(request.user) %}
{% if perms.judge.moss_contest and has_moss_api_key %} {% if perms.judge.moss_contest and has_moss_api_key %}
{{ make_tab('moss', 'fa-gavel', url('contest_moss', contest.key), _('MOSS')) }} {{ make_tab('moss', 'fa-gavel', url('contest_moss', contest.key), _('MOSS')) }}
{% endif %} {% endif %}
@ -27,17 +27,18 @@
{% endif %} {% endif %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{% if contest.can_join or participating or is_organizer %} {% if contest.can_join or is_editor or is_tester %}
{% set in_contest = contest.is_in_contest(request.user) %}
{% if contest.ended %} {% if contest.ended %}
{# Allow users to leave the virtual contest #}
{% if in_contest %} {% if in_contest %}
{# They're in the contest because they're participating virtually #}
<form action="{{ url('contest_leave', contest.key) }}" method="post" <form action="{{ url('contest_leave', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full"> class="contest-join-pseudotab unselectable button full">
{% csrf_token %} {% csrf_token %}
<input type="submit" class="leaving-forever" value="{{ _('Leave contest') }}"> <input type="submit" class="leaving-forever" value="{{ _('Leave contest') }}">
</form> </form>
{% else %} {% else %}
{# They're in the contest because they're participating virtually #} {# Allow users to virtual join #}
<form action="{{ url('contest_join', contest.key) }}" method="post" <form action="{{ url('contest_join', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full"> class="contest-join-pseudotab unselectable button full">
{% csrf_token %} {% csrf_token %}
@ -45,35 +46,30 @@
</form> </form>
{% endif %} {% endif %}
{% else %} {% else %}
{# Allow users to leave the contest #}
{% if in_contest %} {% if in_contest %}
{# Allow people with ended participations to continue spectating #}
<form action="{{ url('contest_leave', contest.key) }}" method="post" <form action="{{ url('contest_leave', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full"> class="contest-join-pseudotab unselectable button full">
{% csrf_token %} {% csrf_token %}
<input type="submit" value=" <input type="submit" value="
{%- if participating and participation.ended or request.profile in contest.organizers.all() %} {%- if request.participation.spectate %}
{{- _('Stop spectating') -}} {{- _('Stop spectating') -}}
{% else %} {% else %}
{{- _('Leave contest') -}} {{- _('Leave contest') -}}
{% endif %}"> {% endif %}">
</form> </form>
{% elif participating and participation.ended or is_organizer %} {% elif is_editor or is_tester or live_participation.ended %}
<form action="{{ url('contest_join', contest.key) }}" method="post" <form action="{{ url('contest_join', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full"> class="contest-join-pseudotab unselectable button full">
{% csrf_token %} {% csrf_token %}
<input type="submit" value="{{ _('Spectate contest') }}"> <input type="submit" value="{{ _('Spectate contest') }}">
</form> </form>
{% elif participating %}
<form action="{{ url('contest_join', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full">
{% csrf_token %}
<input type="submit" value="{{ _('Join contest') }}">
</form>
{% else %} {% else %}
<form action="{{ url('contest_join', contest.key) }}" method="post" <form action="{{ url('contest_join', contest.key) }}" method="post"
class="contest-join-pseudotab unselectable button full"> class="contest-join-pseudotab unselectable button full">
{% csrf_token %} {% csrf_token %}
<input type="submit" class="first-join" value="{{ _('Join contest') }}"> <input type="submit" {% if not has_joined %}class="first-join"{% endif %}
value="{{ _('Join contest') }}">
</form> </form>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -28,11 +28,11 @@
<div id="banner"> <div id="banner">
<a href="https://www.timeanddate.com/worldclock/fixedtime.html?msg={{ contest.name|urlquote('') }}&amp;iso= <a href="https://www.timeanddate.com/worldclock/fixedtime.html?msg={{ contest.name|urlquote('') }}&amp;iso=
{{- contest.start_time|utc|date('Y-m-d\TH:i:s') }}" class="date"> {{- contest.start_time|utc|date('Y-m-d\TH:i:s') }}" class="date">
{%- if participating and participation.virtual and not participation.ended -%} {%- if contest.is_in_contest(request.user) and not request.participation.live -%}
{% if participation.spectate %} {% if request.participation.spectate %}
{{- _('Spectating, contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}} {{- _('Spectating, contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}}
{% elif participation.end_time %} {% elif request.participation.end_time %}
{{- _('Participating virtually, %(countdown)s remaining.', countdown=participation.time_remaining|as_countdown) -}} {{- _('Participating virtually, %(countdown)s remaining.', countdown=request.participation.time_remaining|as_countdown) -}}
{% else %} {% else %}
{{- _('Participating virtually.') -}} {{- _('Participating virtually.') -}}
{% endif %} {% endif %}
@ -42,11 +42,11 @@
{% elif contest.end_time < now %} {% elif contest.end_time < now %}
{{- _('Contest is over.') -}} {{- _('Contest is over.') -}}
{% else %} {% else %}
{%- if participating -%} {%- if has_joined -%}
{% if participation.ended %} {% if live_participation.ended %}
{{- _('Your time is up! Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}} {{- _('Your time is up! Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}}
{% else %} {% else %}
{{- _('You have %(countdown)s remaining.', countdown=participation.time_remaining|as_countdown) -}} {{- _('You have %(countdown)s remaining.', countdown=live_participation.time_remaining|as_countdown) -}}
{% endif %} {% endif %}
{%- else -%} {%- else -%}
{{ _('Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) }} {{ _('Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) }}
@ -73,7 +73,7 @@
{% endcache %} {% endcache %}
</div> </div>
{% if contest.ended or request.user.is_superuser or is_organizer %} {% if contest.ended or request.user.is_superuser or is_editor or is_tester %}
<hr> <hr>
<div class="contest-problems"> <div class="contest-problems">
<h2 style="margin-bottom: 0.2em"><i class="fa fa-fw fa-question-circle"></i>{{ _('Problems') }} </h2> <h2 style="margin-bottom: 0.2em"><i class="fa fa-fw fa-question-circle"></i>{{ _('Problems') }} </h2>

View file

@ -185,7 +185,7 @@
{% endmacro %} {% endmacro %}
{% macro user_count(contest, user) %} {% macro user_count(contest, user) %}
{% if contest.show_scoreboard or contest.can_see_scoreboard(user) %} {% if contest.can_see_own_scoreboard(user) %}
<a href="{{ url('contest_ranking', contest.key) }}">{{ contest.user_count }}</a> <a href="{{ url('contest_ranking', contest.key) }}">{{ contest.user_count }}</a>
{% else %} {% else %}
{{ contest.user_count }} {{ contest.user_count }}
@ -195,7 +195,7 @@
{% macro contest_join(contest, request) %} {% macro contest_join(contest, request) %}
{% if not request.in_contest %} {% if not request.in_contest %}
<td> <td>
{% if request.profile in contest.organizers.all() %} {% if request.profile in contest.authors.all() or request.profile in contest.curators.all() or request.profile in contest.testers.all() %}
<form action="{{ url('contest_join', contest.key) }}" method="post"> <form action="{{ url('contest_join', contest.key) }}" method="post">
{% csrf_token %} {% csrf_token %}
<input type="submit" class="unselectable button full participate-button" <input type="submit" class="unselectable button full participate-button"

View file

@ -32,7 +32,7 @@
{% endblock %} {% endblock %}
{% block user_data %} {% block user_data %}
{% if is_organizer %} {% if can_edit %}
<span class="contest-participation-operation"> <span class="contest-participation-operation">
<form action="{{ url('contest_participation_disqualify', contest.key) }}" method="post"> <form action="{{ url('contest_participation_disqualify', contest.key) }}" method="post">
{% csrf_token %} {% csrf_token %}
@ -56,7 +56,7 @@
{% block before_point_head %} {% block before_point_head %}
{% for problem in problems %} {% for problem in problems %}
<th class="points header problem-score-col"><a href="{{ url('contest_ranked_submissions', contest.key, problem.problem.code) }}"> <th class="points header problem-score-col"><a href="{{ url('contest_ranked_submissions', contest.key, problem.problem.code) }}">
{{- loop.index }} {{- contest.get_label_for_problem(loop.index0) }}
<div class="point-denominator">{{ problem.points }}</div> <div class="point-denominator">{{ problem.points }}</div>
</a></th> </a></th>
{% endfor %} {% endfor %}

View file

@ -179,7 +179,7 @@
{% endblock %} {% endblock %}
{% block users_js_media %} {% block users_js_media %}
{% if is_organizer %} {% if can_edit %}
<script type="text/javascript"> <script type="text/javascript">
$(function () { $(function () {
$('a.disqualify-participation').click(function (e) { $('a.disqualify-participation').click(function (e) {
@ -349,7 +349,7 @@
{% block users_table %} {% block users_table %}
<div style="margin-bottom: 0.5em"> <div style="margin-bottom: 0.5em">
{% if tab == 'participation' %} {% if tab == 'participation' %}
{% if contest.start_time <= now or perms.judge.see_private_contest %} {% if contest.can_see_full_scoreboard(request.user) %}
<input id="search-contest" type="text" placeholder="{{ _('View user participation') }}"> <input id="search-contest" type="text" placeholder="{{ _('View user participation') }}">
{% endif %} {% endif %}
{% endif %} {% endif %}