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, MOSS_LANG_PASCAL 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(help_text=_('0 to not show testcases, 1 to show'), verbose_name=_('visible testcases'), null=True, blank=True, default=0) 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), ('Pascal', MOSS_LANG_PASCAL), ] 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')