From 297b8a2a36077e457b5da81cca207e5bacb19f82 Mon Sep 17 00:00:00 2001 From: cuom1999 Date: Mon, 24 May 2021 15:00:36 -0500 Subject: [PATCH] Add contest tester/curator (DMOJ) --- judge/admin/contest.py | 42 ++-- judge/contest_format/base.py | 8 + judge/contest_format/default.py | 7 + judge/contest_format/icpc.py | 129 ++++++++++ judge/migrations/0115_auto_20210525_0222.py | 63 +++++ judge/models/contest.py | 228 ++++++++++++++---- judge/models/problem.py | 39 ++- judge/models/profile.py | 15 +- judge/sitemap.py | 2 +- judge/tasks/__init__.py | 1 + judge/utils/problems.py | 4 +- judge/views/api/api_v1.py | 12 +- judge/views/blog.py | 15 +- judge/views/contests.py | 139 ++++++----- judge/views/problem.py | 3 +- judge/views/select2.py | 26 +- judge/views/submission.py | 39 +-- requirements.txt | 1 + resources/wpadmin/css/wpadmin.site.css | 4 +- .../admin/judge/contest/change_form.html | 2 +- templates/contest/contest-tabs.html | 34 ++- templates/contest/contest.html | 16 +- templates/contest/list.html | 4 +- templates/contest/ranking-table.html | 4 +- templates/contest/ranking.html | 4 +- 25 files changed, 611 insertions(+), 230 deletions(-) create mode 100644 judge/contest_format/icpc.py create mode 100644 judge/migrations/0115_auto_20210525_0222.py 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 %} - {% 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() %}
{% csrf_token %} {% csrf_token %} @@ -56,7 +56,7 @@ {% block before_point_head %} {% for problem in problems %} - {{- loop.index }} + {{- contest.get_label_for_problem(loop.index0) }}
{{ problem.points }}
{% endfor %} diff --git a/templates/contest/ranking.html b/templates/contest/ranking.html index a8f2ace..63b0538 100644 --- a/templates/contest/ranking.html +++ b/templates/contest/ranking.html @@ -179,7 +179,7 @@ {% endblock %} {% block users_js_media %} - {% if is_organizer %} + {% if can_edit %}