diff --git a/judge/admin/contest.py b/judge/admin/contest.py index 7ab54a20ad..4d3eba3c4c 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -12,7 +12,7 @@ from reversion.admin import VersionAdmin from django_ace import AceWidget -from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating +from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating, Submission from judge.ratings import rate_contest from judge.utils.views import NoBatchDeleteMixin from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminMartorWidget, \ @@ -111,7 +111,7 @@ class ContestAdmin(NoBatchDeleteMixin, VersionAdmin): fieldsets = ( (None, {'fields': ('key', 'name', 'organizers')}), (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'hide_scoreboard', - 'run_pretests_only')}), + 'run_pretests_only', 'is_locked')}), (_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}), (_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}), (_('Format'), {'fields': ('format_name', 'format_config', 'problem_label_script')}), @@ -120,7 +120,8 @@ class ContestAdmin(NoBatchDeleteMixin, VersionAdmin): 'organizations', 'view_contest_scoreboard')}), (_('Justice'), {'fields': ('banned_users',)}), ) - list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count') + list_display = ('key', 'name', 'is_visible', 'is_rated', 'is_locked', 'start_time', 'end_time', 'time_limit', + 'user_count') search_fields = ('key', 'name') inlines = [ContestProblemInline] actions_on_top = True @@ -138,6 +139,10 @@ def get_actions(self, request): for action in ('make_visible', 'make_hidden'): actions[action] = self.get_action(action) + if request.user.has_perm('judge.contest_lock'): + for action in ('set_locked', 'set_unlocked'): + actions[action] = self.get_action(action) + return actions def get_queryset(self, request): @@ -151,6 +156,8 @@ def get_readonly_fields(self, request, obj=None): readonly = [] if not request.user.has_perm('judge.contest_rating'): readonly += ['is_rated', 'rate_all', 'rate_exclude'] + if not request.user.has_perm('judge.contest_lock'): + readonly += ['is_locked'] if not request.user.has_perm('judge.contest_access_code'): readonly += ['access_code'] if not request.user.has_perm('judge.create_private_contest'): @@ -176,6 +183,9 @@ def save_model(self, request, obj, form, change): self._rescore(obj.key) self._rescored = True + if form.changed_data and 'is_locked' in form.changed_data: + self.set_is_locked(obj, form.cleaned_data['is_locked']) + def save_related(self, request, form, formsets, change): super().save_related(request, form, formsets, change) # Only rescored if we did not already do so in `save_model` @@ -211,6 +221,31 @@ def make_hidden(self, request, queryset): count) % count) make_hidden.short_description = _('Mark contests as hidden') + def set_locked(self, request, queryset): + for row in queryset: + self.set_is_locked(row, True) + count = queryset.count() + self.message_user(request, ungettext('%d contest successfully locked.', + '%d contests successfully locked.', + count) % count) + set_locked.short_description = _('Lock contest submissions') + + def set_unlocked(self, request, queryset): + for row in queryset: + self.set_is_locked(row, False) + count = queryset.count() + self.message_user(request, ungettext('%d contest successfully unlocked.', + '%d contests successfully unlocked.', + count) % count) + set_unlocked.short_description = _('Unlock contest submissions') + + def set_is_locked(self, contest, is_locked): + with transaction.atomic(): + contest.is_locked = is_locked + contest.save() + Submission.objects.filter(contest_object=contest, + contest__participation__virtual=0).update(is_locked=is_locked) + def get_urls(self): return [ url(r'^rate/all/$', self.rate_all_view, name='judge_contest_rate_all'), diff --git a/judge/admin/submission.py b/judge/admin/submission.py index 2689a76a84..8327df1d08 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -109,8 +109,8 @@ def get_formset(self, request, obj=None, **kwargs): class SubmissionAdmin(admin.ModelAdmin): readonly_fields = ('user', 'problem', 'date', 'judged_date') - fields = ('user', 'problem', 'date', 'judged_date', 'time', 'memory', 'points', 'language', 'status', 'result', - 'case_points', 'case_total', 'judged_on', 'error') + fields = ('user', 'problem', 'date', 'judged_date', 'is_locked', 'time', 'memory', 'points', 'language', 'status', + 'result', 'case_points', 'case_total', 'judged_on', 'error') actions = ('judge', 'recalculate_score') list_display = ('id', 'problem_code', 'problem_name', 'user_column', 'execution_time', 'pretty_memory', 'points', 'language_column', 'status', 'result', 'judge_column') @@ -120,6 +120,12 @@ class SubmissionAdmin(admin.ModelAdmin): actions_on_bottom = True inlines = [SubmissionSourceInline, SubmissionTestCaseInline, ContestSubmissionInline] + def get_readonly_fields(self, request, obj=None): + fields = self.readonly_fields + if not request.user.has_perm('judge.lock_submission'): + fields += ('is_locked',) + return fields + def get_queryset(self, request): queryset = Submission.objects.select_related('problem', 'user__user', 'language').only( 'problem__code', 'problem__name', 'user__user__username', 'language__name', diff --git a/judge/migrations/0107_submission_lock.py b/judge/migrations/0107_submission_lock.py new file mode 100644 index 0000000000..1d5f796b89 --- /dev/null +++ b/judge/migrations/0107_submission_lock.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.12 on 2020-05-13 20:58 + +from django.db import migrations, models +from django.utils import timezone + + +def updatecontestsubmissions(apps, schema_editor): + Contest = apps.get_model('judge', 'Contest') + Contest.objects.filter(end_time__lt=timezone.now()).update(is_locked=True) + + Submission = apps.get_model('judge', 'Submission') + Submission.objects.filter(contest_object__is_locked=True, contest__participation__virtual=0).update(is_locked=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0106_user_data_download'), + ] + + 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'), ('lock_contest', 'Change lock status of contest')), 'verbose_name': 'contest', 'verbose_name_plural': 'contests'}, + ), + migrations.AlterModelOptions( + name='submission', + options={'permissions': (('abort_any_submission', 'Abort any submission'), ('rejudge_submission', 'Rejudge the submission'), ('rejudge_submission_lot', 'Rejudge a lot of submissions'), ('spam_submission', 'Submit without limit'), ('view_all_submission', 'View all submission'), ('resubmit_other', "Resubmit others' submission"), ('lock_submission', 'Change lock status of submission')), 'verbose_name': 'submission', 'verbose_name_plural': 'submissions'}, + ), + migrations.AddField( + model_name='contest', + name='is_locked', + field=models.BooleanField(default=False, help_text='Prevent submissions from this contest from being rejudged.', verbose_name='contest lock'), + ), + migrations.AddField( + model_name='submission', + name='is_locked', + field=models.BooleanField(default=False, verbose_name='lock submission'), + ), + migrations.RunPython(updatecontestsubmissions, reverse_code=migrations.RunPython.noop), + ] diff --git a/judge/models/contest.py b/judge/models/contest.py index 546b2af19e..c8485083b0 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -126,6 +126,8 @@ class Contest(models.Model): 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.') + is_locked = models.BooleanField(verbose_name=_('contest lock'), default=False, + help_text=_('Prevent submissions from this contest from being rejudged.')) @cached_property def format_class(self): @@ -342,6 +344,7 @@ class Meta: ('create_private_contest', _('Create private contests')), ('change_contest_visibility', _('Change contest visibility')), ('contest_problem_label', _('Edit contest problem label script')), + ('lock_contest', _('Change lock status of contest')), ) verbose_name = _('contest') verbose_name_plural = _('contests') diff --git a/judge/models/submission.py b/judge/models/submission.py index b7f62d088f..dff4654610 100644 --- a/judge/models/submission.py +++ b/judge/models/submission.py @@ -83,6 +83,7 @@ class Submission(models.Model): is_pretested = models.BooleanField(verbose_name=_('was ran on pretests only'), default=False) contest_object = models.ForeignKey('Contest', verbose_name=_('contest'), null=True, blank=True, on_delete=models.SET_NULL, related_name='+') + is_locked = models.BooleanField(verbose_name=_('lock submission'), default=False) objects = TranslatedProblemForeignKeyQuerySet.as_manager() @@ -114,7 +115,8 @@ def long_status(self): return Submission.USER_DISPLAY_CODES.get(self.short_status, '') def judge(self, *args, **kwargs): - judge_submission(self, *args, **kwargs) + if not self.is_locked: + judge_submission(self, *args, **kwargs) judge.alters_data = True @@ -194,6 +196,7 @@ class Meta: ('spam_submission', 'Submit without limit'), ('view_all_submission', 'View all submission'), ('resubmit_other', "Resubmit others' submission"), + ('lock_submission', 'Change lock status of submission'), ) verbose_name = _('submission') verbose_name_plural = _('submissions')