diff --git a/judge/admin/contest.py b/judge/admin/contest.py
index f815016..1726d88 100644
--- a/judge/admin/contest.py
+++ b/judge/admin/contest.py
@@ -7,10 +7,12 @@ from django.forms import ModelForm, ModelMultipleChoiceField
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
+from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _, ungettext
from reversion.admin import VersionAdmin
+from django_ace import AceWidget
from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating
from judge.ratings import rate_contest
from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminPagedownWidget, \
@@ -94,7 +96,9 @@ class ContestForm(ModelForm):
class Meta:
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',
attrs={'style': 'width: 100%'}),
'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'),
@@ -111,18 +115,19 @@ class ContestForm(ModelForm):
class ContestAdmin(VersionAdmin):
fieldsets = (
- (None, {'fields': ('key', 'name', 'organizers')}),
- (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'hide_scoreboard',
+ (None, {'fields': ('key', 'name', 'authors', 'curators', 'testers')}),
+ (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'scoreboard_visibility',
'run_pretests_only', 'points_precision')}),
(_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}),
(_('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')}),
(_('Access'), {'fields': ('access_code', 'is_private', 'private_contestants', 'is_organization_private',
'organizations', 'view_contest_scoreboard')}),
(_('Justice'), {'fields': ('banned_users',)}),
)
list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count')
+ search_fields = ('key', 'name')
inlines = [ContestProblemInline]
actions_on_top = True
actions_on_bottom = True
@@ -146,7 +151,7 @@ class ContestAdmin(VersionAdmin):
if request.user.has_perm('judge.edit_all_contest'):
return queryset
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):
readonly = []
@@ -158,6 +163,8 @@ class ContestAdmin(VersionAdmin):
readonly += ['is_private', 'private_contestants', 'is_organization_private', 'organizations']
if not request.user.has_perm('judge.change_contest_visibility'):
readonly += ['is_visible']
+ if not request.user.has_perm('judge.contest_problem_label'):
+ readonly += ['problem_label_script']
return readonly
def save_model(self, request, obj, form, change):
@@ -185,9 +192,9 @@ class ContestAdmin(VersionAdmin):
def has_change_permission(self, request, obj=None):
if not request.user.has_perm('judge.edit_own_contest'):
return False
- if request.user.has_perm('judge.edit_all_contest') or obj is None:
+ if obj is None:
return True
- return obj.organizers.filter(id=request.profile.id).exists()
+ return obj.is_editable_by(request.user)
def _rescore(self, contest_key):
from judge.tasks import rescore_contest
@@ -232,14 +239,10 @@ class ContestAdmin(VersionAdmin):
if not request.user.has_perm('judge.contest_rating'):
raise PermissionDenied()
with transaction.atomic():
- if connection.vendor == 'sqlite':
- Rating.objects.all().delete()
- else:
- cursor = connection.cursor()
+ with connection.cursor() as cursor:
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
- cursor.close()
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)
return HttpResponseRedirect(reverse('admin:judge_contest_changelist'))
@@ -247,16 +250,21 @@ class ContestAdmin(VersionAdmin):
if not request.user.has_perm('judge.contest_rating'):
raise PermissionDenied()
contest = get_object_or_404(Contest, id=id)
- if not contest.is_rated:
+ if not contest.is_rated or not contest.ended:
raise Http404()
with transaction.atomic():
contest.rate()
return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('admin:judge_contest_changelist')))
- def get_form(self, *args, **kwargs):
- form = super(ContestAdmin, self).get_form(*args, **kwargs)
+ def get_form(self, request, obj=None, **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')
- form.base_fields['organizers'].queryset = Profile.objects.filter(
+ form.base_fields['curators'].queryset = Profile.objects.filter(
Q(user__is_superuser=True) |
Q(user__groups__permissions__codename__in=perms) |
Q(user__user_permissions__codename__in=perms),
diff --git a/judge/contest_format/base.py b/judge/contest_format/base.py
index 7fcadc3..d4e6e6f 100644
--- a/judge/contest_format/base.py
+++ b/judge/contest_format/base.py
@@ -82,6 +82,14 @@ class BaseContestFormat(six.with_metaclass(ABCMeta)):
"""
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
def best_solution_state(cls, points, total):
if not points:
diff --git a/judge/contest_format/default.py b/judge/contest_format/default.py
index 690cdfc..a3c9c0b 100644
--- a/judge/contest_format/default.py
+++ b/judge/contest_format/default.py
@@ -68,3 +68,10 @@ class DefaultContestFormat(BaseContestFormat):
def get_problem_breakdown(self, participation, 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
+ '''
\ No newline at end of file
diff --git a/judge/contest_format/icpc.py b/judge/contest_format/icpc.py
new file mode 100644
index 0000000..0c545fc
--- /dev/null
+++ b/judge/contest_format/icpc.py
@@ -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(' ({penalty})',
+ penalty=floatformat(format_data['penalty'])) if format_data['penalty'] else ''
+ return format_html(
+ '
{points}{penalty} {time} | ',
+ 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(' | ')
+
+ 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
+ '''
\ No newline at end of file
diff --git a/judge/migrations/0115_auto_20210525_0222.py b/judge/migrations/0115_auto_20210525_0222.py
new file mode 100644
index 0000000..91312de
--- /dev/null
+++ b/judge/migrations/0115_auto_20210525_0222.py
@@ -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'),
+ ),
+ ]
diff --git a/judge/models/contest.py b/judge/models/contest.py
index 939e06d..46a6e06 100644
--- a/judge/models/contest.py
+++ b/judge/models/contest.py
@@ -1,12 +1,13 @@
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
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.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _
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 judge import contest_format
@@ -48,11 +49,25 @@ class ContestTag(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,
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)
- organizers = models.ManyToManyField(Profile, help_text=_('These people will be able to edit the contest.'),
- related_name='organizers+')
+ authors = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest.'),
+ 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)
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
@@ -64,10 +79,9 @@ class Contest(models.Model):
'specified organizations.'))
is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'),
default=False)
- hide_scoreboard = models.BooleanField(verbose_name=_('hide scoreboard'),
- help_text=_('Whether the scoreboard should remain hidden for the duration '
- 'of the contest.'),
- default=False)
+ scoreboard_visibility = models.CharField(verbose_name=_('scoreboard visibility'), default=SCOREBOARD_VISIBLE,
+ max_length=1, help_text=_('Scoreboard visibility through the duration '
+ 'of the contest'), choices=SCOREBOARD_VISIBILITY)
view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True,
related_name='view_contest_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 '
'module. Leave empty to use None. Exact format depends on the contest format '
'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,
validators=[MinValueValidator(0), MaxValueValidator(10)],
help_text=_('Number of digits to round points to.'))
@@ -128,30 +146,72 @@ class Contest(models.Model):
def format(self):
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):
# 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:
raise ValidationError('What is this? A contest that ended before it starts?')
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):
if user.is_authenticated:
profile = user.profile
return profile and profile.current_contest is not None and profile.current_contest.contest == self
return False
- def can_see_scoreboard(self, user):
- if user.has_perm('judge.see_private_contest'):
+ def can_see_own_scoreboard(self, user):
+ if self.can_see_full_scoreboard(user):
return True
- if user.is_authenticated and self.organizers.filter(id=user.profile.id).exists():
- return True
- if user.is_authenticated and self.view_contest_scoreboard.filter(id=user.profile.id).exists():
- return True
- if not self.is_visible:
+ if not self.can_join:
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
- 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 True
@@ -186,6 +246,19 @@ class Contest(models.Model):
def ended(self):
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):
return self.name
@@ -198,50 +271,111 @@ class Contest(models.Model):
update_user_count.alters_data = True
- @cached_property
- def show_scoreboard(self):
- if self.hide_scoreboard and not self.ended:
- return False
- return True
+ class Inaccessible(Exception):
+ pass
+
+ class PrivateContest(Exception):
+ 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()
+
+ # Contest is not private
+ if not self.is_private and not self.is_organization_private:
+ return
+
+ if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
+ return
+
+ in_org = self.organizations.filter(id__in=user.profile.organizations.all()).exists()
+ in_users = self.private_contestants.filter(id=user.profile.id).exists()
+
+ if not self.is_private and self.is_organization_private:
+ 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):
- # Contest is publicly visible
- if self.is_visible:
- # Contest is not private
- if not self.is_private and not self.is_organization_private:
- return True
- 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():
- return True
-
- # If the user can view all contests
- if user.has_perm('judge.see_private_contest'):
+ try:
+ self.access_check(user)
+ except (self.Inaccessible, self.PrivateContest):
+ return False
+ else:
return True
- # User can edit the contest
- return self.is_editable_by(user)
-
def is_editable_by(self, user):
# If the user can edit all contests
if user.has_perm('judge.edit_all_contest'):
return True
- # If the user is a contest organizer
- if user.has_perm('judge.edit_own_contest') and \
- self.organizers.filter(id=user.profile.id).exists():
+ # If the user is a contest organizer or curator
+ if user.has_perm('judge.edit_own_contest') and user.profile.id in self.editor_ids:
return True
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):
- Rating.objects.filter(contest__end_time__gte=self.end_time).delete()
- for contest in Contest.objects.filter(is_rated=True, end_time__gte=self.end_time).order_by('end_time'):
+ Rating.objects.filter(contest__end_time__range=(self.end_time, self._now)).delete()
+ for contest in Contest.objects.filter(
+ is_rated=True, end_time__range=(self.end_time, self._now),
+ ).order_by('end_time'):
rate_contest(contest)
class Meta:
@@ -255,6 +389,7 @@ class Contest(models.Model):
('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')
@@ -271,6 +406,7 @@ class ContestParticipation(models.Model):
cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0)
is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False,
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,
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)
diff --git a/judge/models/problem.py b/judge/models/problem.py
index a469ef6..3dac05b 100644
--- a/judge/models/problem.py
+++ b/judge/models/problem.py
@@ -5,7 +5,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.cache import cache
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
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.functions import Coalesce
from django.urls import reverse
@@ -219,6 +219,43 @@ class Problem(models.Model):
def is_subs_manageable_by(self, 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):
return self.name
diff --git a/judge/models/profile.py b/judge/models/profile.py
index 2fb51a3..b3c5fa0 100644
--- a/judge/models/profile.py
+++ b/judge/models/profile.py
@@ -136,12 +136,15 @@ class Profile(models.Model):
def calculate_points(self, table=_pp_table):
from judge.models import Problem
- data = (Problem.objects.filter(submission__user=self, submission__points__isnull=False, is_public=True,
- is_organization_private=False)
- .annotate(max_points=Max('submission__points')).order_by('-max_points')
- .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()
+ public_problems = Problem.get_public_problems()
+ data = (
+ public_problems.filter(submission__user=self, submission__points__isnull=False)
+ .annotate(max_points=Max('submission__points')).order_by('-max_points')
+ .values_list('max_points', flat=True).filter(max_points__gt=0)
+ )
+ extradata = (
+ public_problems.filter(submission__user=self, submission__result='AC').values('id').count()
+ )
bonus_function = settings.DMOJ_PP_BONUS_FUNCTION
points = sum(data)
problems = len(data)
diff --git a/judge/sitemap.py b/judge/sitemap.py
index 6ae8657..2deea97 100644
--- a/judge/sitemap.py
+++ b/judge/sitemap.py
@@ -11,7 +11,7 @@ class ProblemSitemap(Sitemap):
priority = 0.8
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):
return reverse('problem_detail', args=obj)
diff --git a/judge/tasks/__init__.py b/judge/tasks/__init__.py
index 22c5253..90fd6d3 100644
--- a/judge/tasks/__init__.py
+++ b/judge/tasks/__init__.py
@@ -1,3 +1,4 @@
+from judge.tasks.contest import *
from judge.tasks.demo import *
from judge.tasks.contest import *
from judge.tasks.submission import *
diff --git a/judge/utils/problems.py b/judge/utils/problems.py
index f87f807..570035b 100644
--- a/judge/utils/problems.py
+++ b/judge/utils/problems.py
@@ -112,8 +112,8 @@ def hot_problems(duration, limit):
cache_key = 'hot_problems:%d:%d' % (duration.total_seconds(), limit)
qs = cache.get(cache_key)
if qs is None:
- qs = Problem.objects.filter(is_public=True, is_organization_private=False,
- submission__date__gt=timezone.now() - duration, points__gt=3, points__lt=25)
+ qs = Problem.get_public_problems() \
+ .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)
if not qs0:
diff --git a/judge/views/api/api_v1.py b/judge/views/api/api_v1.py
index 93e7690..c67bf34 100644
--- a/judge/views/api/api_v1.py
+++ b/judge/views/api/api_v1.py
@@ -16,10 +16,9 @@ def sane_time_repr(delta):
def api_v1_contest_list(request):
- queryset = Contest.objects.filter(is_visible=True, is_private=False,
- is_organization_private=False).prefetch_related(
- Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list')).defer('description')
-
+ queryset = Contest.get_visible_contests(request.user).prefetch_related(
+ Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list'))
+
return JsonResponse({c.key: {
'name': c.name,
'start_time': c.start_time.isoformat(),
@@ -33,10 +32,7 @@ def api_v1_contest_detail(request, contest):
contest = get_object_or_404(Contest, key=contest)
in_contest = contest.is_in_contest(request.user)
- can_see_rankings = contest.can_see_scoreboard(request.user)
- if contest.hide_scoreboard and in_contest:
- can_see_rankings = False
-
+ can_see_rankings = contest.can_see_full_scoreboard(request.user)
problems = list(contest.contest_problems.select_related('problem')
.defer('problem__description').order_by('order'))
participations = (contest.users.filter(virtual=0, user__is_unlisted=False)
diff --git a/judge/views/blog.py b/judge/views/blog.py
index 4244107..7c6cc11 100644
--- a/judge/views/blog.py
+++ b/judge/views/blog.py
@@ -81,17 +81,14 @@ class PostList(ListView):
.annotate(points=Max('points'), latest=Max('date'))
.order_by('-latest')
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
+
+ visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True) \
+ .order_by('start_time')
- visible_contests = Contest.objects.filter(is_visible=True).order_by('start_time')
- q = Q(is_private=False, is_organization_private=False)
- 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:
profile = self.request.profile
context['own_open_tickets'] = (Ticket.objects.filter(Q(user=profile) | Q(assignees__in=[profile]), is_open=True).order_by('-id')
diff --git a/judge/views/contests.py b/judge/views/contests.py
index b68f137..9099e12 100644
--- a/judge/views/contests.py
+++ b/judge/views/contests.py
@@ -12,7 +12,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
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.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
@@ -59,20 +59,7 @@ def _find_contest(request, key, private_check=True):
class ContestListMixin(object):
def get_queryset(self):
- queryset = Contest.objects.all()
- 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()
+ return Contest.get_visible_contests(self.request.user)
class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
@@ -101,7 +88,7 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
def _get_queryset(self):
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:
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:
for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile,
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:
active.append(participation)
present.remove(participation.contest)
@@ -164,37 +153,44 @@ class ContestMixin(object):
slug_url_kwarg = 'contest'
@cached_property
- def is_organizer(self):
- return self.check_organizer()
+ def is_editor(self):
+ 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):
- if user is None:
- user = self.request.user
- return (contest or self.object).is_editable_by(user)
+ @cached_property
+ def is_tester(self):
+ if not self.request.user.is_authenticated:
+ 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):
context = super(ContestMixin, self).get_context_data(**kwargs)
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
+ try:
+ context['live_participation'] = (
+ self.request.profile.contest_history.get(
+ contest=self.object,
+ virtual=ContestParticipation.LIVE,
+ )
+ )
+ except ContestParticipation.DoesNotExist:
+ context['live_participation'] = None
+ context['has_joined'] = False
else:
- try:
- context['participation'] = profile.contest_history.get(contest=self.object, virtual=0)
- except ContestParticipation.DoesNotExist:
- context['participating'] = False
- context['participation'] = None
- else:
- context['participating'] = True
+ context['has_joined'] = True
else:
- context['participating'] = False
- context['participation'] = None
- context['in_contest'] = False
+ context['live_participation'] = None
+ context['has_joined'] = False
+
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:
metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id,
@@ -210,18 +206,22 @@ class ContestMixin(object):
def get_object(self, queryset=None):
contest = super(ContestMixin, self).get_object(queryset)
- user = self.request.user
profile = self.request.profile
if (profile is not None and
ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()):
return contest
- if not contest.is_visible and not user.has_perm('judge.see_private_contest') and (
- not user.has_perm('judge.edit_own_contest') or
- not self.check_organizer(contest, user)):
+ try:
+ contest.access_check(self.request.user)
+ except Contest.PrivateContest:
+ raise PrivateContestError(contest.name, contest.is_private, contest.is_organization_private,
+ contest.organizations.all())
+ except Contest.Inaccessible:
raise Http404()
-
+ else:
+ return contest
+
if contest.is_private or contest.is_organization_private:
private_contest_error = PrivateContestError(contest.name, contest.is_private,
contest.is_organization_private, contest.organizations.all())
@@ -297,7 +297,7 @@ class ContestClone(ContestMixin, PermissionRequiredMixin, TitleMixin, SingleObje
contest.organizations.set(organizations)
contest.private_contestants.set(private_contestants)
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:
problem.contest = contest
@@ -337,7 +337,7 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
def join_contest(self, request, access_code=None):
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'),
_('"%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 are permanently barred from joining this contest.'))
- requires_access_code = (not (request.user.is_superuser or self.is_organizer) and
- contest.access_code and access_code != contest.access_code)
+ requires_access_code = (not self.can_edit and contest.access_code and access_code != contest.access_code)
if contest.ended:
if requires_access_code:
raise ContestAccessDenied()
@@ -371,22 +370,24 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
else:
break
else:
+ SPECTATE = ContestParticipation.SPECTATE
+ LIVE = ContestParticipation.LIVE
try:
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:
if requires_access_code:
raise ContestAccessDenied()
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(),
)
else:
if participation.ended:
participation = ContestParticipation.objects.get_or_create(
- contest=contest, user=profile, virtual=-1,
+ contest=contest, user=profile, virtual=SPECTATE,
defaults={'real_start': timezone.now()},
)[0]
@@ -449,7 +450,7 @@ class ContestCalendar(TitleMixin, ContestListMixin, TemplateView):
def get_contest_data(self, start, end):
end += timedelta(days=1)
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))
for contest in contests:
start_date = timezone.localtime(contest.start_time).date()
@@ -530,7 +531,7 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
def get_context_data(self, **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()
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'))
.values_list('problem__code', 'result', 'count'),
)
- labels, codes = zip(
- *self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code'),
- )
+ labels, codes = [], []
+ 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)
status_counts = [[] for i in range(num_problems)]
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):
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'))
if show_current_virtual:
@@ -651,7 +649,7 @@ def contest_ranking_ajax(request, contest, participation=None):
if not exists:
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()
users, problems = get_contest_ranking_list(request, contest, participation)
@@ -679,7 +677,7 @@ class ContestRankingBase(ContestMixin, TitleMixin, DetailView):
def get_context_data(self, **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()
users, problems = self.get_ranking_list()
@@ -697,6 +695,14 @@ class ContestRanking(ContestRankingBase):
return _('%s Rankings') % self.object.name
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)
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)
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')
live_link = format_html('{0}', _('Live'), self.profile.username,
reverse('contest_ranking', args=[self.object.key]))
@@ -741,7 +750,7 @@ class ContestParticipationList(LoginRequiredMixin, ContestRankingBase):
class ContestParticipationDisqualify(ContestMixin, SingleObjectMixin, View):
def get_object(self, queryset=None):
contest = super().get_object(queryset)
- if not contest.is_editable_by(self.request.user):
+ if not self.can_edit:
raise Http404()
return contest
@@ -762,7 +771,7 @@ class ContestMossMixin(ContestMixin, PermissionRequiredMixin):
def get_object(self, queryset=None):
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()
if not contest.is_editable_by(self.request.user):
raise Http404()
diff --git a/judge/views/problem.py b/judge/views/problem.py
index fbb2721..64f5f0d 100644
--- a/judge/views/problem.py
+++ b/judge/views/problem.py
@@ -438,7 +438,8 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView
else:
context['hot_problems'] = None
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
def get_noui_slider_points(self):
diff --git a/judge/views/select2.py b/judge/views/select2.py
index cf8e249..0647f98 100644
--- a/judge/views/select2.py
+++ b/judge/views/select2.py
@@ -54,29 +54,14 @@ class OrganizationSelect2View(Select2View):
class ProblemSelect2View(Select2View):
def get_queryset(self):
- queryset = Problem.objects.filter(Q(code__icontains=self.term) | Q(name__icontains=self.term))
- if not self.request.user.has_perm('judge.see_private_problem'):
- 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()
+ return Problem.get_visible_problems(self.request.user) \
+ .filter(Q(code__icontains=self.term) | Q(name__icontains=self.term))
class ContestSelect2View(Select2View):
def get_queryset(self):
- queryset = Contest.objects.filter(Q(key__icontains=self.term) | Q(name__icontains=self.term))
- if not self.request.user.has_perm('judge.see_private_contest'):
- 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
+ return Contest.get_visible_contests(self.request.user) \
+ .filter(Q(key__icontains=self.term) | Q(name__icontains=self.term))
class CommentSelect2View(Select2View):
@@ -119,8 +104,7 @@ class UserSearchSelect2View(BaseListView):
class ContestUserSearchSelect2View(UserSearchSelect2View):
def get_queryset(self):
contest = get_object_or_404(Contest, key=self.kwargs['contest'])
- if not contest.can_see_scoreboard(self.request.user) or \
- contest.hide_scoreboard and contest.is_in_contest(self.request.user):
+ if not contest.is_accessible_by(self.request.user) or not contest.can_see_full_scoreboard(self.request.user):
raise Http404()
return Profile.objects.filter(contest_history__contest=contest,
diff --git a/judge/views/submission.py b/judge/views/submission.py
index 6c7d9e6..5f86ccf 100644
--- a/judge/views/submission.py
+++ b/judge/views/submission.py
@@ -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_completed_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 TitleMixin
@@ -292,11 +292,9 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
queryset=ProblemTranslation.objects.filter(
language=self.request.LANGUAGE_CODE), to_attr='_trans'))
if self.in_contest:
- queryset = queryset.filter(
- contest__participation__contest_id=self.contest.id)
- if self.contest.hide_scoreboard and self.contest.is_in_contest(self.request.user):
- queryset = queryset.filter(
- contest__participation__user=self.request.profile)
+ queryset = queryset.filter(contest_object=self.contest)
+ if not self.contest.can_see_full_scoreboard(self.request.user):
+ queryset = queryset.filter(user=self.request.profile)
else:
queryset = queryset.select_related(
'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
# the join would be far too messy
if not self.request.user.has_perm('judge.see_private_contest'):
- queryset = queryset.exclude(
- contest_object_id__in=Contest.objects.filter(hide_scoreboard=True))
+ # Show submissions for any contest you can edit or visible scoreboard
+ 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:
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:
queryset = queryset.filter(result__in=self.selected_statuses)
@@ -318,14 +322,13 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
def get_queryset(self):
queryset = self._get_queryset()
if not self.in_contest:
- if not self.request.user.has_perm('judge.see_private_problem'):
- queryset = queryset.filter(problem__is_public=True)
- if not self.request.user.has_perm('judge.see_organization_problem'):
- filter = Q(problem__is_organization_private=False)
- if self.request.user.is_authenticated:
- filter |= Q(
- problem__organizations__in=self.request.profile.organizations.all())
- queryset = queryset.filter(filter)
+ join_sql_subquery(
+ queryset,
+ subquery=str(Problem.get_visible_problems(self.request.user).distinct().only('id').query),
+ params=[],
+ join_fields=[('problem_id', 'id')],
+ alias='visible_problems',
+ )
return queryset
def get_my_submissions_page(self):
@@ -452,7 +455,7 @@ class ProblemSubmissionsBase(SubmissionsListBase):
reverse('problem_detail', args=[self.problem.code]))
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()
def access_check(self, request):
diff --git a/requirements.txt b/requirements.txt
index 735c38a..869deb8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,3 +37,4 @@ django-newsletter
python-memcached
netaddr
redis
+lupa
\ No newline at end of file
diff --git a/resources/wpadmin/css/wpadmin.site.css b/resources/wpadmin/css/wpadmin.site.css
index af6b4c6..d60064c 100644
--- a/resources/wpadmin/css/wpadmin.site.css
+++ b/resources/wpadmin/css/wpadmin.site.css
@@ -50,7 +50,9 @@ select[id^=id_contest_problems] {
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_tags.django-select2 {
width: 20em;
diff --git a/templates/admin/judge/contest/change_form.html b/templates/admin/judge/contest/change_form.html
index beb2122..7f540f6 100644
--- a/templates/admin/judge/contest/change_form.html
+++ b/templates/admin/judge/contest/change_form.html
@@ -13,7 +13,7 @@
{% endblock extrahead %}
{% 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 %}
diff --git a/templates/contest/contest-tabs.html b/templates/contest/contest-tabs.html
index 6743433..d063a24 100644
--- a/templates/contest/contest-tabs.html
+++ b/templates/contest/contest-tabs.html
@@ -2,21 +2,21 @@
{% block tabs %}
{{ 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')) }}
{% endif %}
{% 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')) }}
+ {% if request.user.is_authenticated %}
+ {{ make_tab('participation', 'fa-users', url('contest_participation_own', contest.key), _('Participation')) }}
+ {% endif %}
{% else %}
{{ make_tab('ranking', 'fa-bar-chart', None, _('Hidden Rankings')) }}
{% endif %}
- {% if contest.show_scoreboard and request.user.is_authenticated %}
- {{ make_tab('participation', 'fa-users', url('contest_participation_own', contest.key), _('Participation')) }}
- {% endif %}
{% endif %}
- {% if contest.is_editable_by(request.user) %}
+ {% if can_edit %}
{% if perms.judge.moss_contest and has_moss_api_key %}
{{ make_tab('moss', 'fa-gavel', url('contest_moss', contest.key), _('MOSS')) }}
{% endif %}
@@ -27,17 +27,18 @@
{% endif %}
{% 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 %}
+ {# Allow users to leave the virtual contest #}
{% if in_contest %}
- {# They're in the contest because they're participating virtually #}
{% else %}
- {# They're in the contest because they're participating virtually #}
+ {# Allow users to virtual join #}
{% endif %}
{% else %}
+ {# Allow users to leave the contest #}
{% if in_contest %}
- {# Allow people with ended participations to continue spectating #}
- {% elif participating and participation.ended or is_organizer %}
+ {% elif is_editor or is_tester or live_participation.ended %}
- {% elif participating %}
-
{% else %}
{% endif %}
{% endif %}
diff --git a/templates/contest/contest.html b/templates/contest/contest.html
index 9d15cc6..a65d881 100644
--- a/templates/contest/contest.html
+++ b/templates/contest/contest.html
@@ -28,11 +28,11 @@
- {%- if participating and participation.virtual and not participation.ended -%}
- {% if participation.spectate %}
+ {%- if contest.is_in_contest(request.user) and not request.participation.live -%}
+ {% if request.participation.spectate %}
{{- _('Spectating, contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}}
- {% elif participation.end_time %}
- {{- _('Participating virtually, %(countdown)s remaining.', countdown=participation.time_remaining|as_countdown) -}}
+ {% elif request.participation.end_time %}
+ {{- _('Participating virtually, %(countdown)s remaining.', countdown=request.participation.time_remaining|as_countdown) -}}
{% else %}
{{- _('Participating virtually.') -}}
{% endif %}
@@ -42,11 +42,11 @@
{% elif contest.end_time < now %}
{{- _('Contest is over.') -}}
{% else %}
- {%- if participating -%}
- {% if participation.ended %}
+ {%- if has_joined -%}
+ {% if live_participation.ended %}
{{- _('Your time is up! Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) -}}
{% 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 %}
{%- else -%}
{{ _('Contest ends in %(countdown)s.', countdown=contest.time_before_end|as_countdown) }}
@@ -73,7 +73,7 @@
{% endcache %}
- {% 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 %}
{{ _('Problems') }}
diff --git a/templates/contest/list.html b/templates/contest/list.html
index f10b067..9cfbc5c 100644
--- a/templates/contest/list.html
+++ b/templates/contest/list.html
@@ -185,7 +185,7 @@
{% endmacro %}
{% macro user_count(contest, user) %}
- {% if contest.show_scoreboard or contest.can_see_scoreboard(user) %}
+ {% if contest.can_see_own_scoreboard(user) %}
{{ contest.user_count }}
{% else %}
{{ contest.user_count }}
@@ -195,7 +195,7 @@
{% macro contest_join(contest, request) %}
{% if not request.in_contest %}
- {% 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() %}
|