NDOJ/judge/models/contest.py
2023-08-07 20:33:49 +07:00

900 lines
29 KiB
Python

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, 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 django.contrib.contenttypes.fields import GenericRelation
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
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
from judge.models.pagevote import PageVotable
from judge.models.bookmark import Bookmarkable
__all__ = [
"Contest",
"ContestTag",
"ContestParticipation",
"ContestProblem",
"ContestSubmission",
"Rating",
"ContestProblemClarification",
]
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, PageVotable, Bookmarkable):
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
)
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)
end_time = models.DateTimeField(verbose_name=_("end time"), db_index=True)
time_limit = models.DurationField(
verbose_name=_("time limit"),
blank=True,
null=True,
help_text=_(
"Format hh:mm:ss. For example, if you want a 2-hour contest, enter 02:00:00"
),
)
freeze_after = models.DurationField(
verbose_name=_("freeze after"),
blank=True,
null=True,
help_text=_(
"Format hh:mm:ss. For example, if you want to freeze contest after 2 hours, enter 02:00:00"
),
)
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,
)
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."),
)
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=True,
)
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."
),
)
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."),
)
comments = GenericRelation("Comment")
pagevote = GenericRelation("PageVote")
bookmark = GenericRelation("BookMark")
@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)
@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_own_scoreboard(self, user):
if self.can_see_full_scoreboard(user):
return True
if not self.can_join:
return False
if not self.show_scoreboard and not self.is_in_contest(user):
return False
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
@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
@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
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
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):
try:
self.access_check(user)
except (self.Inaccessible, self.PrivateContest):
return False
else:
return True
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 or curator
if user and user.profile.id in self.editor_ids:
return True
return False
@classmethod
def get_visible_contests(cls, user, show_own_contests_only=False):
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")
)
or show_own_contests_only
):
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__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:
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")),
("change_contest_visibility", _("Change contest visibility")),
("contest_problem_label", _("Edit contest problem label script")),
)
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.FloatField(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."),
)
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
)
format_data_final = JSONField(
verbose_name=_("same as format_data, but including frozen results"),
null=True,
blank=True,
)
score_final = models.FloatField(verbose_name=_("final score"), default=0)
cumtime_final = models.PositiveIntegerField(
verbose_name=_("final cumulative time"), default=0
)
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."
),
verbose_name=_("max submissions"),
default=0,
validators=[
MinValueValidator(0, _("Why include a problem you " "can't submit to?"))
],
)
hidden_subtasks = models.CharField(
help_text=_("Separated by commas, e.g: 2, 3"),
verbose_name=_("hidden subtasks"),
null=True,
blank=True,
max_length=20,
)
@property
def clarifications(self):
return ContestProblemClarification.objects.filter(problem=self)
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"))
mean = models.FloatField(verbose_name=_("raw rating"))
performance = models.FloatField(verbose_name=_("contest performance"))
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")
class ContestProblemClarification(models.Model):
problem = models.ForeignKey(
ContestProblem, verbose_name=_("clarified problem"), on_delete=CASCADE
)
description = models.TextField(verbose_name=_("clarification body"))
date = models.DateTimeField(
verbose_name=_("clarification timestamp"), auto_now_add=True
)