Add contest tester/curator (DMOJ)
This commit is contained in:
parent
06318a97e5
commit
297b8a2a36
25 changed files with 611 additions and 230 deletions
|
@ -1,12 +1,13 @@
|
|||
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
|
||||
from django.db.models import CASCADE, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from jsonfield import JSONField
|
||||
from lupa import LuaRuntime
|
||||
from moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON, MOSS_LANG_PASCAL
|
||||
|
||||
from judge import contest_format
|
||||
|
@ -48,11 +49,25 @@ class ContestTag(models.Model):
|
|||
|
||||
|
||||
class Contest(models.Model):
|
||||
SCOREBOARD_VISIBLE = 'V'
|
||||
SCOREBOARD_AFTER_CONTEST = 'C'
|
||||
SCOREBOARD_AFTER_PARTICIPATION = 'P'
|
||||
SCOREBOARD_VISIBILITY = (
|
||||
(SCOREBOARD_VISIBLE, _('Visible')),
|
||||
(SCOREBOARD_AFTER_CONTEST, _('Hidden for duration of contest')),
|
||||
(SCOREBOARD_AFTER_PARTICIPATION, _('Hidden for duration of participation')),
|
||||
)
|
||||
key = models.CharField(max_length=20, 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)
|
||||
organizers = models.ManyToManyField(Profile, help_text=_('These people will be able to edit the contest.'),
|
||||
related_name='organizers+')
|
||||
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)
|
||||
|
@ -64,10 +79,9 @@ class Contest(models.Model):
|
|||
'specified organizations.'))
|
||||
is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'),
|
||||
default=False)
|
||||
hide_scoreboard = models.BooleanField(verbose_name=_('hide scoreboard'),
|
||||
help_text=_('Whether the scoreboard should remain hidden for the duration '
|
||||
'of the contest.'),
|
||||
default=False)
|
||||
scoreboard_visibility = models.CharField(verbose_name=_('scoreboard visibility'), default=SCOREBOARD_VISIBLE,
|
||||
max_length=1, help_text=_('Scoreboard visibility through the duration '
|
||||
'of the contest'), choices=SCOREBOARD_VISIBILITY)
|
||||
view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True,
|
||||
related_name='view_contest_scoreboard',
|
||||
help_text=_('These users will be able to view the scoreboard.'))
|
||||
|
@ -116,6 +130,10 @@ class Contest(models.Model):
|
|||
help_text=_('A JSON object to serve as the configuration for the chosen contest format '
|
||||
'module. Leave empty to use None. Exact format depends on the contest format '
|
||||
'selected.'))
|
||||
problem_label_script = models.TextField(verbose_name='contest problem label script', blank=True,
|
||||
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.')
|
||||
points_precision = models.IntegerField(verbose_name=_('precision points'), default=2,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(10)],
|
||||
help_text=_('Number of digits to round points to.'))
|
||||
|
@ -128,30 +146,72 @@ class Contest(models.Model):
|
|||
def format(self):
|
||||
return self.format_class(self, self.format_config)
|
||||
|
||||
@cached_property
|
||||
def get_label_for_problem(self):
|
||||
def DENY_ALL(obj, attr_name, is_setting):
|
||||
raise AttributeError()
|
||||
lua = LuaRuntime(attribute_filter=DENY_ALL, register_eval=False, register_builtins=False)
|
||||
return lua.eval(self.problem_label_script or self.format.get_contest_problem_label_script())
|
||||
|
||||
def clean(self):
|
||||
# Django will complain if you didn't fill in start_time or end_time, so we don't have to.
|
||||
if self.start_time and self.end_time and self.start_time >= self.end_time:
|
||||
raise ValidationError('What is this? A contest that ended before it starts?')
|
||||
self.format_class.validate(self.format_config)
|
||||
|
||||
try:
|
||||
# a contest should have at least one problem, with contest problem index 0
|
||||
# so test it to see if the script returns a valid label.
|
||||
label = self.get_label_for_problem(0)
|
||||
except Exception as e:
|
||||
raise ValidationError('Contest problem label script: %s' % e)
|
||||
else:
|
||||
if not isinstance(label, str):
|
||||
raise ValidationError('Contest problem label script: script should return a string.')
|
||||
|
||||
def is_in_contest(self, user):
|
||||
if user.is_authenticated:
|
||||
profile = user.profile
|
||||
return profile and profile.current_contest is not None and profile.current_contest.contest == self
|
||||
return False
|
||||
|
||||
def can_see_scoreboard(self, user):
|
||||
if user.has_perm('judge.see_private_contest'):
|
||||
def can_see_own_scoreboard(self, user):
|
||||
if self.can_see_full_scoreboard(user):
|
||||
return True
|
||||
if user.is_authenticated and self.organizers.filter(id=user.profile.id).exists():
|
||||
return True
|
||||
if user.is_authenticated and self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||
return True
|
||||
if not self.is_visible:
|
||||
if not self.can_join:
|
||||
return False
|
||||
if self.start_time is not None and self.start_time > timezone.now():
|
||||
if not self.show_scoreboard and not self.is_in_contest(user):
|
||||
return False
|
||||
if self.hide_scoreboard and not self.is_in_contest(user) and self.end_time > timezone.now():
|
||||
return True
|
||||
|
||||
def can_see_full_scoreboard(self, user):
|
||||
if self.show_scoreboard:
|
||||
return True
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
|
||||
return True
|
||||
if user.profile.id in self.editor_ids:
|
||||
return True
|
||||
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||
return True
|
||||
if self.scoreboard_visibility == self.SCOREBOARD_AFTER_PARTICIPATION and self.has_completed_contest(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_completed_contest(self, user):
|
||||
if user.is_authenticated:
|
||||
participation = self.users.filter(virtual=ContestParticipation.LIVE, user=user.profile).first()
|
||||
if participation and participation.ended:
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def show_scoreboard(self):
|
||||
if not self.can_join:
|
||||
return False
|
||||
if (self.scoreboard_visibility in (self.SCOREBOARD_AFTER_CONTEST, self.SCOREBOARD_AFTER_PARTICIPATION) and
|
||||
not self.ended):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
@ -186,6 +246,19 @@ class Contest(models.Model):
|
|||
def ended(self):
|
||||
return self.end_time < self._now
|
||||
|
||||
@cached_property
|
||||
def author_ids(self):
|
||||
return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True)
|
||||
|
||||
@cached_property
|
||||
def editor_ids(self):
|
||||
return self.author_ids.union(
|
||||
Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True))
|
||||
|
||||
@cached_property
|
||||
def tester_ids(self):
|
||||
return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -198,50 +271,111 @@ class Contest(models.Model):
|
|||
|
||||
update_user_count.alters_data = True
|
||||
|
||||
@cached_property
|
||||
def show_scoreboard(self):
|
||||
if self.hide_scoreboard and not self.ended:
|
||||
return False
|
||||
return True
|
||||
class Inaccessible(Exception):
|
||||
pass
|
||||
|
||||
class PrivateContest(Exception):
|
||||
pass
|
||||
|
||||
def access_check(self, user):
|
||||
# Do unauthenticated check here so we can skip authentication checks later on.
|
||||
if not user.is_authenticated:
|
||||
# Unauthenticated users can only see visible, non-private contests
|
||||
if not self.is_visible:
|
||||
raise self.Inaccessible()
|
||||
if self.is_private or self.is_organization_private:
|
||||
raise self.PrivateContest()
|
||||
return
|
||||
|
||||
# If the user can view or edit all contests
|
||||
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
|
||||
return
|
||||
|
||||
# User is organizer or curator for contest
|
||||
if user.profile.id in self.editor_ids:
|
||||
return
|
||||
|
||||
# User is tester for contest
|
||||
if user.profile.id in self.tester_ids:
|
||||
return
|
||||
|
||||
# Contest is not publicly visible
|
||||
if not self.is_visible:
|
||||
raise self.Inaccessible()
|
||||
|
||||
# Contest is not private
|
||||
if not self.is_private and not self.is_organization_private:
|
||||
return
|
||||
|
||||
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||
return
|
||||
|
||||
in_org = self.organizations.filter(id__in=user.profile.organizations.all()).exists()
|
||||
in_users = self.private_contestants.filter(id=user.profile.id).exists()
|
||||
|
||||
if not self.is_private and self.is_organization_private:
|
||||
if in_org:
|
||||
return
|
||||
raise self.PrivateContest()
|
||||
|
||||
if self.is_private and not self.is_organization_private:
|
||||
if in_users:
|
||||
return
|
||||
raise self.PrivateContest()
|
||||
|
||||
if self.is_private and self.is_organization_private:
|
||||
if in_org and in_users:
|
||||
return
|
||||
raise self.PrivateContest()
|
||||
|
||||
def is_accessible_by(self, user):
|
||||
# Contest is publicly visible
|
||||
if self.is_visible:
|
||||
# Contest is not private
|
||||
if not self.is_private and not self.is_organization_private:
|
||||
return True
|
||||
if user.is_authenticated:
|
||||
# User is in the organizations it is private to
|
||||
if self.organizations.filter(id__in=user.profile.organizations.all()).exists():
|
||||
return True
|
||||
# User is in the group of private contestants
|
||||
if self.private_contestants.filter(id=user.profile.id).exists():
|
||||
return True
|
||||
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||
return True
|
||||
|
||||
# If the user can view all contests
|
||||
if user.has_perm('judge.see_private_contest'):
|
||||
try:
|
||||
self.access_check(user)
|
||||
except (self.Inaccessible, self.PrivateContest):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
# User can edit the contest
|
||||
return self.is_editable_by(user)
|
||||
|
||||
def is_editable_by(self, user):
|
||||
# If the user can edit all contests
|
||||
if user.has_perm('judge.edit_all_contest'):
|
||||
return True
|
||||
|
||||
# If the user is a contest organizer
|
||||
if user.has_perm('judge.edit_own_contest') and \
|
||||
self.organizers.filter(id=user.profile.id).exists():
|
||||
# If the user is a contest organizer or curator
|
||||
if user.has_perm('judge.edit_own_contest') and user.profile.id in self.editor_ids:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_visible_contests(cls, user):
|
||||
if not user.is_authenticated:
|
||||
return cls.objects.filter(is_visible=True, is_organization_private=False, is_private=False) \
|
||||
.defer('description').distinct()
|
||||
|
||||
queryset = cls.objects.defer('description')
|
||||
if not (user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest')):
|
||||
q = Q(is_visible=True)
|
||||
q &= (
|
||||
Q(view_contest_scoreboard=user.profile) |
|
||||
Q(is_organization_private=False, is_private=False) |
|
||||
Q(is_organization_private=False, is_private=True, private_contestants=user.profile) |
|
||||
Q(is_organization_private=True, is_private=False, organizations__in=user.profile.organizations.all()) |
|
||||
Q(is_organization_private=True, is_private=True, organizations__in=user.profile.organizations.all(),
|
||||
private_contestants=user.profile)
|
||||
)
|
||||
|
||||
q |= Q(authors=user.profile)
|
||||
q |= Q(curators=user.profile)
|
||||
q |= Q(testers=user.profile)
|
||||
queryset = queryset.filter(q)
|
||||
return queryset.distinct()
|
||||
|
||||
def rate(self):
|
||||
Rating.objects.filter(contest__end_time__gte=self.end_time).delete()
|
||||
for contest in Contest.objects.filter(is_rated=True, end_time__gte=self.end_time).order_by('end_time'):
|
||||
Rating.objects.filter(contest__end_time__range=(self.end_time, self._now)).delete()
|
||||
for contest in Contest.objects.filter(
|
||||
is_rated=True, end_time__range=(self.end_time, self._now),
|
||||
).order_by('end_time'):
|
||||
rate_contest(contest)
|
||||
|
||||
class Meta:
|
||||
|
@ -255,6 +389,7 @@ class Contest(models.Model):
|
|||
('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')),
|
||||
)
|
||||
verbose_name = _('contest')
|
||||
verbose_name_plural = _('contests')
|
||||
|
@ -271,6 +406,7 @@ class ContestParticipation(models.Model):
|
|||
cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0)
|
||||
is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False,
|
||||
help_text=_('Whether this participation is disqualified.'))
|
||||
tiebreaker = models.FloatField(verbose_name=_('tie-breaking field'), default=0.0)
|
||||
virtual = models.IntegerField(verbose_name=_('virtual participation id'), default=LIVE,
|
||||
help_text=_('0 means non-virtual, otherwise the n-th virtual participation.'))
|
||||
format_data = JSONField(verbose_name=_('contest format specific data'), null=True, blank=True)
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
|||
from django.core.cache import cache
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, F, QuerySet, SET_NULL
|
||||
from django.db.models import CASCADE, F, Q, QuerySet, SET_NULL
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import reverse
|
||||
|
@ -219,6 +219,43 @@ class Problem(models.Model):
|
|||
|
||||
def is_subs_manageable_by(self, user):
|
||||
return user.is_staff and user.has_perm('judge.rejudge_submission') and self.is_editable_by(user)
|
||||
|
||||
@classmethod
|
||||
def get_visible_problems(cls, user):
|
||||
# Do unauthenticated check here so we can skip authentication checks later on.
|
||||
if not user.is_authenticated:
|
||||
return cls.get_public_problems()
|
||||
|
||||
# Conditions for visible problem:
|
||||
# - `judge.edit_all_problem` or `judge.see_private_problem`
|
||||
# - otherwise
|
||||
# - not is_public problems
|
||||
# - author or curator or tester
|
||||
# - is_public problems
|
||||
# - not is_organization_private or in organization or `judge.see_organization_problem`
|
||||
# - author or curator or tester
|
||||
queryset = cls.objects.defer('description')
|
||||
|
||||
if not (user.has_perm('judge.see_private_problem') or user.has_perm('judge.edit_all_problem')):
|
||||
q = Q(is_public=True)
|
||||
if not user.has_perm('judge.see_organization_problem'):
|
||||
# Either not organization private or in the organization.
|
||||
q &= (
|
||||
Q(is_organization_private=False) |
|
||||
Q(is_organization_private=True, organizations__in=user.profile.organizations.all())
|
||||
)
|
||||
|
||||
# Authors, curators, and testers should always have access, so OR at the very end.
|
||||
q |= Q(authors=user.profile)
|
||||
q |= Q(curators=user.profile)
|
||||
q |= Q(testers=user.profile)
|
||||
queryset = queryset.filter(q)
|
||||
|
||||
return queryset.distinct()
|
||||
|
||||
@classmethod
|
||||
def get_public_problems(cls):
|
||||
return cls.objects.filter(is_public=True, is_organization_private=False).defer('description').distinct()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
@ -136,12 +136,15 @@ class Profile(models.Model):
|
|||
|
||||
def calculate_points(self, table=_pp_table):
|
||||
from judge.models import Problem
|
||||
data = (Problem.objects.filter(submission__user=self, submission__points__isnull=False, is_public=True,
|
||||
is_organization_private=False)
|
||||
.annotate(max_points=Max('submission__points')).order_by('-max_points')
|
||||
.values_list('max_points', flat=True).filter(max_points__gt=0))
|
||||
extradata = Problem.objects.filter(submission__user=self, submission__result='AC', is_public=True) \
|
||||
.values('id').distinct().count()
|
||||
public_problems = Problem.get_public_problems()
|
||||
data = (
|
||||
public_problems.filter(submission__user=self, submission__points__isnull=False)
|
||||
.annotate(max_points=Max('submission__points')).order_by('-max_points')
|
||||
.values_list('max_points', flat=True).filter(max_points__gt=0)
|
||||
)
|
||||
extradata = (
|
||||
public_problems.filter(submission__user=self, submission__result='AC').values('id').count()
|
||||
)
|
||||
bonus_function = settings.DMOJ_PP_BONUS_FUNCTION
|
||||
points = sum(data)
|
||||
problems = len(data)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue