diff --git a/ b/ new file mode 100644 index 000000000..e69de29bb diff --git a/judge/admin/contest.py b/judge/admin/contest.py index a0216a720..aae5b7b74 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -15,6 +15,7 @@ from reversion.admin import VersionAdmin from judge.models import Contest, ContestAnnouncement, ContestProblem, ContestSubmission, Profile, Rating, Submission +from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR from judge.ratings import rate_contest from judge.utils.views import NoBatchDeleteMixin from judge.widgets import AdminAceWidget, AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, \ @@ -124,9 +125,6 @@ def clean(self): class Meta: widgets = { - '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'), 'organization': AdminHeavySelect2Widget(data_view='organization_select2'), 'tags': AdminSelect2MultipleWidget, @@ -137,9 +135,23 @@ class Meta: } +class ContestRoleInlineForm(ModelForm): + class Meta: + model = ContestRole + fields = ('user', 'role') + widgets = {'user': AdminHeavySelect2Widget(data_view='profile_select2')} + + +class ContestRoleInline(admin.TabularInline): + model = ContestRole + fields = ('user', 'role') + extra = 0 + form = ContestRoleInlineForm + + class ContestAdmin(NoBatchDeleteMixin, SortableAdminBase, VersionAdmin): fieldsets = ( - (None, {'fields': ('key', 'name', 'authors', 'curators', 'testers')}), + (None, {'fields': ('key', 'name')}), (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'push_announcements', 'disallow_virtual', 'hide_problem_tags', 'hide_problem_authors', 'show_short_display', 'run_pretests_only', 'locked_after', 'scoreboard_visibility', @@ -159,7 +171,7 @@ class ContestAdmin(NoBatchDeleteMixin, SortableAdminBase, VersionAdmin): list_display = ('key', 'name', 'is_visible', 'is_rated', 'locked_after', 'start_time', 'end_time', 'time_limit', 'user_count') search_fields = ('key', 'name') - inlines = [ContestProblemInline, ContestAnnouncementInline] + inlines = [ContestRoleInline, ContestProblemInline, ContestAnnouncementInline] actions_on_top = True actions_on_bottom = True form = ContestForm @@ -186,7 +198,11 @@ def get_queryset(self, request): if request.user.has_perm('judge.edit_all_contest'): return queryset else: - return queryset.filter(Q(authors=request.profile) | Q(curators=request.profile)).distinct() + return queryset.filter( + contest_roles__user=request.profile, + ).filter( + Q(contest_roles__role=ROLE_AUTHOR) | Q(contest_roles__role=ROLE_CURATOR), + ).distinct() def get_readonly_fields(self, request, obj=None): readonly = [] @@ -358,18 +374,9 @@ def rate_view(self, request, id): 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 = AdminAceWidget( mode='lua', theme=request.profile.resolved_ace_theme, ) - - perms = ('edit_own_contest', 'edit_all_contest') - 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), - ).distinct() return form diff --git a/judge/admin/problem.py b/judge/admin/problem.py index 00313b91e..22a72887d 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -1,5 +1,3 @@ -from operator import attrgetter - from django import forms from django.contrib import admin from django.db import transaction @@ -10,7 +8,8 @@ from django.utils.translation import gettext, gettext_lazy as _, ngettext from reversion.admin import VersionAdmin -from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemTranslation, Profile, Solution +from judge.models import LanguageLimit, Problem, ProblemClarification, ProblemTranslation, Solution +from judge.models.role import ProblemRole, ROLE_AUTHOR from judge.utils.views import NoBatchDeleteMixin from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminMartorWidget, \ AdminSelect2MultipleWidget, AdminSelect2Widget, CheckboxSelectMultipleWithSelectAll @@ -21,10 +20,7 @@ class ProblemForm(ModelForm): def __init__(self, *args, **kwargs): super(ProblemForm, self).__init__(*args, **kwargs) - self.fields['authors'].widget.can_add_related = False - self.fields['curators'].widget.can_add_related = False self.fields['suggester'].widget.can_add_related = False - self.fields['testers'].widget.can_add_related = False self.fields['banned_users'].widget.can_add_related = False self.fields['change_message'].widget.attrs.update({ 'placeholder': gettext('Describe the changes you made (optional)'), @@ -32,10 +28,7 @@ def __init__(self, *args, **kwargs): class Meta: widgets = { - 'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), - 'curators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'suggester': AdminHeavySelect2Widget(data_view='profile_select2'), - 'testers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'banned_users': AdminHeavySelect2MultipleWidget(data_view='profile_select2'), 'organization': AdminHeavySelect2Widget(data_view='organization_select2'), 'types': AdminSelect2MultipleWidget, @@ -48,13 +41,18 @@ class ProblemCreatorListFilter(admin.SimpleListFilter): title = parameter_name = 'creator' def lookups(self, request, model_admin): - queryset = Profile.objects.exclude(authored_problems=None).values_list('user__username', flat=True) + queryset = ProblemRole.objects.filter( + role=ROLE_AUTHOR, + ).values_list('user__user__username', flat=True).distinct() return [(name, name) for name in queryset] def queryset(self, request, queryset): if self.value() is None: return queryset - return queryset.filter(authors__user__username=self.value()) + return queryset.filter( + problem_roles__role=ROLE_AUTHOR, + problem_roles__user__user__username=self.value(), + ) class LanguageLimitInlineForm(ModelForm): @@ -118,12 +116,26 @@ def has_permission_full_markup(self, request, obj=None): has_add_permission = has_change_permission = has_delete_permission = has_permission_full_markup +class ProblemRoleInlineForm(ModelForm): + class Meta: + model = ProblemRole + fields = ('user', 'role') + widgets = {'user': AdminHeavySelect2Widget(data_view='profile_select2')} + + +class ProblemRoleInline(admin.TabularInline): + model = ProblemRole + fields = ('user', 'role') + extra = 0 + form = ProblemRoleInlineForm + + class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin): fieldsets = ( (None, { 'fields': ( - 'code', 'name', 'suggester', 'is_public', 'is_manually_managed', 'date', 'authors', - 'curators', 'testers', 'is_organization_private', 'organization', 'submission_source_visibility_mode', + 'code', 'name', 'suggester', 'is_public', 'is_manually_managed', 'date', + 'is_organization_private', 'organization', 'submission_source_visibility_mode', 'testcase_visibility_mode', 'testcase_result_visibility_mode', 'allow_view_feedback', 'is_full_markup', 'pdf_url', 'source', 'description', 'license', ), @@ -138,8 +150,9 @@ class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin): ) list_display = ['code', 'name', 'show_authors', 'points', 'is_public', 'show_public'] ordering = ['code'] - search_fields = ('code', 'name', 'authors__user__username', 'curators__user__username') - inlines = [LanguageLimitInline, ProblemClarificationInline, ProblemSolutionInline, ProblemTranslationInline] + search_fields = ('code', 'name', 'problem_roles__user__user__username') + inlines = [ProblemRoleInline, LanguageLimitInline, ProblemClarificationInline, + ProblemSolutionInline, ProblemTranslationInline] list_max_show_all = 1000 actions_on_top = True actions_on_bottom = True @@ -173,7 +186,10 @@ def get_readonly_fields(self, request, obj=None): @admin.display(description=_('authors')) def show_authors(self, obj): - return ', '.join(map(attrgetter('user.username'), obj.authors.all())) + return ', '.join( + ProblemRole.objects.filter(problem=obj, role=ROLE_AUTHOR) + .values_list('user__user__username', flat=True), + ) @admin.display(description='') def show_public(self, obj): @@ -203,7 +219,9 @@ def make_private(self, request, queryset): count) % count) def get_queryset(self, request): - return Problem.get_editable_problems(request.user).prefetch_related('authors__user').distinct() + return Problem.get_editable_problems(request.user).prefetch_related( + 'problem_roles__user__user', + ).distinct() def has_change_permission(self, request, obj=None): if obj is None: @@ -216,9 +234,7 @@ def formfield_for_manytomany(self, db_field, request=None, **kwargs): return super(ProblemAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) def get_form(self, *args, **kwargs): - form = super(ProblemAdmin, self).get_form(*args, **kwargs) - form.base_fields['authors'].queryset = Profile.objects.all() - return form + return super(ProblemAdmin, self).get_form(*args, **kwargs) def save_model(self, request, obj, form, change): super(ProblemAdmin, self).save_model(request, obj, form, change) diff --git a/judge/admin/submission.py b/judge/admin/submission.py index be9ee65a5..a664a6eeb 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -5,7 +5,7 @@ from django.contrib import admin, messages from django.core.cache import cache from django.core.exceptions import PermissionDenied -from django.db.models import Q +from django.db.models import Exists, OuterRef from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import path, reverse @@ -17,6 +17,7 @@ from judge.models import ContestParticipation, ContestProblem, ContestSubmission, Profile, Submission, \ SubmissionSource, SubmissionTestCase +from judge.models.role import ProblemRole, ROLE_AUTHOR, ROLE_CURATOR from judge.utils.raw_sql import use_straight_join from judge.widgets import AdminAceWidget @@ -143,8 +144,12 @@ def get_queryset(self, request): ) use_straight_join(queryset) if not request.user.has_perm('judge.edit_all_problem'): - id = request.profile.id - queryset = queryset.filter(Q(problem__authors__id=id) | Q(problem__curators__id=id)).distinct() + queryset = queryset.annotate( + has_editor=Exists(ProblemRole.objects.filter( + problem_id=OuterRef('problem_id'), user_id=request.profile.id, + role__in=[ROLE_AUTHOR, ROLE_CURATOR], + )), + ).filter(has_editor=True).distinct() return queryset def has_add_permission(self, request): @@ -173,8 +178,12 @@ def judge(self, request, queryset): level=messages.ERROR) return if not request.user.has_perm('judge.edit_all_problem'): - id = request.profile.id - queryset = queryset.filter(Q(problem__authors__id=id) | Q(problem__curators__id=id)) + queryset = queryset.annotate( + has_editor=Exists(ProblemRole.objects.filter( + problem_id=OuterRef('problem_id'), user_id=request.profile.id, + role__in=[ROLE_AUTHOR, ROLE_CURATOR], + )), + ).filter(has_editor=True) judged = len(queryset) for model in queryset: model.judge(rejudge=True, batch_rejudge=True, rejudge_user=request.user) diff --git a/judge/forms.py b/judge/forms.py index 27db7f528..e4f787e9b 100755 --- a/judge/forms.py +++ b/judge/forms.py @@ -225,17 +225,13 @@ class Meta: model = Problem fields = ['is_public', 'code', 'name', 'time_limit', 'memory_limit', 'points', 'partial', 'statement_file', 'source', 'types', 'group', 'submission_source_visibility_mode', - 'testcase_visibility_mode', 'description', 'testers'] + 'testcase_visibility_mode', 'description'] widgets = { 'types': Select2MultipleWidget, 'group': Select2Widget, 'submission_source_visibility_mode': Select2Widget, 'testcase_visibility_mode': Select2Widget, 'description': MartorWidget(attrs={'data-markdownfy-url': reverse_lazy('problem_preview')}), - 'testers': HeavySelect2MultipleWidget( - data_view='profile_select2', - attrs={'style': 'width: 100%'}, - ), } help_texts = { 'is_public': _( diff --git a/judge/jinja2/submission.py b/judge/jinja2/submission.py index e92df7367..f6f331f18 100644 --- a/judge/jinja2/submission.py +++ b/judge/jinja2/submission.py @@ -1,12 +1,12 @@ -from operator import attrgetter - from judge.models import SubmissionSourceAccess +from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR from . import registry -# TODO: maybe refactor this? def get_editor_ids(contest): - return set(map(attrgetter('id'), contest.authors.all())) | set(map(attrgetter('id'), contest.curators.all())) + return set(ContestRole.objects.filter( + contest=contest, role__in=[ROLE_AUTHOR, ROLE_CURATOR], + ).values_list('user_id', flat=True)) @registry.function diff --git a/judge/migrations/0220_migrate_contest_roles.py b/judge/migrations/0220_migrate_contest_roles.py new file mode 100644 index 000000000..94178b8a8 --- /dev/null +++ b/judge/migrations/0220_migrate_contest_roles.py @@ -0,0 +1,95 @@ +"""Single migration: create ContestRole, copy contest authors/curators/testers data, remove old M2M fields.""" +import django.db.models.deletion +from django.db import migrations, models + +ROLE_AUTHOR = 'A' +ROLE_CURATOR = 'C' +ROLE_TESTER = 'T' + + +def forwards(apps, schema_editor): + Contest = apps.get_model('judge', 'Contest') + ContestRole = apps.get_model('judge', 'ContestRole') + + contest_roles = [] + for contest in Contest.objects.prefetch_related('authors', 'curators', 'testers').iterator(): + for author in contest.authors.all(): + contest_roles.append(ContestRole(contest=contest, user=author, role=ROLE_AUTHOR)) + for curator in contest.curators.all(): + contest_roles.append(ContestRole(contest=contest, user=curator, role=ROLE_CURATOR)) + for tester in contest.testers.all(): + contest_roles.append(ContestRole(contest=contest, user=tester, role=ROLE_TESTER)) + ContestRole.objects.bulk_create(contest_roles, ignore_conflicts=True) + + +def backwards(apps, schema_editor): + Contest = apps.get_model('judge', 'Contest') + ContestRole = apps.get_model('judge', 'ContestRole') + + for contest in Contest.objects.all().iterator(): + roles = ContestRole.objects.filter(contest=contest) + contest.authors.set(roles.filter(role=ROLE_AUTHOR).values_list('user', flat=True)) + contest.curators.set(roles.filter(role=ROLE_CURATOR).values_list('user', flat=True)) + contest.testers.set(roles.filter(role=ROLE_TESTER).values_list('user', flat=True)) + + +class Migration(migrations.Migration): + dependencies = [ + ('judge', '0219_problemdata_zipfile_size_alter_contest_authors_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ContestRole', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'role', + models.CharField( + choices=[('A', 'Author'), ('C', 'Curator'), ('T', 'Tester')], + max_length=1, + verbose_name='role', + ), + ), + ( + 'contest', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='contest_roles', + to='judge.contest', + verbose_name='contest', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='contest_roles', + to='judge.profile', + verbose_name='user', + ), + ), + ], + options={ + 'verbose_name': 'contest role', + 'verbose_name_plural': 'contest roles', + 'unique_together': {('contest', 'user', 'role')}, + }, + ), + migrations.RunPython(forwards, backwards), + migrations.RemoveField( + model_name='contest', + name='authors', + ), + migrations.RemoveField( + model_name='contest', + name='curators', + ), + migrations.RemoveField( + model_name='contest', + name='testers', + ), + ] diff --git a/judge/migrations/0221_migrate_problem_roles.py b/judge/migrations/0221_migrate_problem_roles.py new file mode 100644 index 000000000..e99fa31cd --- /dev/null +++ b/judge/migrations/0221_migrate_problem_roles.py @@ -0,0 +1,95 @@ +"""Single migration: create ProblemRole, copy problem authors/curators/testers data, remove old M2M fields.""" +import django.db.models.deletion +from django.db import migrations, models + +ROLE_AUTHOR = 'A' +ROLE_CURATOR = 'C' +ROLE_TESTER = 'T' + + +def forwards(apps, schema_editor): + Problem = apps.get_model('judge', 'Problem') + ProblemRole = apps.get_model('judge', 'ProblemRole') + + problem_roles = [] + for problem in Problem.objects.prefetch_related('authors', 'curators', 'testers').iterator(): + for author in problem.authors.all(): + problem_roles.append(ProblemRole(problem=problem, user=author, role=ROLE_AUTHOR)) + for curator in problem.curators.all(): + problem_roles.append(ProblemRole(problem=problem, user=curator, role=ROLE_CURATOR)) + for tester in problem.testers.all(): + problem_roles.append(ProblemRole(problem=problem, user=tester, role=ROLE_TESTER)) + ProblemRole.objects.bulk_create(problem_roles, ignore_conflicts=True) + + +def backwards(apps, schema_editor): + Problem = apps.get_model('judge', 'Problem') + ProblemRole = apps.get_model('judge', 'ProblemRole') + + for problem in Problem.objects.all().iterator(): + roles = ProblemRole.objects.filter(problem=problem) + problem.authors.set(roles.filter(role=ROLE_AUTHOR).values_list('user', flat=True)) + problem.curators.set(roles.filter(role=ROLE_CURATOR).values_list('user', flat=True)) + problem.testers.set(roles.filter(role=ROLE_TESTER).values_list('user', flat=True)) + + +class Migration(migrations.Migration): + dependencies = [ + ('judge', '0220_migrate_contest_roles'), + ] + + operations = [ + migrations.CreateModel( + name='ProblemRole', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'role', + models.CharField( + choices=[('A', 'Author'), ('C', 'Curator'), ('T', 'Tester')], + max_length=1, + verbose_name='role', + ), + ), + ( + 'problem', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='problem_roles', + to='judge.problem', + verbose_name='problem', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='problem_roles', + to='judge.profile', + verbose_name='user', + ), + ), + ], + options={ + 'verbose_name': 'problem role', + 'verbose_name_plural': 'problem roles', + 'unique_together': {('problem', 'user', 'role')}, + }, + ), + migrations.RunPython(forwards, backwards), + migrations.RemoveField( + model_name='problem', + name='authors', + ), + migrations.RemoveField( + model_name='problem', + name='curators', + ), + migrations.RemoveField( + model_name='problem', + name='testers', + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 4d8d93e4b..efe8ad249 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -11,6 +11,7 @@ problem_directory_file from judge.models.profile import Badge, Organization, OrganizationMonthlyUsage, OrganizationRequest, \ Profile, WebAuthnCredential +from judge.models.role import ContestRole, ProblemRole, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER from judge.models.runtime import Judge, Language, RuntimeVersion from judge.models.submission import SUBMISSION_RESULT, Submission, SubmissionSource, SubmissionTestCase from judge.models.tag import Tag, TagData, TagGroup, TagProblem @@ -21,6 +22,8 @@ revisions.register(LanguageLimit) revisions.register(Contest, follow=['contest_problems']) revisions.register(ContestProblem) +revisions.register(ContestRole) +revisions.register(ProblemRole) revisions.register(Organization) revisions.register(BlogPost) revisions.register(Solution) diff --git a/judge/models/contest.py b/judge/models/contest.py index 6f6ac1693..9d5a026e0 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -18,12 +18,13 @@ from judge import contest_format, event_poster as event from judge.models.problem import Problem from judge.models.profile import Organization, Profile +from judge.models.role import ContestRole, RoleQuerySetAdapter, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER from judge.models.submission import Submission from judge.ratings import rate_contest from judge.utils.unicode import utf8bytes __all__ = ['Contest', 'ContestTag', 'ContestAnnouncement', 'ContestParticipation', 'ContestProblem', - 'ContestSubmission', 'Rating'] + 'ContestSubmission', 'Rating', 'ContestRole'] class MinValueOrNoneValidator(MinValueValidator): @@ -74,14 +75,6 @@ class Contest(models.Model): key = models.CharField(max_length=32, 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) - 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) @@ -318,6 +311,23 @@ def show_scoreboard(self): return False return True + def _role_users(self, role): + return RoleQuerySetAdapter(Profile.objects.filter( + contest_roles__contest=self, contest_roles__role=role, + )) + + @property + def authors(self): + return self._role_users(ROLE_AUTHOR) + + @property + def curators(self): + return self._role_users(ROLE_CURATOR) + + @property + def testers(self): + return self._role_users(ROLE_TESTER) + @property def contest_window_length(self): return self.end_time - self.start_time @@ -375,18 +385,22 @@ def time_before_end(self): def ended(self): return self.end_time < self._now + def _role_ids(self, roles): + return ContestRole.objects.filter( + contest=self, role__in=roles, + ).values_list('user_id', flat=True) + @cached_property def author_ids(self): - return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True) + return self._role_ids([ROLE_AUTHOR]) @cached_property def editor_ids(self): - return self.author_ids.union( - Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True)) + return self._role_ids([ROLE_AUTHOR, ROLE_CURATOR]) @cached_property def tester_ids(self): - return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True) + return self._role_ids([ROLE_TESTER]) @classmethod def get_id_secret(cls, contest_id): @@ -530,31 +544,9 @@ def get_visible_contests(cls, user): ) ) - authors_exists = Contest.authors.through.objects.filter( - contest_id=OuterRef('pk'), - profile_id=user.profile.id, - ) - curators_exists = Contest.curators.through.objects.filter( - contest_id=OuterRef('pk'), - profile_id=user.profile.id, - ) - testers_exists = Contest.testers.through.objects.filter( - contest_id=OuterRef('pk'), - profile_id=user.profile.id, - ) - queryset = queryset.annotate( - has_author=Exists(authors_exists), - has_curator=Exists(curators_exists), - has_tester=Exists(testers_exists), - ) - - queryset = queryset.filter( - q | - Q(has_author=True) | - Q(has_curator=True) | - Q(has_tester=True), - ) + has_role=ContestRole.exists_for(user.profile), + ).filter(q | Q(has_role=True)) return queryset diff --git a/judge/models/problem.py b/judge/models/problem.py index b872a696b..dc8af704c 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -8,7 +8,7 @@ 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, Exists, F, FilteredRelation, OuterRef, Q, SET_NULL +from django.db.models import CASCADE, F, FilteredRelation, Q, SET_NULL from django.db.models.functions import Coalesce from django.urls import reverse from django.utils import timezone @@ -18,6 +18,7 @@ from judge.fulltext import SearchQuerySet from judge.models.problem_data import problem_data_storage from judge.models.profile import Organization, Profile +from judge.models.role import ProblemRole, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER, RoleQuerySetAdapter from judge.models.runtime import Language from judge.user_translations import gettext as user_gettext from judge.utils.url import get_absolute_pdf_url @@ -151,15 +152,6 @@ class Problem(models.Model): 'if it is not yours')) description = models.TextField(verbose_name=_('problem body'), blank=True, validators=[disallowed_characters_validator]) - authors = models.ManyToManyField(Profile, verbose_name=_('creators'), blank=True, related_name='authored_problems', - help_text=_('These users will be able to edit the problem, ' - 'and be listed as authors.')) - curators = models.ManyToManyField(Profile, verbose_name=_('curators'), blank=True, related_name='curated_problems', - help_text=_('These users will be able to edit the problem, ' - 'but not be listed as authors.')) - testers = models.ManyToManyField(Profile, verbose_name=_('testers'), blank=True, related_name='tested_problems', - help_text=_( - 'These users will be able to view the private problem, but not edit it.')) types = models.ManyToManyField(ProblemType, verbose_name=_('problem types'), help_text=_("The type of problem, as shown on the problem's page.")) group = models.ForeignKey(ProblemGroup, verbose_name=_('problem group'), on_delete=CASCADE, @@ -238,6 +230,23 @@ def __init__(self, *args, **kwargs): if 'points' in self.__dict__: self.__original_points = self.points + def _role_users(self, role): + return RoleQuerySetAdapter(Profile.objects.filter( + problem_roles__problem=self, problem_roles__role=role, + )) + + @property + def authors(self): + return self._role_users(ROLE_AUTHOR) + + @property + def curators(self): + return self._role_users(ROLE_CURATOR) + + @property + def testers(self): + return self._role_users(ROLE_TESTER) + @property def absolute_pdf_url(self): return get_absolute_pdf_url(self.pdf_url) if self.pdf_url else None @@ -250,7 +259,14 @@ def languages_list(self): return self.allowed_languages.values_list('common_name', flat=True).distinct().order_by('common_name') def is_editor(self, profile): - return (self.authors.filter(id=profile.id) | self.curators.filter(id=profile.id)).exists() + return ProblemRole.objects.filter( + problem=self, + user=profile, + ).filter(role=ROLE_AUTHOR).exists() or ProblemRole.objects.filter( + problem=self, + user=profile, + role=ROLE_CURATOR, + ).exists() @property def is_suggesting(self): @@ -315,7 +331,7 @@ def is_accessible_by(self, user, skip_contest_problem_check=False): return True # If user is a tester. - if self.testers.filter(id=user.profile.id).exists(): + if ProblemRole.objects.filter(problem=self, user=user.profile, role=ROLE_TESTER).exists(): return True return False @@ -386,11 +402,7 @@ def get_visible_problems(cls, user): @classmethod def q_add_author_curator_tester(cls, q, profile): - # This is way faster than the obvious |= Q(authors=profile) et al. because we are not doing - # joins and forcing the user to clean it up with .distinct(). - q |= Exists(Problem.authors.through.objects.filter(problem=OuterRef('pk'), profile=profile)) - q |= Exists(Problem.curators.through.objects.filter(problem=OuterRef('pk'), profile=profile)) - q |= Exists(Problem.testers.through.objects.filter(problem=OuterRef('pk'), profile=profile)) + q |= ProblemRole.exists_for(profile) return q @classmethod @@ -410,7 +422,10 @@ def get_editable_problems(cls, user): if user.has_perm('judge.edit_all_problem'): return cls.objects.all() - q = Q(authors=user.profile) | Q(curators=user.profile) | Q(suggester=user.profile) + q = ( + ProblemRole.exists_for(user.profile, roles=[ROLE_AUTHOR, ROLE_CURATOR]) | + Q(suggester=user.profile) + ) if user.has_perm('judge.edit_public_problem'): q |= Q(is_public=True) @@ -427,12 +442,21 @@ def get_absolute_url(self): @cached_property def author_ids(self): - return Problem.authors.through.objects.filter(problem=self).values_list('profile_id', flat=True) + return ProblemRole.objects.filter( + problem=self, role=ROLE_AUTHOR, + ).values_list('user_id', flat=True) @cached_property def editor_ids(self): - editors = self.author_ids.union( - Problem.curators.through.objects.filter(problem=self).values_list('profile_id', flat=True)) + editors = ProblemRole.objects.filter( + problem=self, + role=ROLE_AUTHOR, + ).values_list('user_id', flat=True).union( + ProblemRole.objects.filter( + problem=self, + role=ROLE_CURATOR, + ).values_list('user_id', flat=True), + ) if self.suggester is not None: editors = list(editors) editors.append(self.suggester.id) @@ -440,7 +464,9 @@ def editor_ids(self): @cached_property def tester_ids(self): - return Problem.testers.through.objects.filter(problem=self).values_list('profile_id', flat=True) + return ProblemRole.objects.filter( + problem=self, role=ROLE_TESTER, + ).values_list('user_id', flat=True) @cached_property def usable_common_names(self): diff --git a/judge/models/role.py b/judge/models/role.py new file mode 100644 index 000000000..ff46fd1a8 --- /dev/null +++ b/judge/models/role.py @@ -0,0 +1,97 @@ +from django.db import models +from django.db.models import CASCADE, Exists, OuterRef +from django.utils.translation import gettext_lazy as _ + +from judge.models.profile import Profile + +ROLE_AUTHOR = 'A' +ROLE_CURATOR = 'C' +ROLE_TESTER = 'T' + +ROLE_CHOICES = ( + (ROLE_AUTHOR, _('Author')), + (ROLE_CURATOR, _('Curator')), + (ROLE_TESTER, _('Tester')), +) + + +class RoleQuerySetAdapter: + def __init__(self, queryset): + self.queryset = queryset + + def all(self): + return self.queryset.all() + + def filter(self, *args, **kwargs): + return self.queryset.filter(*args, **kwargs) + + def exists(self): + return self.queryset.exists() + + def first(self): + return self.queryset.first() + + def values_list(self, *args, **kwargs): + return self.queryset.values_list(*args, **kwargs) + + def __iter__(self): + return iter(self.queryset) + + def __contains__(self, item): + return self.queryset.filter(pk=getattr(item, 'pk', item)).exists() + + +class ContestRole(models.Model): + contest = models.ForeignKey( + 'Contest', verbose_name=_('contest'), related_name='contest_roles', on_delete=CASCADE, + ) + user = models.ForeignKey( + Profile, verbose_name=_('user'), related_name='contest_roles', on_delete=CASCADE, + ) + role = models.CharField(max_length=1, choices=ROLE_CHOICES, verbose_name=_('role')) + + class Meta: + unique_together = ('contest', 'user', 'role') + verbose_name = _('contest role') + verbose_name_plural = _('contest roles') + + def __str__(self): + return f'{self.user} - {self.get_role_display()} of {self.contest}' + + @staticmethod + def exists_for(user, role=None, roles=None): + filters = {'contest_id': OuterRef('pk'), 'user': user} + if role: + filters['role'] = role + qs = ContestRole.objects.filter(**filters) + if roles: + qs = qs.filter(role__in=roles) + return Exists(qs) + + +class ProblemRole(models.Model): + problem = models.ForeignKey( + 'Problem', verbose_name=_('problem'), related_name='problem_roles', on_delete=CASCADE, + ) + user = models.ForeignKey( + Profile, verbose_name=_('user'), related_name='problem_roles', on_delete=CASCADE, + ) + role = models.CharField(max_length=1, choices=ROLE_CHOICES, verbose_name=_('role')) + + class Meta: + unique_together = ('problem', 'user', 'role') + verbose_name = _('problem role') + verbose_name_plural = _('problem roles') + + def __str__(self): + return f'{self.user} - {self.get_role_display()} of {self.problem}' + + @staticmethod + def exists_for(user, role=None, roles=None): + filters = {'problem_id': OuterRef('pk'), 'user': user} + if role: + filters['role'] = role + qs = ProblemRole.objects.filter(**filters) + if roles: + qs = qs.filter(role__in=roles) + return Exists(qs) diff --git a/judge/models/tests/test_contest.py b/judge/models/tests/test_contest.py index 099d0d194..cb40992c8 100644 --- a/judge/models/tests/test_contest.py +++ b/judge/models/tests/test_contest.py @@ -737,7 +737,9 @@ def test_contests_list(self): with self.subTest(user=name): # We only care about consistency between Contest.is_accessible_by and Contest.get_visible_contests contest_keys = [] - for contest in Contest.objects.prefetch_related('testers', 'private_contestants', 'organization'): + for contest in Contest.objects.prefetch_related( + 'contest_roles', 'contest_roles__user', 'private_contestants', 'organization', + ): if contest.is_accessible_by(user): contest_keys.append(contest.key) diff --git a/judge/models/tests/test_problem.py b/judge/models/tests/test_problem.py index 46482a1e9..25a194b1e 100644 --- a/judge/models/tests/test_problem.py +++ b/judge/models/tests/test_problem.py @@ -426,7 +426,9 @@ def test_problems_list(self): with self.subTest(list='accessible problems'): # We only care about consistency between Problem.is_accessible_by and Problem.get_visible_problems problem_codes = [] - for problem in Problem.objects.prefetch_related('authors', 'curators', 'testers', 'organization'): + for problem in Problem.objects.prefetch_related( + 'problem_roles', 'problem_roles__user', 'organization', + ): if problem.is_accessible_by(user): problem_codes.append(problem.code) @@ -438,7 +440,7 @@ def test_problems_list(self): with self.subTest(list='editable problems'): # We only care about consistency between Problem.is_editable_by and Problem.get_editable_problems problem_codes = [] - for problem in Problem.objects.prefetch_related('authors', 'curators'): + for problem in Problem.objects.prefetch_related('problem_roles', 'problem_roles__user'): if problem.is_editable_by(user): problem_codes.append(problem.code) diff --git a/judge/models/tests/test_role.py b/judge/models/tests/test_role.py new file mode 100644 index 000000000..4c8dfa332 --- /dev/null +++ b/judge/models/tests/test_role.py @@ -0,0 +1,70 @@ +from django.db import IntegrityError +from django.db.models import Q +from django.test import TestCase + +from judge.models.role import ContestRole, ProblemRole, ROLE_AUTHOR, ROLE_CURATOR +from judge.models.tests.util import CommonDataMixin, create_contest, create_problem + + +class ContestRoleTestCase(CommonDataMixin, TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.contest = create_contest( + key='role_contest', + authors=('normal',), + curators=('staff_problem_edit_own',), + testers=('staff_problem_see_all',), + ) + + def test_role_properties(self): + self.assertIn(self.users['normal'].profile, self.contest.authors) + self.assertIn(self.users['staff_problem_edit_own'].profile, self.contest.curators) + self.assertIn(self.users['staff_problem_see_all'].profile, self.contest.testers) + + def test_role_id_sets(self): + self.assertCountEqual(self.contest.author_ids, [self.users['normal'].profile.id]) + self.assertCountEqual( + self.contest.editor_ids, + [self.users['normal'].profile.id, self.users['staff_problem_edit_own'].profile.id], + ) + self.assertCountEqual(self.contest.tester_ids, [self.users['staff_problem_see_all'].profile.id]) + + def test_unique_together(self): + with self.assertRaises(IntegrityError): + ContestRole.objects.create( + contest=self.contest, user=self.users['normal'].profile, role=ROLE_AUTHOR, + ) + + +class ProblemRoleTestCase(CommonDataMixin, TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.problem = create_problem( + code='role_problem', + authors=('normal',), + curators=('staff_problem_edit_own',), + testers=('staff_problem_see_all',), + ) + + def test_role_properties(self): + self.assertIn(self.users['normal'].profile, self.problem.authors) + self.assertIn(self.users['staff_problem_edit_own'].profile, self.problem.curators) + self.assertIn(self.users['staff_problem_see_all'].profile, self.problem.testers) + + def test_editor_helpers(self): + self.assertTrue(self.problem.is_editor(self.users['normal'].profile)) + self.assertTrue(self.problem.is_editor(self.users['staff_problem_edit_own'].profile)) + self.assertFalse(self.problem.is_editor(self.users['staff_problem_see_all'].profile)) + + def test_unique_together(self): + with self.assertRaises(IntegrityError): + ProblemRole.objects.create( + problem=self.problem, user=self.users['normal'].profile, role=ROLE_AUTHOR, + ) + + def test_tester_role_is_not_editor(self): + self.assertFalse(ProblemRole.objects.filter( + problem=self.problem, user=self.users['staff_problem_see_all'].profile, + ).filter(Q(role=ROLE_AUTHOR) | Q(role=ROLE_CURATOR)).exists()) diff --git a/judge/models/tests/util.py b/judge/models/tests/util.py index 0792b11fa..3f95cb294 100644 --- a/judge/models/tests/util.py +++ b/judge/models/tests/util.py @@ -3,6 +3,7 @@ from judge.models import BlogPost, Contest, ContestParticipation, ContestProblem, ContestTag, Language, Organization, \ Problem, ProblemGroup, ProblemType, Profile, Solution +from judge.models.role import ContestRole, ProblemRole, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER class CreateModel: @@ -134,9 +135,6 @@ def get_defaults(self, required_kwargs, kwargs): class CreateProblem(CreateModel): model = Problem m2m_fields = { - 'authors': (Profile, 'user__username'), - 'curators': (Profile, 'user__username'), - 'testers': (Profile, 'user__username'), 'types': (ProblemType, 'name'), 'allowed_languages': (Language, 'key'), 'banned_users': (Profile, 'user__username'), @@ -157,6 +155,25 @@ def process_related_objects(self, required_kwargs, defaults): if not isinstance(defaults['group'], ProblemGroup): defaults['group'] = create_problem_group(defaults['group']) + def __call__(self, *args, **kwargs): + authors = kwargs.pop('authors', ()) + curators = kwargs.pop('curators', ()) + testers = kwargs.pop('testers', ()) + obj = super().__call__(*args, **kwargs) + for username in authors: + ProblemRole.objects.get_or_create( + problem=obj, user=Profile.objects.get(user__username=username), role=ROLE_AUTHOR, + ) + for username in curators: + ProblemRole.objects.get_or_create( + problem=obj, user=Profile.objects.get(user__username=username), role=ROLE_CURATOR, + ) + for username in testers: + ProblemRole.objects.get_or_create( + problem=obj, user=Profile.objects.get(user__username=username), role=ROLE_TESTER, + ) + return obj + create_problem = CreateProblem() @@ -187,9 +204,6 @@ def process_related_objects(self, required_kwargs, defaults): class CreateContest(CreateModel): model = Contest m2m_fields = { - 'authors': (Profile, 'user__username'), - 'curators': (Profile, 'user__username'), - 'testers': (Profile, 'user__username'), 'problems': (Problem, 'code'), 'view_contest_scoreboard': (Profile, 'user__username'), 'rate_exclude': (Profile, 'user__username'), @@ -209,6 +223,31 @@ def get_defaults(self, required_kwargs, kwargs): 'show_submission_list': False, } + def __call__(self, *args, **kwargs): + authors = kwargs.pop('authors', ()) + curators = kwargs.pop('curators', ()) + testers = kwargs.pop('testers', ()) + obj = super().__call__(*args, **kwargs) + for username in authors: + ContestRole.objects.get_or_create( + contest=obj, + user=Profile.objects.get(user__username=username), + role=ROLE_AUTHOR, + ) + for username in curators: + ContestRole.objects.get_or_create( + contest=obj, + user=Profile.objects.get(user__username=username), + role=ROLE_CURATOR, + ) + for username in testers: + ContestRole.objects.get_or_create( + contest=obj, + user=Profile.objects.get(user__username=username), + role=ROLE_TESTER, + ) + return obj + create_contest = CreateContest() diff --git a/judge/utils/codeforces_polygon.py b/judge/utils/codeforces_polygon.py index 85ce9ea3c..0d639df81 100644 --- a/judge/utils/codeforces_polygon.py +++ b/judge/utils/codeforces_polygon.py @@ -19,6 +19,7 @@ from judge.models import Language, Problem, ProblemData, ProblemGroup, ProblemTestCase, ProblemTranslation, \ ProblemType, Profile, Solution +from judge.models.role import ProblemRole, ROLE_AUTHOR, ROLE_CURATOR from judge.utils.problem_data import ProblemDataCompiler from judge.views.widgets import django_uploader @@ -812,8 +813,10 @@ def update_or_create_problem(self): }) problem.save() problem.allowed_languages.set(Language.objects.filter(include_in_problem=True)) - problem.authors.set(self.meta['authors']) - problem.curators.set(self.meta['curators']) + for author in self.meta['authors']: + ProblemRole.objects.get_or_create(problem=problem, user=author, role=ROLE_AUTHOR) + for curator in self.meta['curators']: + ProblemRole.objects.get_or_create(problem=problem, user=curator, role=ROLE_CURATOR) problem.types.set([ProblemType.objects.order_by('id').first()]) # Uncategorized problem.save() diff --git a/judge/utils/problems.py b/judge/utils/problems.py index fd09f5e87..9ca69dddd 100644 --- a/judge/utils/problems.py +++ b/judge/utils/problems.py @@ -8,12 +8,16 @@ from django.utils.translation import gettext_noop from judge.models import Problem, Submission +from judge.models.role import ProblemRole, ROLE_TESTER __all__ = ['contest_completed_ids', 'get_result_data', 'user_completed_ids', 'user_editable_ids', 'user_tester_ids'] def user_tester_ids(profile): - return set(Problem.testers.through.objects.filter(profile=profile).values_list('problem_id', flat=True)) + return set(ProblemRole.objects.filter( + user=profile, + role=ROLE_TESTER, + ).values_list('problem_id', flat=True)) def user_editable_ids(profile): diff --git a/judge/views/api/api_v2.py b/judge/views/api/api_v2.py index 8b71fbb79..5c03471a6 100644 --- a/judge/views/api/api_v2.py +++ b/judge/views/api/api_v2.py @@ -14,6 +14,7 @@ Contest, ContestParticipation, ContestTag, Judge, Language, Organization, Problem, ProblemType, Profile, Rating, Submission, ) +from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR from judge.utils.infinite_paginator import InfinitePaginationMixin from judge.utils.raw_sql import join_sql_subquery, use_straight_join from judge.views.submission import group_test_cases @@ -371,8 +372,8 @@ def get_unfiltered_queryset(self): q = Q(end_time__lt=self._now) if self.request.user.is_authenticated: if self.request.user.has_perm('judge.edit_own_contest'): - q |= Q(authors=self.request.profile) - q |= Q(curators=self.request.profile) + q |= ContestRole.exists_for( + self.request.profile, roles=[ROLE_AUTHOR, ROLE_CURATOR]) q |= Q(view_contest_scoreboard=self.request.profile) visible_contests = visible_contests.filter(q) diff --git a/judge/views/contests.py b/judge/views/contests.py index 1b92563d2..c001f29ec 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -41,6 +41,7 @@ ProposeContestProblemFormSet from judge.models import Contest, ContestAnnouncement, ContestMoss, ContestParticipation, ContestProblem, ContestTag, \ Language, Organization, Problem, ProblemClarification, Profile, Submission +from judge.models.role import ContestRole, ROLE_AUTHOR from judge.tasks import on_new_contest, prepare_contest_data, rescore_problem, run_moss from judge.utils.celery import redirect_to_task_status, task_status_by_id, task_status_url_by_id from judge.utils.cms import parse_csv_ranking @@ -104,7 +105,7 @@ def _now(self): return timezone.now() def _get_queryset(self): - return super().get_queryset().prefetch_related('tags', 'organization', 'authors', 'curators', 'testers') + return super().get_queryset().prefetch_related('tags', 'organization', 'contest_roles', 'contest_roles__user') def get_queryset(self): self.search_query = None @@ -133,7 +134,7 @@ def get_context_data(self, **kwargs): for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile, contest_id__in=present) \ .select_related('contest') \ - .prefetch_related('contest__authors', 'contest__curators', 'contest__testers') \ + .prefetch_related('contest__contest_roles', 'contest__contest_roles__user') \ .annotate(key=F('contest__key')): if participation.ended: finished.add(participation.contest.key) @@ -405,7 +406,7 @@ def form_valid(self, form): contest.organization = organization contest.private_contestants.set(private_contestants) contest.view_contest_scoreboard.set(view_contest_scoreboard) - contest.authors.add(self.request.profile) + ContestRole.objects.get_or_create(contest=contest, user=self.request.profile, role=ROLE_AUTHOR) for problem in contest_problems: problem.contest = contest @@ -1258,7 +1259,7 @@ def get_context_data(self, **kwargs): def save_contest_form(self, form): self.object = form.save() - self.object.authors.add(self.request.profile) + ContestRole.objects.get_or_create(contest=self.object, user=self.request.profile, role=ROLE_AUTHOR) self.object.save() def post(self, request, *args, **kwargs): diff --git a/judge/views/organization.py b/judge/views/organization.py index f38558c57..610738b97 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -23,6 +23,7 @@ from judge.models import BlogPost, Comment, Contest, Language, Organization, OrganizationRequest, \ Problem, ProblemData, Profile from judge.models.profile import OrganizationMonthlyUsage +from judge.models.role import ContestRole, ProblemRole, ROLE_AUTHOR from judge.tasks import on_new_problem from judge.utils import cache_helper from judge.utils.infinite_paginator import InfinitePaginationMixin @@ -581,9 +582,7 @@ def get_filter(self): # Authors, curators, and testers should always have access, so OR at the very end. if self.profile is not None: - _filter |= Q(authors=self.profile) - _filter |= Q(curators=self.profile) - _filter |= Q(testers=self.profile) + _filter |= ProblemRole.exists_for(self.profile) return _filter & Q(organization=self.organization) @@ -687,7 +686,7 @@ def get_form_kwargs(self): def form_valid(self, form): with revisions.create_revision(atomic=True): self.object = problem = form.save() - problem.authors.add(self.request.user.profile) + ProblemRole.objects.get_or_create(problem=problem, user=self.request.user.profile, role=ROLE_AUTHOR) problem.allowed_languages.set(Language.objects.filter(include_in_problem=True)) problem.is_organization_private = True @@ -742,7 +741,7 @@ def get_form_kwargs(self): def save_contest_form(self, form): self.object = form.save() - self.object.authors.add(self.request.profile) + ContestRole.objects.get_or_create(contest=self.object, user=self.request.profile, role=ROLE_AUTHOR) self.object.is_organization_private = True self.object.organization = self.organization self.object.save() diff --git a/judge/views/problem.py b/judge/views/problem.py index 0558a4f7e..4cd502e29 100755 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -31,6 +31,7 @@ ProblemImportPolygonForm, ProblemImportPolygonStatementFormSet, ProblemSubmitForm, ProposeProblemSolutionFormSet from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, \ ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource +from judge.models.role import ProblemRole, ROLE_CURATOR from judge.tasks import on_new_problem from judge.template_context import misc_config from judge.utils.codeforces_polygon import ImportPolygonError, PolygonImporter @@ -861,7 +862,7 @@ def form_valid(self, form): problem.date = timezone.now() with revisions.create_revision(atomic=True): problem.save(is_clone=True) - problem.curators.add(self.request.profile) + ProblemRole.objects.get_or_create(problem=problem, user=self.request.profile, role=ROLE_CURATOR) problem.allowed_languages.set(languages) problem.language_limits.set(language_limits) problem.organization = organization @@ -903,7 +904,7 @@ def save_statement(self, form, problem): def form_valid(self, form): with revisions.create_revision(atomic=True): self.object = problem = form.save() - problem.curators.add(self.request.user.profile) + ProblemRole.objects.get_or_create(problem=problem, user=self.request.user.profile, role=ROLE_CURATOR) problem.allowed_languages.set(Language.objects.filter(include_in_problem=True)) problem.date = timezone.now() self.save_statement(form, problem) diff --git a/judge/views/submission.py b/judge/views/submission.py index 499b04e1e..10a784b7c 100644 --- a/judge/views/submission.py +++ b/judge/views/submission.py @@ -26,6 +26,7 @@ from judge.highlight_code import highlight_code from judge.models import Contest, Language, Organization, Problem, ProblemTranslation, Profile, Submission from judge.models.problem import ProblemTestcaseResultAccess, SubmissionSourceAccess +from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR from judge.utils.infinite_paginator import InfinitePaginationMixin from judge.utils.lazy import memo_lazy from judge.utils.problem_data import get_problem_testcases_data @@ -41,7 +42,7 @@ def submission_related(queryset): 'memory', 'points', 'result', 'status', 'case_points', 'case_total', 'current_testcase', 'contest_object', 'locked_after', 'problem__submission_source_visibility_mode', 'problem__testcase_result_visibility_mode', 'user__username_display_override', 'user__display_badge__name', 'user__display_badge__mini') \ - .prefetch_related('contest_object__authors', 'contest_object__curators') + .prefetch_related('contest_object__contest_roles', 'contest_object__contest_roles__user') class SubmissionPermissionDenied(PermissionDenied): @@ -334,10 +335,11 @@ def abort_submission(request, submission): def filter_submissions_by_visible_problems(queryset, user): + subquery, params = Problem.get_visible_problems(user).only('id').query.sql_with_params() join_sql_subquery( queryset, - subquery=str(Problem.get_visible_problems(user).only('id').query), - params=[], + subquery=subquery, + params=list(params), join_fields=[('problem_id', 'id')], alias='visible_problems', related_model=Problem, @@ -400,10 +402,14 @@ def _get_queryset(self): if not self.request.user.has_perm('judge.see_private_contest'): # 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() + contest_queryset = Contest.objects.annotate( + has_editor=ContestRole.exists_for( + self.request.profile, roles=[ROLE_AUTHOR, ROLE_CURATOR]), + ).filter( + Q(has_editor=True) | + 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))