Skip to content

Commit a153e3f

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

File tree

13 files changed

+308
-72
lines changed

13 files changed

+308
-72
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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from operator import attrgetter
2-
31
from judge.models import SubmissionSourceAccess
2+
from judge.models.role import ContestRole, ROLE_AUTHOR, ROLE_CURATOR
43
from . import registry
54

65

7-
# TODO: maybe refactor this?
86
def get_editor_ids(contest):
9-
return set(map(attrgetter('id'), contest.authors.all())) | set(map(attrgetter('id'), contest.curators.all()))
7+
return set(ContestRole.objects.filter(
8+
contest=contest, role__in=[ROLE_AUTHOR, ROLE_CURATOR],
9+
).values_list('user_id', flat=True))
1010

1111

1212
@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: 29 additions & 37 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, RoleQuerySetAdapter, 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):
@@ -74,14 +75,6 @@ class Contest(models.Model):
7475
key = models.CharField(max_length=32, verbose_name=_('contest id'), unique=True,
7576
validators=[RegexValidator('^[a-z0-9_]+$', _('Contest id must be ^[a-z0-9_]+$'))])
7677
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+')
8578
description = models.TextField(verbose_name=_('description'), blank=True)
8679
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
8780
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
@@ -318,6 +311,23 @@ def show_scoreboard(self):
318311
return False
319312
return True
320313

314+
def _role_users(self, role):
315+
return RoleQuerySetAdapter(Profile.objects.filter(
316+
contest_roles__contest=self, contest_roles__role=role,
317+
))
318+
319+
@property
320+
def authors(self):
321+
return self._role_users(ROLE_AUTHOR)
322+
323+
@property
324+
def curators(self):
325+
return self._role_users(ROLE_CURATOR)
326+
327+
@property
328+
def testers(self):
329+
return self._role_users(ROLE_TESTER)
330+
321331
@property
322332
def contest_window_length(self):
323333
return self.end_time - self.start_time
@@ -375,18 +385,22 @@ def time_before_end(self):
375385
def ended(self):
376386
return self.end_time < self._now
377387

388+
def _role_ids(self, roles):
389+
return ContestRole.objects.filter(
390+
contest=self, role__in=roles,
391+
).values_list('user_id', flat=True)
392+
378393
@cached_property
379394
def author_ids(self):
380-
return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True)
395+
return self._role_ids([ROLE_AUTHOR])
381396

382397
@cached_property
383398
def editor_ids(self):
384-
return self.author_ids.union(
385-
Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True))
399+
return self._role_ids([ROLE_AUTHOR, ROLE_CURATOR])
386400

387401
@cached_property
388402
def tester_ids(self):
389-
return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True)
403+
return self._role_ids([ROLE_TESTER])
390404

391405
@classmethod
392406
def get_id_secret(cls, contest_id):
@@ -530,31 +544,9 @@ def get_visible_contests(cls, user):
530544
)
531545
)
532546

533-
authors_exists = Contest.authors.through.objects.filter(
534-
contest_id=OuterRef('pk'),
535-
profile_id=user.profile.id,
536-
)
537-
curators_exists = Contest.curators.through.objects.filter(
538-
contest_id=OuterRef('pk'),
539-
profile_id=user.profile.id,
540-
)
541-
testers_exists = Contest.testers.through.objects.filter(
542-
contest_id=OuterRef('pk'),
543-
profile_id=user.profile.id,
544-
)
545-
546547
queryset = queryset.annotate(
547-
has_author=Exists(authors_exists),
548-
has_curator=Exists(curators_exists),
549-
has_tester=Exists(testers_exists),
550-
)
551-
552-
queryset = queryset.filter(
553-
q |
554-
Q(has_author=True) |
555-
Q(has_curator=True) |
556-
Q(has_tester=True),
557-
)
548+
has_role=ContestRole.exists_for(user.profile),
549+
).filter(q | Q(has_role=True))
558550

559551
return queryset
560552

judge/models/role.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from django.db import models
2+
from django.db.models import CASCADE, Exists, OuterRef
3+
from django.utils.translation import gettext_lazy as _
4+
5+
from judge.models.profile import Profile
6+
7+
ROLE_AUTHOR = 'A'
8+
ROLE_CURATOR = 'C'
9+
ROLE_TESTER = 'T'
10+
11+
ROLE_CHOICES = (
12+
(ROLE_AUTHOR, _('Author')),
13+
(ROLE_CURATOR, _('Curator')),
14+
(ROLE_TESTER, _('Tester')),
15+
)
16+
17+
18+
class RoleQuerySetAdapter:
19+
def __init__(self, queryset):
20+
self.queryset = queryset
21+
22+
def all(self):
23+
return self.queryset.all()
24+
25+
def filter(self, *args, **kwargs):
26+
return self.queryset.filter(*args, **kwargs)
27+
28+
def exists(self):
29+
return self.queryset.exists()
30+
31+
def first(self):
32+
return self.queryset.first()
33+
34+
def values_list(self, *args, **kwargs):
35+
return self.queryset.values_list(*args, **kwargs)
36+
37+
def __iter__(self):
38+
return iter(self.queryset)
39+
40+
def __contains__(self, item):
41+
return self.queryset.filter(pk=getattr(item, 'pk', item)).exists()
42+
43+
44+
class ContestRole(models.Model):
45+
contest = models.ForeignKey(
46+
'Contest', verbose_name=_('contest'), related_name='contest_roles', on_delete=CASCADE,
47+
)
48+
user = models.ForeignKey(
49+
Profile, verbose_name=_('user'), related_name='contest_roles', on_delete=CASCADE,
50+
)
51+
role = models.CharField(max_length=1, choices=ROLE_CHOICES, verbose_name=_('role'))
52+
53+
class Meta:
54+
unique_together = ('contest', 'user', 'role')
55+
verbose_name = _('contest role')
56+
verbose_name_plural = _('contest roles')
57+
58+
def __str__(self):
59+
return f'{self.user} - {self.get_role_display()} of {self.contest}'
60+
61+
@staticmethod
62+
def exists_for(user, role=None, roles=None):
63+
filters = {'contest_id': OuterRef('pk'), 'user': user}
64+
if role:
65+
filters['role'] = role
66+
qs = ContestRole.objects.filter(**filters)
67+
if roles:
68+
qs = qs.filter(role__in=roles)
69+
return Exists(qs)

0 commit comments

Comments
 (0)