NDOJ/judge/models/contest.py

413 lines
20 KiB
Python
Raw Normal View History

2020-01-21 06:35:58 +00:00
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')