import logging
import os.path
from contextlib import contextmanager
from traceback import format_exception
from django.conf import settings
from django.contrib.auth.models import User
from django.core import validators
from django.core.files.base import ContentFile
from django.core.validators import validate_slug
from django.db import models, transaction
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.module_loading import import_string
from django.utils.text import get_valid_filename
from django.utils.translation import get_language, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from oioioi.base.fields import DottedNameField, EnumField, EnumRegistry
from oioioi.base.utils import split_extension, strip_num_or_hash
from oioioi.contests.models import ProblemInstance
from oioioi.filetracker.fields import FileField
from oioioi.problems.validators import validate_origintag
from unidecode import unidecode
[docs]logger = logging.getLogger(__name__)
[docs]def make_problem_filename(instance, filename):
if not isinstance(instance, Problem):
try:
instance = instance.problem
except AttributeError:
assert hasattr(instance, 'problem'), (
'problem_file_generator used '
'on object %r which does not have \'problem\' attribute' % (instance,)
)
return 'problems/%d/%s' % (
instance.id,
get_valid_filename(os.path.basename(filename)),
)
[docs]class Problem(models.Model):
"""Represents a problem in the problems database.
Instances of :class:`Problem` do not represent problems in contests,
see :class:`oioioi.contests.models.ProblemInstance` for those.
Each :class:`Problem` has associated main
:class:`oioioi.contests.models.ProblemInstance`,
called main_problem_instance:
1) It is not assigned to any contest.
2) It allows sending submissions aside from contests.
3) It is a base to create another instances.
"""
[docs] legacy_name = models.CharField(max_length=255, verbose_name=_("legacy name"))
[docs] short_name = models.CharField(
max_length=30, validators=[validate_slug], verbose_name=_("short name")
)
[docs] controller_name = DottedNameField(
'oioioi.problems.controllers.ProblemController', verbose_name=_("type")
)
[docs] contest = models.ForeignKey(
'contests.Contest',
null=True,
blank=True,
verbose_name=_("contest"),
on_delete=models.SET_NULL,
)
[docs] author = models.ForeignKey(
User, null=True, blank=True, verbose_name=_("author"), on_delete=models.SET_NULL
)
# visibility defines read access to all of problem data (this includes
# the package, all tests and attachments)
[docs] VISIBILITY_PUBLIC = 'PU'
[docs] VISIBILITY_FRIENDS = 'FR'
[docs] VISIBILITY_PRIVATE = 'PR'
[docs] VISIBILITY_LEVELS_CHOICES = [
(VISIBILITY_PUBLIC, 'Public'),
(VISIBILITY_FRIENDS, 'Friends'),
(VISIBILITY_PRIVATE, 'Private'),
]
[docs] visibility = models.CharField(
max_length=2,
verbose_name=_("visibility"),
choices=VISIBILITY_LEVELS_CHOICES,
default=VISIBILITY_FRIENDS,
)
[docs] package_backend_name = DottedNameField(
'oioioi.problems.package.ProblemPackageBackend',
null=True,
blank=True,
verbose_name=_("package type"),
)
[docs] ascii_name = models.CharField(
max_length=255, null=True
) # autofield, no polish characters
# main_problem_instance:
# null=True, because there is a cyclic dependency
# and during creation of any Problem, main_problem_instance
# must be temporarily set to Null
# (ProblemInstance has ForeignKey to Problem
# and Problem has ForeignKey to ProblemInstance)
[docs] main_problem_instance = models.ForeignKey(
'contests.ProblemInstance',
null=True,
blank=False,
verbose_name=_("main problem instance"),
related_name='main_problem_instance',
on_delete=models.CASCADE,
)
@cached_property
[docs] def name(self):
problem_name = ProblemName.objects.filter(
problem=self, language=get_language()
).first()
return problem_name.name if problem_name else self.legacy_name
@property
[docs] def controller(self):
return import_string(self.controller_name)(self)
@property
[docs] def package_backend(self):
return import_string(self.package_backend_name)()
@classmethod
[docs] def create(cls, *args, **kwargs):
"""Creates a new :class:`Problem` object, with associated
main_problem_instance.
After the call, the :class:`Problem` and the
:class:`ProblemInstance` objects will be saved in the database.
"""
problem = cls(*args, **kwargs)
problem.save()
import oioioi.contests.models
pi = oioioi.contests.models.ProblemInstance(problem=problem)
pi.save()
pi.short_name += "_main"
pi.save()
problem.main_problem_instance = pi
problem.save()
return problem
[docs] def __str__(self):
return u'%(name)s (%(short_name)s)' % {
u'short_name': self.short_name,
u'name': self.name,
}
[docs] def save(self, *args, **kwargs):
self.ascii_name = unidecode(str(self.name))
super(Problem, self).save(*args, **kwargs)
@receiver(post_save, sender=Problem)
[docs]def _call_controller_adjust_problem(sender, instance, raw, **kwargs):
if not raw and instance.controller_name:
instance.controller.adjust_problem()
@receiver(pre_delete, sender=Problem)
[docs]def _check_problem_instance_integrity(sender, instance, **kwargs):
from oioioi.contests.models import ProblemInstance
pis = ProblemInstance.objects.filter(problem=instance, contest__isnull=True)
if pis.count() > 1:
raise RuntimeError(
"Multiple main_problem_instance objects for a single problem."
)
[docs]class ProblemName(models.Model):
"""Represents a problem's name translation in a given language.
Problem should have its name translated to all available languages.
"""
[docs] problem = models.ForeignKey(Problem, related_name='names', on_delete=models.CASCADE)
[docs] name = models.CharField(
max_length=255,
verbose_name=_("name translation"),
help_text=_("Human-readable name."),
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language code")
)
[docs] def __str__(self):
return str("{} - {}".format(self.problem, self.language))
[docs]class ProblemStatement(models.Model):
"""Represents a file containing problem statement.
Problem may have multiple statements, for example in various languages
or formats. Formats should be detected according to filename extension
of :attr:`content`.
"""
[docs] problem = models.ForeignKey(
Problem, related_name='statements', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=6, blank=True, null=True, verbose_name=_("language code")
)
[docs] content = FileField(upload_to=make_problem_filename, verbose_name=_("content"))
@property
[docs] def filename(self):
return os.path.split(self.content.name)[1]
@property
[docs] def download_name(self):
return self.problem.short_name + self.extension
@property
[docs] def extension(self):
return os.path.splitext(self.content.name)[1].lower()
[docs] def __str__(self):
return u'%s / %s' % (self.problem.name, self.filename)
[docs]class ProblemAttachment(models.Model):
"""Represents an additional file visible to the contestant, linked to
a problem.
This may be used for things like input data for open data tasks, or for
giving users additional libraries etc.
"""
[docs] problem = models.ForeignKey(
Problem, related_name='attachments', on_delete=models.CASCADE
)
[docs] description = models.CharField(max_length=255, verbose_name=_("description"))
[docs] content = FileField(upload_to=make_problem_filename, verbose_name=_("content"))
@property
[docs] def filename(self):
return os.path.split(self.content.name)[1]
@property
[docs] def download_name(self):
return strip_num_or_hash(self.filename)
[docs] def __str__(self):
return u'%s / %s' % (self.problem.name, self.filename)
[docs]def _make_package_filename(instance, filename):
if instance.contest:
contest_name = instance.contest.id
else:
contest_name = 'no_contest'
return 'package/%s/%s' % (
contest_name,
get_valid_filename(os.path.basename(filename)),
)
[docs]package_statuses = EnumRegistry()
package_statuses.register('?', pgettext_lazy("Pending", "Pending problem package"))
package_statuses.register('OK', _("Uploaded"))
package_statuses.register('ERR', _("Error"))
[docs]TRACEBACK_STACK_LIMIT = 100
[docs]def truncate_unicode(string, length, encoding='utf-8'):
""" Truncates string to be `length` bytes long. """
encoded = string.encode(encoding)[:length]
return encoded.decode(encoding, 'ignore')
[docs]class ProblemPackage(models.Model):
"""Represents a file with data necessary for creating a
:class:`~oioioi.problems.models.Problem` instance.
"""
[docs] package_file = FileField(
upload_to=_make_package_filename, verbose_name=_("package")
)
[docs] contest = models.ForeignKey(
'contests.Contest',
null=True,
blank=True,
verbose_name=_("contest"),
on_delete=models.SET_NULL,
)
[docs] problem = models.ForeignKey(
Problem,
verbose_name=_("problem"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
[docs] created_by = models.ForeignKey(
User,
verbose_name=_("created by"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
[docs] problem_name = models.CharField(
max_length=30,
validators=[validate_slug],
verbose_name=_("problem name"),
null=True,
blank=True,
)
[docs] celery_task_id = models.CharField(max_length=50, unique=True, null=True, blank=True)
[docs] info = models.CharField(
max_length=1000, null=True, blank=True, verbose_name=_("Package information")
)
[docs] traceback = FileField(
upload_to=_make_package_filename,
verbose_name=_("traceback"),
null=True,
blank=True,
)
[docs] status = EnumField(package_statuses, default='?', verbose_name=_("status"))
[docs] creation_date = models.DateTimeField(
default=timezone.now, verbose_name=_("creation date")
)
@property
[docs] def download_name(self):
ext = split_extension(self.package_file.name)[1]
if self.problem:
return self.problem.short_name + ext
else:
filename = os.path.split(self.package_file.name)[1]
return strip_num_or_hash(filename)
[docs] class StatusSaver(object):
def __init__(self, package):
self.package_id = package.id
[docs] def __enter__(self):
pass
[docs] def __exit__(self, type, value, traceback):
package = ProblemPackage.objects.get(id=self.package_id)
if type:
package.status = 'ERR'
try:
# This will work if a PackageProcessingError was thrown
info = _(
u"Failed operation: %(name)s\n"
u"Operation description: %(desc)s\n \n"
u"Error description: %(error)r\n \n"
% dict(
name=value.raiser,
desc=value.raiser_desc,
error=value.original_exception_info[1],
)
)
type, value, _old_traceback = value.original_exception_info
except AttributeError:
info = _(
u"Failed operation unknown.\n"
u"Error description: %(error)s\n \n" % dict(error=value)
)
# Truncate error so it doesn't take up whole page in list
# view (or much space in the database).
# Full info is available in package.traceback anyway.
package.info = truncate_unicode(
info, ProblemPackage._meta.get_field('info').max_length
)
package.traceback = ContentFile(
force_str(info)
+ ''.join(
format_exception(type, value, traceback, TRACEBACK_STACK_LIMIT)
),
'traceback.txt',
)
logger.exception(
"Error processing package %s",
package.package_file.name,
extra={'omit_sentry': True},
)
else:
package.status = 'OK'
package.celery_task_id = None
package.save()
return True
[docs] def save_operation_status(self):
"""Returns a context manager to be used during the unpacking process.
The code inside the ``with`` statment is executed in a transaction.
If the code inside the ``with`` statement executes successfully,
the package ``status`` field is set to ``OK``.
If an exception is thrown, it gets logged together with the
traceback. Additionally, its value is saved in the package
``info`` field.
Lastly, if the package gets deleted from the database before
the ``with`` statement ends, a
:class:`oioioi.problems.models.ProblemPackage.DoesNotExist`
exception is thrown.
"""
@contextmanager
def manager():
with self.StatusSaver(self), transaction.atomic():
yield None
return manager()
[docs]class ProblemSite(models.Model):
"""Represents a global problem site.
Contains configuration necessary to view and submit solutions
to a :class:`~oioioi.problems.models.Problem`.
"""
[docs] problem = models.OneToOneField(Problem, on_delete=models.CASCADE)
[docs] url_key = models.CharField(max_length=40, unique=True)
[docs] def __str__(self):
return str(self.problem)
[docs]class MainProblemInstance(ProblemInstance):
[docs]class ProblemStatistics(models.Model):
[docs] problem = models.OneToOneField(
Problem, on_delete=models.CASCADE, related_name='statistics'
)
[docs] user_statistics = models.ManyToManyField(User, through='UserStatistics')
[docs] submitted = models.IntegerField(default=0, verbose_name=_("attempted solutions"))
[docs] solved = models.IntegerField(default=0, verbose_name=_("correct solutions"))
[docs] avg_best_score = models.IntegerField(default=0, verbose_name=_("average result"))
[docs] _best_score_sum = models.IntegerField(default=0)
[docs]class UserStatistics(models.Model):
[docs] problem_statistics = models.ForeignKey(ProblemStatistics, on_delete=models.CASCADE)
[docs] user = models.ForeignKey(User, on_delete=models.CASCADE)
[docs] has_submitted = models.BooleanField(default=False, verbose_name=_("user submitted"))
[docs] has_solved = models.BooleanField(default=False, verbose_name=_("user solved"))
[docs] best_score = models.IntegerField(default=0, verbose_name=_("user's best score"))
[docs]def _localized(*localized_fields):
"""Some models may have fields with language-specific data, which cannot be
translated through the normal internalization tools, as it is not
defined in the source code (e.g. names of dynamically defined items).
Decorate a class with this decorator when there exists a class that:
- has a ForeignKey to the decorated class with a related_name
of `localizations`.
- has a `language` field, and all of `localized_fields`.
The `localized_fields` can then be accessed directly through the
decorated class, and will be matched to the current language.
Be sure to use prefetch_related('localizations') if you will be
querying multiple localized model instances!
Also see: LocalizationForm
"""
def decorator(cls):
def localize(self, key):
language = get_language()
# In case prefetch_related('localizations') was done don't want to
# use filter to avoid database queries. If it wasn't - querying one
# language vs all languages is not too much of a difference anyway.
for localization in self.localizations.all():
if localization.language == language:
return getattr(localization, key)
return None
def __getattr__(self, key):
if key in self.localized_fields:
return self.localize(key)
else:
raise AttributeError(
"'{}' object has no attribute '{}'".format(cls.__name__, key)
)
cls.localized_fields = localized_fields
cls.localize = localize
cls.__getattr__ = __getattr__
return cls
return decorator
@_localized('short_name', 'full_name', 'description')
[docs]class OriginTag(models.Model):
"""OriginTags are used along with OriginInfoCategories and OriginInfoValue
to give information about the problem's origin. OriginTags themselves
represent general information about a problem's origin, whereas
OriginInfoValues grouped under OriginInfoCategories represent more
specific information. A Problem should probably not have more than one
OriginTag, and should probably have one OriginInfoValue for each
category.
See also: OriginInfoCategory, OriginInfoValue
"""
[docs] name = models.CharField(
max_length=20,
validators=(validate_origintag,),
verbose_name=_("name"),
help_text=_(
"Short, searchable name consisting only of lowercase letters, numbers, "
"and hyphens.<br>"
"This should refer to general origin, i.e. a particular contest, "
"competition, programming camp, etc.<br>"
"This will be displayed verbatim in the Problemset."
),
)
[docs] problems = models.ManyToManyField(
Problem,
blank=True,
verbose_name=_("problems"),
help_text=_("Selected problems will be tagged with this tag.<br>"),
)
[docs] def __str__(self):
return str(self.name)
[docs]class OriginTagLocalization(models.Model):
[docs] origin_tag = models.ForeignKey(
OriginTag, related_name='localizations', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language")
)
[docs] full_name = models.CharField(
max_length=255,
verbose_name=_("full name"),
help_text=_(
"Full, official name of the contest, competition, programming camp, etc. "
"which this tag represents."
),
)
[docs] short_name = models.CharField(
max_length=32,
blank=True,
verbose_name=_("abbreviation"),
help_text=_("(optional) Official abbreviation of the full name."),
)
[docs] description = models.TextField(
blank=True,
verbose_name=_("description"),
help_text=_(
"(optional) Longer description which Will be displayed in the "
"Task Archive next to the name."
),
)
[docs] def __str__(self):
return str("{} - {}".format(self.origin_tag, self.language))
@_localized('full_name')
[docs]class OriginInfoCategory(models.Model):
"""This class represents a category of information, which further specifies
what its parent_tag is already telling about the origin. It doesn't do
much by itself and is instead used to group OriginInfoValues by category
See also: OriginTag, OriginInfoValue
"""
[docs] parent_tag = models.ForeignKey(
OriginTag,
related_name='info_categories',
on_delete=models.CASCADE,
verbose_name=_("parent tag"),
help_text=_(
"This category will be a possible category of information for problems "
"tagged with the selected tag."
),
)
[docs] name = models.CharField(
max_length=20,
validators=(validate_origintag,),
verbose_name=_("name"),
help_text=_(
"Type of information within this category. Short, searchable name "
"consisting of only lowercase letters, numbers, and hyphens.<br>"
"Examples: 'year', 'edition', 'stage', 'day'."
),
)
[docs] order = models.IntegerField(
blank=True,
null=True,
verbose_name=_("grouping order"),
help_text=_(
"Sometimes the parent_tag relationship by itself is not enough to convey "
"full information about the information hierarchy.<br>"
"Some categories are broader, and others are more specific. More specific "
"tags should probably be visually grouped after/under broader tags when "
"displayed.<br>The broader the category is the lower grouping order it "
"should have - e.g. 'year' should have lower order than 'round'.<br>"
"Left blank means 'infinity', which usually means that this category will "
"not be used for grouping - some categories could be too specific (e.g. "
"when grouping would result in 'groups' of single Problems)."
),
)
[docs] def __str__(self):
return str("{}_{}".format(self.parent_tag, self.name))
[docs]class OriginInfoCategoryLocalization(models.Model):
[docs] origin_info_category = models.ForeignKey(
OriginInfoCategory, related_name='localizations', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language")
)
[docs] full_name = models.CharField(
max_length=32,
verbose_name=_("name translation"),
help_text=_("Human-readable name."),
)
[docs] def __str__(self):
return str("{} - {}".format(self.origin_info_category, self.language))
@_localized('full_value')
[docs]class OriginInfoValue(models.Model):
"""This class represents additional information, further specifying
what its parent_tag is already telling about the origin. Each
OriginInfoValue has a category, in which it should be unique, and
problems should only have one OriginInfoValue within any category.
See alse: OriginTag, OriginInfoCategory
"""
[docs] parent_tag = models.ForeignKey(
OriginTag,
related_name='info_values',
on_delete=models.CASCADE,
verbose_name=_("parent tag"),
help_text=_(
"If an OriginTag T is a parent of OriginInfoValue V, the presence of V "
"on a Problem implies the presence of T.<br>"
"OriginInfoValues with the same values are also treated as distinct if "
"they have different parents.<br>"
"You can think of this distinction as prepending an OriginTag.name prefix "
"to an OriginInfoValue.value<br>"
"e.g. for OriginTag 'pa' and OriginInfoValue '2011', this unique "
"OriginInfoValue.name would be 'pa_2011'"
),
)
[docs] category = models.ForeignKey(
OriginInfoCategory,
related_name='values',
on_delete=models.CASCADE,
verbose_name=_("category"),
help_text=_(
"This information should be categorized under the selected category."
),
)
[docs] value = models.CharField(
max_length=32,
validators=(validate_origintag,),
verbose_name=_("value"),
help_text=_(
"Short, searchable value consisting of only lowercase letters and "
"numbers.<br>"
"This will be displayed verbatim in the Problemset - it must be unique "
"within its parent tag.<br>"
"Examples: for year: '2011', but for round: 'r1' (just '1' for round "
"would be ambiguous)."
),
)
[docs] order = models.IntegerField(
default=0,
verbose_name=_("display order"),
help_text=_("Order in which this value will be sorted within its category."),
)
[docs] problems = models.ManyToManyField(
Problem,
blank=True,
verbose_name=_("problems"),
help_text=_(
"Select problems described by this value. They will also be tagged with "
"the parent tag.<br>"
),
)
@property
[docs] def name(self):
# Should be unique due to unique constraints on value and parent_tag.name
return str('{}_{}'.format(self.parent_tag, self.value))
@property
[docs] def full_name(self):
return str(
u'{} {}'.format(self.parent_tag.full_name, self.full_value)
)
[docs] def __str__(self):
return str(self.name)
[docs]class OriginInfoValueLocalization(models.Model):
[docs] origin_info_value = models.ForeignKey(
OriginInfoValue, related_name='localizations', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language")
)
[docs] full_value = models.CharField(
max_length=64,
verbose_name=_("translated value"),
help_text=_("Human-readable value."),
)
[docs] def __str__(self):
return str("{} - {}".format(self.origin_info_value, self.language))
@_localized('full_name')
[docs]class DifficultyTag(models.Model):
[docs] name = models.CharField(
max_length=20,
unique=True,
verbose_name=_("name"),
null=False,
blank=False,
validators=[
validators.MinLengthValidator(3),
validators.MaxLengthValidator(20),
validators.validate_slug,
],
)
[docs] problems = models.ManyToManyField(Problem, through='DifficultyTagThrough')
[docs] def __str__(self):
return str(self.name)
[docs]class DifficultyTagThrough(models.Model):
[docs] problem = models.OneToOneField(Problem, on_delete=models.CASCADE)
[docs] tag = models.ForeignKey(DifficultyTag, on_delete=models.CASCADE)
# This string will be visible in an admin form.
[docs] def __str__(self):
return str(self.tag.name)
[docs]class DifficultyTagLocalization(models.Model):
[docs] difficulty_tag = models.ForeignKey(
DifficultyTag, related_name='localizations', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language")
)
[docs] full_name = models.CharField(
max_length=32,
verbose_name=_("name translation"),
help_text=_("Human-readable name."),
)
[docs] def __str__(self):
return str("{} - {}".format(self.difficulty_tag, self.language))
[docs]class DifficultyTagProposal(models.Model):
[docs] problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
[docs] tag = models.ForeignKey(DifficultyTag, on_delete=models.CASCADE, null=True)
[docs] user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
[docs] def __str__(self):
return str(self.problem.name) + u' -- ' + str(self.tag.name)
@_localized('full_name')
[docs]class AlgorithmTag(models.Model):
[docs] name = models.CharField(
max_length=20,
unique=True,
verbose_name=_("name"),
null=False,
blank=False,
validators=[
validators.MinLengthValidator(2),
validators.MaxLengthValidator(20),
validators.validate_slug,
],
)
[docs] problems = models.ManyToManyField(Problem, through='AlgorithmTagThrough')
[docs] def __str__(self):
return str(self.name)
[docs]class AlgorithmTagThrough(models.Model):
[docs] problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
[docs] tag = models.ForeignKey(AlgorithmTag, on_delete=models.CASCADE)
# This string will be visible in an admin form.
[docs] def __str__(self):
return str(self.tag.name)
[docs]class AlgorithmTagLocalization(models.Model):
[docs] algorithm_tag = models.ForeignKey(
AlgorithmTag, related_name='localizations', on_delete=models.CASCADE
)
[docs] language = models.CharField(
max_length=2, choices=settings.LANGUAGES, verbose_name=_("language")
)
[docs] full_name = models.CharField(
max_length=50,
verbose_name=_("name translation"),
help_text=_("Human-readable name."),
)
[docs] def __str__(self):
return str("{} - {}".format(self.algorithm_tag, self.language))
[docs]class AlgorithmTagProposal(models.Model):
[docs] problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
[docs] tag = models.ForeignKey(AlgorithmTag, on_delete=models.CASCADE)
[docs] user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
[docs] def __str__(self):
return str(self.problem.name) + u' -- ' + str(self.tag.name)