NDOJ/judge/models/contest.py
2020-01-21 15:35:58 +09:00

412 lines
20 KiB
Python

from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models, transaction
from django.db.models import CASCADE
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 moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON
from judge import contest_format
from judge.models.problem import Problem
from judge.models.profile import Organization, Profile
from judge.models.submission import Submission
from judge.ratings import rate_contest
__all__ = ['Contest', 'ContestTag', 'ContestParticipation', 'ContestProblem', 'ContestSubmission', 'Rating']
class ContestTag(models.Model):
color_validator = RegexValidator('^#(?:[A-Fa-f0-9]{3}){1,2}$', _('Invalid colour.'))
name = models.CharField(max_length=20, verbose_name=_('tag name'), unique=True,
validators=[RegexValidator(r'^[a-z-]+$', message=_('Lowercase letters and hyphens only.'))])
color = models.CharField(max_length=7, verbose_name=_('tag colour'), validators=[color_validator])
description = models.TextField(verbose_name=_('tag description'), blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('contest_tag', args=[self.name])
@property
def text_color(self, cache={}):
if self.color not in cache:
if len(self.color) == 4:
r, g, b = [ord(bytes.fromhex(i * 2)) for i in self.color[1:]]
else:
r, g, b = [i for i in bytes.fromhex(self.color[1:])]
cache[self.color] = '#000' if 299 * r + 587 * g + 144 * b > 140000 else '#fff'
return cache[self.color]
class Meta:
verbose_name = _('contest tag')
verbose_name_plural = _('contest tags')
class Contest(models.Model):
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+')
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)
end_time = models.DateTimeField(verbose_name=_('end time'), db_index=True)
time_limit = models.DurationField(verbose_name=_('time limit'), blank=True, null=True)
is_visible = models.BooleanField(verbose_name=_('publicly visible'), default=False,
help_text=_('Should be set even for organization-private contests, where it '
'determines whether the contest is visible to members of the '
'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)
use_clarifications = models.BooleanField(verbose_name=_('no comments'),
help_text=_("Use clarification system instead of comments."),
default=True)
rating_floor = models.IntegerField(verbose_name=('rating floor'), help_text=_('Rating floor for contest'),
null=True, blank=True)
rating_ceiling = models.IntegerField(verbose_name=('rating ceiling'), help_text=_('Rating ceiling for contest'),
null=True, blank=True)
rate_all = models.BooleanField(verbose_name=_('rate all'), help_text=_('Rate all users who joined.'), default=False)
rate_exclude = models.ManyToManyField(Profile, verbose_name=_('exclude from ratings'), blank=True,
related_name='rate_exclude+')
is_private = models.BooleanField(verbose_name=_('private to specific users'), default=False)
private_contestants = models.ManyToManyField(Profile, blank=True, verbose_name=_('private contestants'),
help_text=_('If private, only these users may see the contest'),
related_name='private_contestants+')
hide_problem_tags = models.BooleanField(verbose_name=_('hide problem tags'),
help_text=_('Whether problem tags should be hidden by default.'),
default=False)
run_pretests_only = models.BooleanField(verbose_name=_('run pretests only'),
help_text=_('Whether judges should grade pretests only, versus all '
'testcases. Commonly set during a contest, then unset '
'prior to rejudging user submissions when the contest ends.'),
default=False)
is_organization_private = models.BooleanField(verbose_name=_('private to organizations'), default=False)
organizations = models.ManyToManyField(Organization, blank=True, verbose_name=_('organizations'),
help_text=_('If private, only these organizations may see the contest'))
og_image = models.CharField(verbose_name=_('OpenGraph image'), default='', max_length=150, blank=True)
logo_override_image = models.CharField(verbose_name=_('Logo override image'), default='', max_length=150,
blank=True,
help_text=_('This image will replace the default site logo for users '
'inside the contest.'))
tags = models.ManyToManyField(ContestTag, verbose_name=_('contest tags'), blank=True, related_name='contests')
user_count = models.IntegerField(verbose_name=_('the amount of live participants'), default=0)
summary = models.TextField(blank=True, verbose_name=_('contest summary'),
help_text=_('Plain-text, shown in meta description tag, e.g. for social media.'))
access_code = models.CharField(verbose_name=_('access code'), blank=True, default='', max_length=255,
help_text=_('An optional code to prompt contestants before they are allowed '
'to join the contest. Leave it blank to disable.'))
banned_users = models.ManyToManyField(Profile, verbose_name=_('personae non gratae'), blank=True,
help_text=_('Bans the selected users from joining this contest.'))
format_name = models.CharField(verbose_name=_('contest format'), default='default', max_length=32,
choices=contest_format.choices(), help_text=_('The contest format module to use.'))
format_config = JSONField(verbose_name=_('contest format configuration'), null=True, blank=True,
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.'))
@cached_property
def format_class(self):
return contest_format.formats[self.format_name]
@cached_property
def format(self):
return self.format_class(self, self.format_config)
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)
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'):
return True
if user.is_authenticated and self.organizers.filter(id=user.profile.id).exists():
return True
if not self.is_visible:
return False
if self.start_time is not None and self.start_time > timezone.now():
return False
if self.hide_scoreboard and not self.is_in_contest(user) and self.end_time > timezone.now():
return False
return True
@property
def contest_window_length(self):
return self.end_time - self.start_time
@cached_property
def _now(self):
# This ensures that all methods talk about the same now.
return timezone.now()
@cached_property
def can_join(self):
return self.start_time <= self._now
@property
def time_before_start(self):
if self.start_time >= self._now:
return self.start_time - self._now
else:
return None
@property
def time_before_end(self):
if self.end_time >= self._now:
return self.end_time - self._now
else:
return None
@cached_property
def ended(self):
return self.end_time < self._now
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('contest_view', args=(self.key,))
def update_user_count(self):
self.user_count = self.users.filter(virtual=0).count()
self.save()
update_user_count.alters_data = True
@cached_property
def show_scoreboard(self):
if self.hide_scoreboard and not self.ended:
return False
return True
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 the user can view all contests
if user.has_perm('judge.see_private_contest'):
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():
return True
return False
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'):
rate_contest(contest)
class Meta:
permissions = (
('see_private_contest', _('See private contests')),
('edit_own_contest', _('Edit own contests')),
('edit_all_contest', _('Edit all contests')),
('clone_contest', _('Clone contest')),
('moss_contest', _('MOSS contest')),
('contest_rating', _('Rate contests')),
('contest_access_code', _('Contest access codes')),
('create_private_contest', _('Create private contests')),
)
verbose_name = _('contest')
verbose_name_plural = _('contests')
class ContestParticipation(models.Model):
LIVE = 0
SPECTATE = -1
contest = models.ForeignKey(Contest, verbose_name=_('associated contest'), related_name='users', on_delete=CASCADE)
user = models.ForeignKey(Profile, verbose_name=_('user'), related_name='contest_history', on_delete=CASCADE)
real_start = models.DateTimeField(verbose_name=_('start time'), default=timezone.now, db_column='start')
score = models.IntegerField(verbose_name=_('score'), default=0, db_index=True)
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.'))
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)
def recompute_results(self):
with transaction.atomic():
self.contest.format.update_participation(self)
if self.is_disqualified:
self.score = -9999
self.save(update_fields=['score'])
recompute_results.alters_data = True
def set_disqualified(self, disqualified):
self.is_disqualified = disqualified
self.recompute_results()
if self.contest.is_rated and self.contest.ratings.exists():
self.contest.rate()
if self.is_disqualified:
if self.user.current_contest == self:
self.user.remove_contest()
self.contest.banned_users.add(self.user)
else:
self.contest.banned_users.remove(self.user)
set_disqualified.alters_data = True
@property
def live(self):
return self.virtual == self.LIVE
@property
def spectate(self):
return self.virtual == self.SPECTATE
@cached_property
def start(self):
contest = self.contest
return contest.start_time if contest.time_limit is None and (self.live or self.spectate) else self.real_start
@cached_property
def end_time(self):
contest = self.contest
if self.spectate:
return contest.end_time
if self.virtual:
if contest.time_limit:
return self.real_start + contest.time_limit
else:
return self.real_start + (contest.end_time - contest.start_time)
return contest.end_time if contest.time_limit is None else \
min(self.real_start + contest.time_limit, contest.end_time)
@cached_property
def _now(self):
# This ensures that all methods talk about the same now.
return timezone.now()
@property
def ended(self):
return self.end_time is not None and self.end_time < self._now
@property
def time_remaining(self):
end = self.end_time
if end is not None and end >= self._now:
return end - self._now
def __str__(self):
if self.spectate:
return gettext('%s spectating in %s') % (self.user.username, self.contest.name)
if self.virtual:
return gettext('%s in %s, v%d') % (self.user.username, self.contest.name, self.virtual)
return gettext('%s in %s') % (self.user.username, self.contest.name)
class Meta:
verbose_name = _('contest participation')
verbose_name_plural = _('contest participations')
unique_together = ('contest', 'user', 'virtual')
class ContestProblem(models.Model):
problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='contests', on_delete=CASCADE)
contest = models.ForeignKey(Contest, verbose_name=_('contest'), related_name='contest_problems', on_delete=CASCADE)
points = models.IntegerField(verbose_name=_('points'))
partial = models.BooleanField(default=True, verbose_name=_('partial'))
is_pretested = models.BooleanField(default=False, verbose_name=_('is pretested'))
order = models.PositiveIntegerField(db_index=True, verbose_name=_('order'))
output_prefix_override = models.IntegerField(verbose_name=_('output prefix length override'), null=True, blank=True)
max_submissions = models.IntegerField(help_text=_('Maximum number of submissions for this problem, '
'or 0 for no limit.'), default=0,
validators=[MinValueValidator(0, _('Why include a problem you '
'can\'t submit to?'))])
class Meta:
unique_together = ('problem', 'contest')
verbose_name = _('contest problem')
verbose_name_plural = _('contest problems')
class ContestSubmission(models.Model):
submission = models.OneToOneField(Submission, verbose_name=_('submission'),
related_name='contest', on_delete=CASCADE)
problem = models.ForeignKey(ContestProblem, verbose_name=_('problem'), on_delete=CASCADE,
related_name='submissions', related_query_name='submission')
participation = models.ForeignKey(ContestParticipation, verbose_name=_('participation'), on_delete=CASCADE,
related_name='submissions', related_query_name='submission')
points = models.FloatField(default=0.0, verbose_name=_('points'))
is_pretest = models.BooleanField(verbose_name=_('is pretested'),
help_text=_('Whether this submission was ran only on pretests.'),
default=False)
class Meta:
verbose_name = _('contest submission')
verbose_name_plural = _('contest submissions')
class Rating(models.Model):
user = models.ForeignKey(Profile, verbose_name=_('user'), related_name='ratings', on_delete=CASCADE)
contest = models.ForeignKey(Contest, verbose_name=_('contest'), related_name='ratings', on_delete=CASCADE)
participation = models.OneToOneField(ContestParticipation, verbose_name=_('participation'),
related_name='rating', on_delete=CASCADE)
rank = models.IntegerField(verbose_name=_('rank'))
rating = models.IntegerField(verbose_name=_('rating'))
volatility = models.IntegerField(verbose_name=_('volatility'))
last_rated = models.DateTimeField(db_index=True, verbose_name=_('last rated'))
class Meta:
unique_together = ('user', 'contest')
verbose_name = _('contest rating')
verbose_name_plural = _('contest ratings')
class ContestMoss(models.Model):
LANG_MAPPING = [
('C', MOSS_LANG_C),
('C++', MOSS_LANG_CC),
('Java', MOSS_LANG_JAVA),
('Python', MOSS_LANG_PYTHON),
]
contest = models.ForeignKey(Contest, verbose_name=_('contest'), related_name='moss', on_delete=CASCADE)
problem = models.ForeignKey(Problem, verbose_name=_('problem'), related_name='moss', on_delete=CASCADE)
language = models.CharField(max_length=10)
submission_count = models.PositiveIntegerField(default=0)
url = models.URLField(null=True, blank=True)
class Meta:
unique_together = ('contest', 'problem', 'language')
verbose_name = _('contest moss result')
verbose_name_plural = _('contest moss results')