diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6190110f6..f541ed3051 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,10 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.7 + - name: Set up Python 3.11 uses: useblacksmith/setup-python@v6 with: - python-version: '3.7' + python-version: '3.11' - name: Install flake8 run: pip install flake8 flake8-import-order flake8-future-import flake8-commas flake8-logging-format flake8-quotes - name: Lint with flake8 @@ -19,10 +19,10 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.7 + - name: Set up Python 3.11 uses: useblacksmith/setup-python@v6 with: - python-version: '3.7' + python-version: '3.11' - name: Cache pip uses: useblacksmith/cache@v5 with: diff --git a/.github/workflows/compilemessages.yml b/.github/workflows/compilemessages.yml index a635cafc3d..a2ad5bf4ff 100644 --- a/.github/workflows/compilemessages.yml +++ b/.github/workflows/compilemessages.yml @@ -11,10 +11,10 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.7 + - name: Set up Python 3.11 uses: useblacksmith/setup-python@v6 with: - python-version: '3.7' + python-version: '3.11' - name: Checkout submodules run: | git submodule init diff --git a/django_ace/static/django_ace/widget.js b/django_ace/static/django_ace/widget.js index 7da6f32436..de517577fa 100644 --- a/django_ace/static/django_ace/widget.js +++ b/django_ace/static/django_ace/widget.js @@ -111,9 +111,15 @@ } setEditorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches); - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(ev) { - setEditorTheme(ev.matches); - }) + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(ev) { + setEditorTheme(ev.matches); + }) + } catch (err) { + window.matchMedia('(prefers-color-scheme: dark)').addListener(function(ev) { + setEditorTheme(ev.matches); + }) + } } } if (wordwrap == "true") { diff --git a/judge/admin/organization.py b/judge/admin/organization.py index ff5fab5f0b..0224423b37 100644 --- a/judge/admin/organization.py +++ b/judge/admin/organization.py @@ -105,7 +105,7 @@ def has_change_permission(self, request, obj=None): class OrganizationRequestAdmin(admin.ModelAdmin): list_display = ('username', 'organization', 'state', 'time') - readonly_fields = ('user', 'organization', 'request_class') + readonly_fields = ('user', 'organization', 'state', 'request_class') @admin.display(description=_('username'), ordering='user__user__username') def username(self, obj): diff --git a/judge/admin/problem.py b/judge/admin/problem.py index c78c0981f6..1ca41ba5a0 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -2,6 +2,7 @@ from django import forms from django.contrib import admin +from django.core.exceptions import PermissionDenied from django.db import transaction from django.forms import ModelForm from django.urls import reverse, reverse_lazy @@ -150,7 +151,8 @@ class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin): def get_actions(self, request): actions = super(ProblemAdmin, self).get_actions(request) - if request.user.has_perm('judge.change_public_visibility'): + if request.user.has_perm('judge.change_public_visibility') or \ + request.user.has_perm('judge.create_private_problem'): func, name, desc = self.get_action('make_public') actions[name] = (func, name, desc) @@ -164,8 +166,10 @@ def get_actions(self, request): def get_readonly_fields(self, request, obj=None): fields = self.readonly_fields - if not request.user.has_perm('judge.change_public_visibility'): - fields += ('is_public',) + if not request.user.has_perm('judge.create_private_problem'): + fields += ('organizations',) + if not request.user.has_perm('judge.change_public_visibility'): + fields += ('is_public',) if not request.user.has_perm('judge.change_manually_managed'): fields += ('is_manually_managed',) if not request.user.has_perm('judge.problem_full_markup'): @@ -195,6 +199,8 @@ def update_publish_date(self, request, queryset): @admin.display(description=_('Mark problems as public')) def make_public(self, request, queryset): + if not request.user.has_perm('judge.change_public_visibility'): + queryset = queryset.filter(is_organization_private=True) count = queryset.update(is_public=True) for problem_id in queryset.values_list('id', flat=True): self._rescore(request, problem_id) @@ -204,6 +210,8 @@ def make_public(self, request, queryset): @admin.display(description=_('Mark problems as private')) def make_private(self, request, queryset): + if not request.user.has_perm('judge.change_public_visibility'): + queryset = queryset.filter(is_organization_private=True) count = queryset.update(is_public=False) for problem_id in queryset.values_list('id', flat=True): self._rescore(request, problem_id) @@ -233,6 +241,13 @@ def save_model(self, request, obj, form, change): # `organizations` will not appear in `cleaned_data` if user cannot edit it if form.changed_data and 'organizations' in form.changed_data: obj.is_organization_private = bool(form.cleaned_data['organizations']) + + if form.cleaned_data.get('is_public') and not request.user.has_perm('judge.change_public_visibility'): + if not obj.is_organization_private: + raise PermissionDenied + if not request.user.has_perm('judge.create_private_problem'): + raise PermissionDenied + super(ProblemAdmin, self).save_model(request, obj, form, change) if ( form.changed_data and diff --git a/judge/admin/submission.py b/judge/admin/submission.py index 1fb5a27eb4..fc897a7f24 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -82,7 +82,7 @@ def formfield_for_dbfield(self, db_field, **kwargs): contest__problems=submission.problem) \ .only('id', 'contest__name', 'virtual') - def label(obj): + def label(obj): # noqa: F811 if obj.spectate: return gettext('%s (spectating)') % obj.contest.name if obj.virtual: @@ -92,7 +92,7 @@ def label(obj): kwargs['queryset'] = ContestProblem.objects.filter(problem=submission.problem) \ .only('id', 'problem__name', 'contest__name') - def label(obj): + def label(obj): # noqa: F811 return pgettext('contest problem', '%(problem)s in %(contest)s') % { 'problem': obj.problem.name, 'contest': obj.contest.name, } diff --git a/judge/migrations/0148_clarify_rate_all_desc.py b/judge/migrations/0148_clarify_rate_all_desc.py new file mode 100644 index 0000000000..110bcfb0a9 --- /dev/null +++ b/judge/migrations/0148_clarify_rate_all_desc.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0147_judge_add_tiers'), + ] + + operations = [ + migrations.AlterField( + model_name='contest', + name='rate_all', + field=models.BooleanField(default=False, help_text='Rate users even if they make no submissions.', verbose_name='rate all'), + ), + ] diff --git a/judge/migrations/0149_add_organization_private_problems_permission.py b/judge/migrations/0149_add_organization_private_problems_permission.py new file mode 100644 index 0000000000..226840f7af --- /dev/null +++ b/judge/migrations/0149_add_organization_private_problems_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2024-12-17 03:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0148_clarify_rate_all_desc'), + ] + + operations = [ + migrations.AlterModelOptions( + name='problem', + options={'permissions': (('see_private_problem', 'See hidden problems'), ('edit_own_problem', 'Edit own problems'), ('edit_all_problem', 'Edit all problems'), ('edit_public_problem', 'Edit all public problems'), ('problem_full_markup', 'Edit problems with full markup'), ('clone_problem', 'Clone problem'), ('change_public_visibility', 'Change is_public field'), ('change_manually_managed', 'Change is_manually_managed field'), ('see_organization_problem', 'See organization-private problems'), ('create_private_problem', 'Create private problems')), 'verbose_name': 'problem', 'verbose_name_plural': 'problems'}, + ), + ] diff --git a/judge/models/contest.py b/judge/models/contest.py index 37f2010a47..3c752ef696 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -114,7 +114,9 @@ class Contest(models.Model): rating_ceiling = models.IntegerField(verbose_name=_('rating ceiling'), help_text=_('Do not rate users who have a higher rating.'), null=True, blank=True) - rate_all = models.BooleanField(verbose_name=_('rate all'), help_text=_('Rate all users who joined.'), default=False) + rate_all = models.BooleanField(verbose_name=_('rate all'), + help_text=_('Rate users even if they make no submissions.'), + default=False) rate_exclude = models.ManyToManyField(Profile, verbose_name=_('exclude from ratings'), blank=True, related_name='rate_exclude+') is_private = models.BooleanField(verbose_name=_('private to specific users'), default=False) diff --git a/judge/models/problem.py b/judge/models/problem.py index aa436724fb..50f30f5cdb 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -501,6 +501,7 @@ class Meta: ('change_public_visibility', _('Change is_public field')), ('change_manually_managed', _('Change is_manually_managed field')), ('see_organization_problem', _('See organization-private problems')), + ('create_private_problem', _('Create private problems')), ) verbose_name = _('problem') verbose_name_plural = _('problems') diff --git a/judge/models/runtime.py b/judge/models/runtime.py index dda6a029d7..16046f149c 100644 --- a/judge/models/runtime.py +++ b/judge/models/runtime.py @@ -163,7 +163,7 @@ def toggle_disabled(self): def runtime_versions(cls): qs = (RuntimeVersion.objects.filter(judge__online=True) .values('judge__name', 'language__key', 'language__name', 'version', 'name') - .order_by('language__key', 'priority')) + .order_by('language__name', 'priority')) ret = defaultdict(OrderedDict) diff --git a/judge/signals.py b/judge/signals.py index ce4c5d4612..c54554b46b 100644 --- a/judge/signals.py +++ b/judge/signals.py @@ -9,8 +9,8 @@ from django.dispatch import receiver from .caching import finished_submission -from .models import BlogPost, Comment, Contest, ContestSubmission, EFFECTIVE_MATH_ENGINES, Judge, Language, License, \ - MiscConfig, Organization, Problem, Profile, Submission, WebAuthnCredential +from .models import BlogPost, Comment, Contest, ContestProblem, ContestSubmission, EFFECTIVE_MATH_ENGINES, Judge, \ + Language, License, MiscConfig, Organization, Problem, Profile, Submission, WebAuthnCredential def get_pdf_path(basename: str) -> Optional[str]: @@ -79,6 +79,13 @@ def contest_update(sender, instance, **kwargs): for engine in EFFECTIVE_MATH_ENGINES]) +@receiver(post_delete, sender=ContestProblem) +def contest_problem_delete(sender, instance, **kwargs): + # `contest_object` is the `Contest` object indirectly associated with the `Submission` object + # `contest` is the `ContestSubmission` object associated with the `Submission` object + Submission.objects.filter(contest_object=instance.contest, contest__isnull=True).update(contest_object=None) + + @receiver(post_save, sender=License) def license_update(sender, instance, **kwargs): cache.delete(make_template_fragment_key('license_html', (instance.id,)))