Skip to content

Commit d4d6086

Browse files
committed
refactor: migrate contest authors/curators/testers to ContestRole
1 parent 7003304 commit d4d6086

File tree

13 files changed

+348
-54
lines changed

13 files changed

+348
-54
lines changed

judge/admin/contest.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from reversion.admin import VersionAdmin
1616

1717
from judge.models import Contest, ContestAnnouncement, ContestProblem, ContestSubmission, Profile, Rating, Submission
18+
from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR
1819
from judge.ratings import rate_contest
1920
from judge.utils.views import NoBatchDeleteMixin
2021
from judge.widgets import AdminAceWidget, AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, \
@@ -124,9 +125,6 @@ def clean(self):
124125

125126
class Meta:
126127
widgets = {
127-
'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
128-
'curators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
129-
'testers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
130128
'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
131129
'organization': AdminHeavySelect2Widget(data_view='organization_select2'),
132130
'tags': AdminSelect2MultipleWidget,
@@ -137,9 +135,23 @@ class Meta:
137135
}
138136

139137

138+
class ContestRoleInlineForm(ModelForm):
139+
class Meta:
140+
model = ContestRole
141+
fields = ('user', 'role')
142+
widgets = {'user': AdminHeavySelect2Widget(data_view='profile_select2')}
143+
144+
145+
class ContestRoleInline(admin.TabularInline):
146+
model = ContestRole
147+
fields = ('user', 'role')
148+
extra = 0
149+
form = ContestRoleInlineForm
150+
151+
140152
class ContestAdmin(NoBatchDeleteMixin, SortableAdminBase, VersionAdmin):
141153
fieldsets = (
142-
(None, {'fields': ('key', 'name', 'authors', 'curators', 'testers')}),
154+
(None, {'fields': ('key', 'name')}),
143155
(_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'push_announcements', 'disallow_virtual',
144156
'hide_problem_tags', 'hide_problem_authors', 'show_short_display',
145157
'run_pretests_only', 'locked_after', 'scoreboard_visibility',
@@ -159,7 +171,7 @@ class ContestAdmin(NoBatchDeleteMixin, SortableAdminBase, VersionAdmin):
159171
list_display = ('key', 'name', 'is_visible', 'is_rated', 'locked_after', 'start_time', 'end_time', 'time_limit',
160172
'user_count')
161173
search_fields = ('key', 'name')
162-
inlines = [ContestProblemInline, ContestAnnouncementInline]
174+
inlines = [ContestRoleInline, ContestProblemInline, ContestAnnouncementInline]
163175
actions_on_top = True
164176
actions_on_bottom = True
165177
form = ContestForm
@@ -186,7 +198,11 @@ def get_queryset(self, request):
186198
if request.user.has_perm('judge.edit_all_contest'):
187199
return queryset
188200
else:
189-
return queryset.filter(Q(authors=request.profile) | Q(curators=request.profile)).distinct()
201+
return queryset.filter(
202+
contest_roles__user=request.profile,
203+
).filter(
204+
Q(contest_roles__role=ROLE_AUTHOR) | Q(contest_roles__role=ROLE_CURATOR),
205+
).distinct()
190206

191207
def get_readonly_fields(self, request, obj=None):
192208
readonly = []
@@ -358,18 +374,9 @@ def rate_view(self, request, id):
358374
def get_form(self, request, obj=None, **kwargs):
359375
form = super(ContestAdmin, self).get_form(request, obj, **kwargs)
360376
if 'problem_label_script' in form.base_fields:
361-
# form.base_fields['problem_label_script'] does not exist when the user has only view permission
362-
# on the model.
363377
form.base_fields['problem_label_script'].widget = AdminAceWidget(
364378
mode='lua', theme=request.profile.resolved_ace_theme,
365379
)
366-
367-
perms = ('edit_own_contest', 'edit_all_contest')
368-
form.base_fields['curators'].queryset = Profile.objects.filter(
369-
Q(user__is_superuser=True) |
370-
Q(user__groups__permissions__codename__in=perms) |
371-
Q(user__user_permissions__codename__in=perms),
372-
).distinct()
373380
return form
374381

375382

judge/jinja2/submission.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
from operator import attrgetter
1+
from django.db.models import Q
22

33
from judge.models import SubmissionSourceAccess
4+
from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR
45
from . import registry
56

67

78
# TODO: maybe refactor this?
89
def get_editor_ids(contest):
9-
return set(map(attrgetter('id'), contest.authors.all())) | set(map(attrgetter('id'), contest.curators.all()))
10+
return set(ContestRole.objects.filter(
11+
contest=contest,
12+
).filter(
13+
Q(role=ROLE_AUTHOR) | Q(role=ROLE_CURATOR),
14+
).values_list('user_id', flat=True))
1015

1116

1217
@registry.function
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Single migration: create ContestRole, copy contest authors/curators/testers data, remove old M2M fields."""
2+
import django.db.models.deletion
3+
from django.db import migrations, models
4+
5+
ROLE_AUTHOR = 'A'
6+
ROLE_CURATOR = 'C'
7+
ROLE_TESTER = 'T'
8+
9+
10+
def forwards(apps, schema_editor):
11+
Contest = apps.get_model('judge', 'Contest')
12+
ContestRole = apps.get_model('judge', 'ContestRole')
13+
14+
contest_roles = []
15+
for contest in Contest.objects.prefetch_related('authors', 'curators', 'testers').iterator():
16+
for author in contest.authors.all():
17+
contest_roles.append(ContestRole(contest=contest, user=author, role=ROLE_AUTHOR))
18+
for curator in contest.curators.all():
19+
contest_roles.append(ContestRole(contest=contest, user=curator, role=ROLE_CURATOR))
20+
for tester in contest.testers.all():
21+
contest_roles.append(ContestRole(contest=contest, user=tester, role=ROLE_TESTER))
22+
ContestRole.objects.bulk_create(contest_roles, ignore_conflicts=True)
23+
24+
25+
def backwards(apps, schema_editor):
26+
Contest = apps.get_model('judge', 'Contest')
27+
ContestRole = apps.get_model('judge', 'ContestRole')
28+
29+
for contest in Contest.objects.all().iterator():
30+
roles = ContestRole.objects.filter(contest=contest)
31+
contest.authors.set(roles.filter(role=ROLE_AUTHOR).values_list('user', flat=True))
32+
contest.curators.set(roles.filter(role=ROLE_CURATOR).values_list('user', flat=True))
33+
contest.testers.set(roles.filter(role=ROLE_TESTER).values_list('user', flat=True))
34+
35+
36+
class Migration(migrations.Migration):
37+
dependencies = [
38+
('judge', '0219_problemdata_zipfile_size_alter_contest_authors_and_more'),
39+
]
40+
41+
operations = [
42+
migrations.CreateModel(
43+
name='ContestRole',
44+
fields=[
45+
(
46+
'id',
47+
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
48+
),
49+
(
50+
'role',
51+
models.CharField(
52+
choices=[('A', 'Author'), ('C', 'Curator'), ('T', 'Tester')],
53+
max_length=1,
54+
verbose_name='role',
55+
),
56+
),
57+
(
58+
'contest',
59+
models.ForeignKey(
60+
on_delete=django.db.models.deletion.CASCADE,
61+
related_name='contest_roles',
62+
to='judge.contest',
63+
verbose_name='contest',
64+
),
65+
),
66+
(
67+
'user',
68+
models.ForeignKey(
69+
on_delete=django.db.models.deletion.CASCADE,
70+
related_name='contest_roles',
71+
to='judge.profile',
72+
verbose_name='user',
73+
),
74+
),
75+
],
76+
options={
77+
'verbose_name': 'contest role',
78+
'verbose_name_plural': 'contest roles',
79+
'unique_together': {('contest', 'user', 'role')},
80+
},
81+
),
82+
migrations.RunPython(forwards, backwards),
83+
migrations.RemoveField(
84+
model_name='contest',
85+
name='authors',
86+
),
87+
migrations.RemoveField(
88+
model_name='contest',
89+
name='curators',
90+
),
91+
migrations.RemoveField(
92+
model_name='contest',
93+
name='testers',
94+
),
95+
]

judge/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
problem_directory_file
1212
from judge.models.profile import Badge, Organization, OrganizationMonthlyUsage, OrganizationRequest, \
1313
Profile, WebAuthnCredential
14+
from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER
1415
from judge.models.runtime import Judge, Language, RuntimeVersion
1516
from judge.models.submission import SUBMISSION_RESULT, Submission, SubmissionSource, SubmissionTestCase
1617
from judge.models.tag import Tag, TagData, TagGroup, TagProblem
@@ -21,6 +22,7 @@
2122
revisions.register(LanguageLimit)
2223
revisions.register(Contest, follow=['contest_problems'])
2324
revisions.register(ContestProblem)
25+
revisions.register(ContestRole)
2426
revisions.register(Organization)
2527
revisions.register(BlogPost)
2628
revisions.register(Solution)

judge/models/contest.py

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
from judge import contest_format, event_poster as event
1919
from judge.models.problem import Problem
2020
from judge.models.profile import Organization, Profile
21+
from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR, ROLE_TESTER
2122
from judge.models.submission import Submission
2223
from judge.ratings import rate_contest
2324
from judge.utils.unicode import utf8bytes
2425

2526
__all__ = ['Contest', 'ContestTag', 'ContestAnnouncement', 'ContestParticipation', 'ContestProblem',
26-
'ContestSubmission', 'Rating']
27+
'ContestSubmission', 'Rating', 'ContestRole']
2728

2829

2930
class MinValueOrNoneValidator(MinValueValidator):
@@ -60,6 +61,32 @@ class Meta:
6061
verbose_name_plural = _('contest tags')
6162

6263

64+
class RoleQuerySetAdapter:
65+
def __init__(self, queryset):
66+
self.queryset = queryset
67+
68+
def all(self):
69+
return self.queryset.all()
70+
71+
def filter(self, *args, **kwargs):
72+
return self.queryset.filter(*args, **kwargs)
73+
74+
def exists(self):
75+
return self.queryset.exists()
76+
77+
def first(self):
78+
return self.queryset.first()
79+
80+
def values_list(self, *args, **kwargs):
81+
return self.queryset.values_list(*args, **kwargs)
82+
83+
def __iter__(self):
84+
return iter(self.queryset)
85+
86+
def __contains__(self, item):
87+
return self.queryset.filter(pk=getattr(item, 'pk', item)).exists()
88+
89+
6390
class Contest(models.Model):
6491
SCOREBOARD_VISIBLE = 'V'
6592
SCOREBOARD_HIDDEN = 'H'
@@ -74,14 +101,6 @@ class Contest(models.Model):
74101
key = models.CharField(max_length=32, verbose_name=_('contest id'), unique=True,
75102
validators=[RegexValidator('^[a-z0-9_]+$', _('Contest id must be ^[a-z0-9_]+$'))])
76103
name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True)
77-
authors = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest.'),
78-
related_name='authors+')
79-
curators = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest, '
80-
'but will not be listed as authors.'),
81-
related_name='curators+', blank=True)
82-
testers = models.ManyToManyField(Profile, help_text=_('These users will be able to view the contest, '
83-
'but not edit it.'),
84-
blank=True, related_name='testers+')
85104
description = models.TextField(verbose_name=_('description'), blank=True)
86105
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
87106
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
@@ -318,6 +337,27 @@ def show_scoreboard(self):
318337
return False
319338
return True
320339

340+
@property
341+
def authors(self):
342+
return RoleQuerySetAdapter(Profile.objects.filter(
343+
contest_roles__contest=self,
344+
contest_roles__role=ROLE_AUTHOR,
345+
))
346+
347+
@property
348+
def curators(self):
349+
return RoleQuerySetAdapter(Profile.objects.filter(
350+
contest_roles__contest=self,
351+
contest_roles__role=ROLE_CURATOR,
352+
))
353+
354+
@property
355+
def testers(self):
356+
return RoleQuerySetAdapter(Profile.objects.filter(
357+
contest_roles__contest=self,
358+
contest_roles__role=ROLE_TESTER,
359+
))
360+
321361
@property
322362
def contest_window_length(self):
323363
return self.end_time - self.start_time
@@ -377,16 +417,27 @@ def ended(self):
377417

378418
@cached_property
379419
def author_ids(self):
380-
return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True)
420+
return ContestRole.objects.filter(
421+
contest=self, role=ROLE_AUTHOR,
422+
).values_list('user_id', flat=True)
381423

382424
@cached_property
383425
def editor_ids(self):
384-
return self.author_ids.union(
385-
Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True))
426+
return ContestRole.objects.filter(
427+
contest=self,
428+
role=ROLE_AUTHOR,
429+
).values_list('user_id', flat=True).union(
430+
ContestRole.objects.filter(
431+
contest=self,
432+
role=ROLE_CURATOR,
433+
).values_list('user_id', flat=True),
434+
)
386435

387436
@cached_property
388437
def tester_ids(self):
389-
return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True)
438+
return ContestRole.objects.filter(
439+
contest=self, role=ROLE_TESTER,
440+
).values_list('user_id', flat=True)
390441

391442
@classmethod
392443
def get_id_secret(cls, contest_id):
@@ -530,17 +581,20 @@ def get_visible_contests(cls, user):
530581
)
531582
)
532583

533-
authors_exists = Contest.authors.through.objects.filter(
584+
authors_exists = ContestRole.objects.filter(
534585
contest_id=OuterRef('pk'),
535-
profile_id=user.profile.id,
586+
user_id=user.profile.id,
587+
role=ROLE_AUTHOR,
536588
)
537-
curators_exists = Contest.curators.through.objects.filter(
589+
curators_exists = ContestRole.objects.filter(
538590
contest_id=OuterRef('pk'),
539-
profile_id=user.profile.id,
591+
user_id=user.profile.id,
592+
role=ROLE_CURATOR,
540593
)
541-
testers_exists = Contest.testers.through.objects.filter(
594+
testers_exists = ContestRole.objects.filter(
542595
contest_id=OuterRef('pk'),
543-
profile_id=user.profile.id,
596+
user_id=user.profile.id,
597+
role=ROLE_TESTER,
544598
)
545599

546600
queryset = queryset.annotate(

0 commit comments

Comments
 (0)