diff --git a/.github/workflows/formatter.yml b/.github/workflows/formatter.yml index e930f84..175a59c 100644 --- a/.github/workflows/formatter.yml +++ b/.github/workflows/formatter.yml @@ -19,5 +19,5 @@ jobs: pip install isort black - name: Run isort and black run: | - isort -c . - black --check . + isort -c src tests + black --check src tests diff --git a/.gitignore b/.gitignore index 36815e6..120528a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ htmlcov coverage.xml # macOS -.DS_Store \ No newline at end of file +.DS_Store + +# Files from Django db +/sio3pack \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8dcf968..3d16678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,9 @@ build-backend = "setuptools.build_meta" [tool.isort] line_length = 120 -multi_line_output = 1 +multi_line_output = 3 include_trailing_comma = true +skip = ["migrations", ".venv"] [tool.black] line_length = 120 diff --git a/src/sio3pack/packages/package/django/__init__.py b/src/sio3pack/django/common/__init__.py similarity index 100% rename from src/sio3pack/packages/package/django/__init__.py rename to src/sio3pack/django/common/__init__.py diff --git a/src/sio3pack/django/common/handler.py b/src/sio3pack/django/common/handler.py new file mode 100644 index 0000000..19847bf --- /dev/null +++ b/src/sio3pack/django/common/handler.py @@ -0,0 +1,66 @@ +from typing import Type + +from django.core.files import File +from django.db import transaction + +from sio3pack.django.common.models import SIO3Package, SIO3PackModelSolution, SIO3PackNameTranslation, SIO3PackStatement +from sio3pack.files.local_file import LocalFile +from sio3pack.packages.exceptions import ImproperlyConfigured, PackageAlreadyExists + + +class DjangoHandler: + def __init__(self, package: Type["Package"], problem_id: int): + self.package = package + self.problem_id = problem_id + self.db_package = None + + @transaction.atomic + def save_to_db(self): + """ + Save the package to the database. + """ + if SIO3Package.objects.filter(problem_id=self.problem_id).exists(): + raise PackageAlreadyExists(self.problem_id) + + self.db_package = SIO3Package.objects.create( + problem_id=self.problem_id, + short_name=self.package.short_name, + full_name=self.package.full_name, + ) + + self._save_translated_titles() + self._save_model_solutions() + self._save_problem_statements() + + def _save_translated_titles(self): + """ + Save the translated titles to the database. + """ + for lang, title in self.package.get_titles().items(): + SIO3PackNameTranslation.objects.create( + package=self.db_package, + language=lang, + name=title, + ) + + def _save_model_solutions(self): + for order, solution in enumerate(self.package.get_model_solutions()): + instance = SIO3PackModelSolution( + package=self.db_package, + name=solution.filename, + order_key=order, + ) + instance.source_file.save(solution.filename, File(open(solution.path, "rb"))) + + def _save_problem_statements(self): + def _add_statement(language: str, statement: LocalFile): + instance = SIO3PackStatement( + package=self.db_package, + language=language, + ) + instance.content.save(statement.filename, File(open(statement.path, "rb"))) + + if self.package.get_statement(): + _add_statement("", self.package.get_statement()) + for lang, statement in self.package.get_statements().items(): + _add_statement(lang, statement) diff --git a/src/sio3pack/django/common/migrations/0001_initial.py b/src/sio3pack/django/common/migrations/0001_initial.py new file mode 100644 index 0000000..6c2b137 --- /dev/null +++ b/src/sio3pack/django/common/migrations/0001_initial.py @@ -0,0 +1,287 @@ +# Generated by Django 5.1.4 on 2024-12-12 10:46 + +import django.db.models.deletion +import sio3pack.django.common.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SIO3Package", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("problem_id", models.IntegerField()), + ("short_name", models.CharField(max_length=30, verbose_name="short name")), + ("full_name", models.CharField(default="", max_length=255, verbose_name="full name")), + ], + ), + migrations.CreateModel( + name="SIO3PackModelSolution", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, verbose_name="name")), + ( + "source_file", + models.FileField( + upload_to=sio3pack.django.common.models.make_problem_filename, verbose_name="source file" + ), + ), + ("order_key", models.IntegerField(default=0)), + ("package", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), + ], + ), + migrations.CreateModel( + name="SIO3PackStatement", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "language", + models.CharField( + blank=True, + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("ug", "Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + max_length=7, + null=True, + verbose_name="language code", + ), + ), + ( + "content", + models.FileField( + upload_to=sio3pack.django.common.models.make_problem_filename, verbose_name="content" + ), + ), + ("package", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), + ], + options={ + "verbose_name": "problem statement", + "verbose_name_plural": "problem statements", + }, + ), + migrations.CreateModel( + name="SIO3PackNameTranslation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "language", + models.CharField( + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("ug", "Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + max_length=7, + verbose_name="language code", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="name translation")), + ("package", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), + ], + options={ + "verbose_name": "sio3pack's name translation", + "verbose_name_plural": "sio3pack's name translations", + "unique_together": {("package", "language")}, + }, + ), + ] diff --git a/src/sio3pack/django/common/migrations/__init__.py b/src/sio3pack/django/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sio3pack/django/common/models.py b/src/sio3pack/django/common/models.py new file mode 100644 index 0000000..7198414 --- /dev/null +++ b/src/sio3pack/django/common/models.py @@ -0,0 +1,94 @@ +import os + +from django.conf import settings +from django.db import models +from django.utils.text import get_valid_filename +from django.utils.translation import gettext_lazy as _ + +try: + from oioioi.filetracker.fields import FileField +except ImportError: + FileField = models.FileField + + +def make_problem_filename(instance, filename): + if not isinstance(instance, SIO3Package): + try: + instance = instance.package + except AttributeError: + raise ValueError( + f"make_problem_filename used on an object {type(instance)} which does not have " f"a package attribute" + ) + return f"sio3pack/{instance.problem_id}/{get_valid_filename(filename)}" + + +class SIO3Package(models.Model): + """ + A generic package type. + """ + + problem_id = models.IntegerField() + short_name = models.CharField(max_length=30, verbose_name=_("short name")) + full_name = models.CharField(max_length=255, default="", verbose_name=_("full name")) + + def __str__(self): + return f"" + + +class SIO3PackNameTranslation(models.Model): + """ + Model to store translated task's title. + """ + + package = models.ForeignKey(SIO3Package, on_delete=models.CASCADE) + language = models.CharField(max_length=7, choices=settings.LANGUAGES, verbose_name=_("language code")) + name = models.CharField(max_length=255, verbose_name=_("name translation")) + + def __str__(self): + return f"" + + class Meta: + verbose_name = _("sio3pack's name translation") + verbose_name_plural = _("sio3pack's name translations") + unique_together = ("package", "language") + + +class SIO3PackModelSolution(models.Model): + package = models.ForeignKey(SIO3Package, on_delete=models.CASCADE) + name = models.CharField(max_length=255, verbose_name=_("name")) + source_file = FileField(upload_to=make_problem_filename, verbose_name=_("source file")) + order_key = models.IntegerField(default=0) + + def __str__(self): + return f"" + + @property + def short_name(self): + return self.name.rsplit(".", 1)[0] + + +class SIO3PackStatement(models.Model): + package = models.ForeignKey(SIO3Package, on_delete=models.CASCADE) + language = models.CharField( + max_length=7, blank=True, null=True, choices=settings.LANGUAGES, verbose_name=_("language code") + ) + content = FileField(upload_to=make_problem_filename, verbose_name=_("content")) + + @property + def filename(self): + return os.path.split(self.content.name)[1] + + @property + def download_name(self): + return self.package.short_name + self.extension + + @property + def extension(self): + return os.path.splitext(self.content.name)[1].lower() + + def __str__(self): + return f"" + + class Meta(object): + verbose_name = _("problem statement") + verbose_name_plural = _("problem statements") diff --git a/src/sio3pack/django/sinolpack/handler.py b/src/sio3pack/django/sinolpack/handler.py index 0ae67e7..62116b2 100644 --- a/src/sio3pack/django/sinolpack/handler.py +++ b/src/sio3pack/django/sinolpack/handler.py @@ -1,17 +1,67 @@ -from sio3pack.django.sinolpack.models import SinolpackPackage -from sio3pack.packages.exceptions import PackageAlreadyExists -from sio3pack.packages.package.django.handler import DjangoHandler +from typing import Type + +import yaml +from django.core.files import File +from django.db import transaction + +from sio3pack.django.common.handler import DjangoHandler +from sio3pack.django.sinolpack.models import ( + SinolpackAdditionalFile, + SinolpackAttachment, + SinolpackConfig, + SinolpackModelSolution, +) class SinolpackDjangoHandler(DjangoHandler): + + def __init__(self, package: Type["Package"], problem_id: int): + super().__init__(package, problem_id) + self.db_package = None + + @transaction.atomic def save_to_db(self): """ Save the package to the database. """ - if SinolpackPackage.objects.filter(problem_id=self.problem_id).exists(): - raise PackageAlreadyExists(self.problem_id) + super(SinolpackDjangoHandler, self).save_to_db() + + self._save_config() + self._save_additional_files() + self._save_attachments() - SinolpackPackage.objects.create( - problem_id=self.problem_id, - short_name=self.package.short_name, + def _save_config(self): + """ + Save the ``config.yml`` to the database. + """ + config = self.package.get_config() + SinolpackConfig.objects.create( + package=self.db_package, + config=yaml.dump(config), ) + + def _save_model_solutions(self): + for order, (kind, solution) in enumerate(self.package.get_model_solutions()): + instance = SinolpackModelSolution( + package=self.db_package, + name=solution.filename, + kind_name=kind.value, + order_key=order, + ) + instance.source_file.save(solution.filename, File(open(solution.path, "rb"))) + + def _save_additional_files(self): + for file in self.package.get_additional_files(): + instance = SinolpackAdditionalFile( + package=self.db_package, + name=file.filename, + ) + instance.file.save(file.filename, File(open(file.path, "rb"))) + + def _save_attachments(self): + for attachment in self.package.get_attachments(): + instance = SinolpackAttachment( + package=self.db_package, + description=attachment.filename, + ) + instance.content.save(attachment.filename, File(open(attachment.path, "rb"))) diff --git a/src/sio3pack/django/sinolpack/migrations/0001_initial.py b/src/sio3pack/django/sinolpack/migrations/0001_initial.py index c342290..2816b3d 100644 --- a/src/sio3pack/django/sinolpack/migrations/0001_initial.py +++ b/src/sio3pack/django/sinolpack/migrations/0001_initial.py @@ -1,5 +1,7 @@ -# Generated by Django 5.1.3 on 2024-12-01 17:22 +# Generated by Django 5.1.4 on 2024-12-12 10:46 +import django.db.models.deletion +import sio3pack.django.common.models from django.db import migrations, models @@ -7,15 +9,80 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("common", "0001_initial"), + ] operations = [ migrations.CreateModel( - name="SinolpackPackage", + name="SinolpackModelSolution", + fields=[ + ( + "sio3packmodelsolution_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="common.sio3packmodelsolution", + ), + ), + ( + "kind_name", + models.CharField( + choices=[("", "NORMAL"), ("s", "SLOW"), ("b", "INCORRECT")], max_length=1, verbose_name="kind" + ), + ), + ], + bases=("common.sio3packmodelsolution",), + ), + migrations.CreateModel( + name="SinolpackAdditionalFile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=30, verbose_name="name")), + ( + "file", + models.FileField( + upload_to=sio3pack.django.common.models.make_problem_filename, verbose_name="file" + ), + ), + ("package", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), + ], + options={ + "verbose_name": "additional file", + "verbose_name_plural": "additional files", + }, + ), + migrations.CreateModel( + name="SinolpackAttachment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("description", models.CharField(max_length=255, verbose_name="description")), + ( + "content", + models.FileField( + upload_to=sio3pack.django.common.models.make_problem_filename, verbose_name="file" + ), + ), + ("package", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), + ], + options={ + "verbose_name": "attachment", + "verbose_name_plural": "attachments", + }, + ), + migrations.CreateModel( + name="SinolpackConfig", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("problem_id", models.IntegerField()), - ("short_name", models.CharField(max_length=100)), + ("config", models.TextField(verbose_name="config")), + ("package", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="common.sio3package")), ], + options={ + "verbose_name": "sinolpack's configuration", + "verbose_name_plural": "sinolpack's configurations", + }, ), ] diff --git a/src/sio3pack/django/sinolpack/models.py b/src/sio3pack/django/sinolpack/models.py index 961494c..9eead00 100644 --- a/src/sio3pack/django/sinolpack/models.py +++ b/src/sio3pack/django/sinolpack/models.py @@ -1,10 +1,80 @@ +import os + +import yaml from django.db import models +from django.utils.translation import gettext_lazy as _ + +from sio3pack.django.common.models import SIO3Package, SIO3PackModelSolution, make_problem_filename +from sio3pack.packages.sinolpack.enums import ModelSolutionKind + +try: + from oioioi.filetracker.fields import FileField +except ImportError: + FileField = models.FileField -class SinolpackPackage(models.Model): +class SinolpackConfig(models.Model): """ - A package for the sinolpack package type. + Model to store ``config.yml`` present in Sinolpack packages. """ - problem_id = models.IntegerField() - short_name = models.CharField(max_length=100) + package = models.OneToOneField(SIO3Package, on_delete=models.CASCADE) + config = models.TextField(verbose_name=_("config")) + + @property + def parsed_config(self): + if not self.config: + return {} + return yaml.safe_load(self.config) + + def __str__(self): + return f"" + + class Meta: + verbose_name = _("sinolpack's configuration") + verbose_name_plural = _("sinolpack's configurations") + + +class SinolpackModelSolution(SIO3PackModelSolution): + kind_name = models.CharField(max_length=1, choices=ModelSolutionKind.django_choices(), verbose_name=_("kind")) + + def __str__(self): + return f"" + + @property + def short_name(self): + return self.name.rsplit(".", 1)[0] + + @property + def kind(self): + return ModelSolutionKind(self.kind_name) + + +class SinolpackAdditionalFile(models.Model): + package = models.ForeignKey(SIO3Package, on_delete=models.CASCADE) + name = models.CharField(max_length=30, verbose_name=_("name")) + file = FileField(upload_to=make_problem_filename, verbose_name=_("file")) + + def __str__(self): + return f"" + + class Meta: + verbose_name = _("additional file") + verbose_name_plural = _("additional files") + + +class SinolpackAttachment(models.Model): + package = models.ForeignKey(SIO3Package, on_delete=models.CASCADE) + description = models.CharField(max_length=255, verbose_name=_("description")) + content = FileField(upload_to=make_problem_filename, verbose_name=_("file")) + + @property + def filename(self): + return os.path.split(self.content.name)[1] + + def __str__(self): + return f"" + + class Meta(object): + verbose_name = _("attachment") + verbose_name_plural = _("attachments") diff --git a/src/sio3pack/files/local_file.py b/src/sio3pack/files/local_file.py index 43b1c15..59cd75c 100644 --- a/src/sio3pack/files/local_file.py +++ b/src/sio3pack/files/local_file.py @@ -18,7 +18,7 @@ def get_file_matching_extension(cls, dir: str, filename: str, extensions: list[s :return: The file object. """ for ext in extensions: - path = os.path.join(dir, filename + ext) + path = os.path.join(dir, filename + "." + ext) if os.path.exists(path): return cls(path) raise FileNotFoundError diff --git a/src/sio3pack/packages/package/django/handler.py b/src/sio3pack/packages/package/handler.py similarity index 53% rename from src/sio3pack/packages/package/django/handler.py rename to src/sio3pack/packages/package/handler.py index e838fca..ff535c5 100644 --- a/src/sio3pack/packages/package/django/handler.py +++ b/src/sio3pack/packages/package/handler.py @@ -1,14 +1,6 @@ -from typing import Type - from sio3pack.packages.exceptions import ImproperlyConfigured -class DjangoHandler: - def __init__(self, package: Type["Package"], problem_id: int): - self.package = package - self.problem_id = problem_id - - class NoDjangoHandler: def __call__(self, *args, **kwargs): raise ImproperlyConfigured("sio3pack is not installed with Django support.") diff --git a/src/sio3pack/packages/package/model.py b/src/sio3pack/packages/package/model.py index 9438957..1f31da4 100644 --- a/src/sio3pack/packages/package/model.py +++ b/src/sio3pack/packages/package/model.py @@ -5,7 +5,7 @@ from sio3pack.files import File from sio3pack.graph import Graph, GraphOperation from sio3pack.packages.exceptions import UnknownPackageType -from sio3pack.packages.package.django.handler import NoDjangoHandler +from sio3pack.packages.package.handler import NoDjangoHandler from sio3pack.test import Test from sio3pack.utils.archive import Archive from sio3pack.utils.classinit import RegisteredSubclassesBase diff --git a/src/sio3pack/packages/sinolpack/enums.py b/src/sio3pack/packages/sinolpack/enums.py index 0f44533..5cd91cb 100644 --- a/src/sio3pack/packages/sinolpack/enums.py +++ b/src/sio3pack/packages/sinolpack/enums.py @@ -2,9 +2,9 @@ class ModelSolutionKind(Enum): - NORMAL = 0 - SLOW = 1 - INCORRECT = 2 + NORMAL = "" + SLOW = "s" + INCORRECT = "b" @classmethod def from_regex(cls, group): @@ -15,3 +15,11 @@ def from_regex(cls, group): if group == "b": return cls.INCORRECT raise ValueError(f"Invalid model solution kind: {group}") + + @classmethod + def all(cls): + return [cls.NORMAL, cls.SLOW, cls.INCORRECT] + + @classmethod + def django_choices(cls): + return [(kind.value, kind.name) for kind in cls.all()] diff --git a/src/sio3pack/packages/sinolpack/model.py b/src/sio3pack/packages/sinolpack/model.py index 9d31864..c27d8e9 100644 --- a/src/sio3pack/packages/sinolpack/model.py +++ b/src/sio3pack/packages/sinolpack/model.py @@ -1,10 +1,12 @@ import os import re import tempfile +from typing import Any import yaml from sio3pack import LocalFile +from sio3pack.files import File from sio3pack.graph import Graph, GraphManager, GraphOperation from sio3pack.packages.exceptions import ImproperlyConfigured from sio3pack.packages.package import Package @@ -167,6 +169,12 @@ def _process_package(self): # self.graph_manager = self._default_graph_manager() pass + def get_config(self) -> dict[str, Any]: + """ + Returns the configuration of the problem. + """ + return self.config + def _process_config_yml(self): """ Process the config.yml file. If it exists, it will be loaded into the config attribute. @@ -199,6 +207,20 @@ def _detect_full_name(self): except FileNotFoundError: pass + def get_titles(self) -> dict[str, str]: + """ + Returns a dictionary of problem titles, where keys are language codes and values are titles. + """ + return self.lang_titles + + def get_title(self, lang: str | None = None) -> str: + """ + Returns the problem title for a given language code. + """ + if lang is None: + return self.full_name + return self.lang_titles.get(lang, self.full_name) + def _detect_full_name_translations(self): """Creates problem's full name translations from the ``config.yml`` (keys matching the pattern ``title_[a-z]{2}``, where ``[a-z]{2}`` represents @@ -224,9 +246,9 @@ def get_model_solution_regex(self): Returns the regex used to determine model solutions. """ extensions = self.get_submittable_extensions() - return rf"^{self.short_name}[0-9]*([bs]?)[0-9]*(_.*)?\.(" + "|".join(extensions) + ")" + return rf"^{self.short_name}[0-9]*([bs]?)[0-9]*(_.*)?\.({'|'.join(extensions)})" - def _get_model_solutions(self) -> list[tuple[ModelSolutionKind, str]]: + def _get_model_solutions(self) -> list[tuple[ModelSolutionKind, LocalFile]]: """ Returns a list of model solutions, where each element is a tuple of model solution kind and filename. """ @@ -238,23 +260,36 @@ def _get_model_solutions(self) -> list[tuple[ModelSolutionKind, str]]: for file in os.listdir(self.get_prog_dir()): match = re.match(regex, file) if match and os.path.isfile(os.path.join(self.get_prog_dir(), file)): + file = LocalFile(os.path.join(self.get_prog_dir(), file)) model_solutions.append((ModelSolutionKind.from_regex(match.group(1)), file)) return model_solutions def sort_model_solutions( - self, model_solutions: list[tuple[ModelSolutionKind, str]] - ) -> list[tuple[ModelSolutionKind, str]]: + self, model_solutions: list[tuple[ModelSolutionKind, LocalFile]] + ) -> list[tuple[ModelSolutionKind, LocalFile]]: """ Sorts model solutions by kind. """ def sort_key(model_solution): - kind, name = model_solution - return kind.value, naturalsort_key(name[: name.index(".")]) + kind, file = model_solution + return kind.value, naturalsort_key(file.filename[: file.filename.index(".")]) return list(sorted(model_solutions, key=sort_key)) + def get_model_solutions(self) -> list[tuple[ModelSolutionKind, LocalFile]]: + """ + Returns a list of model solutions, where each element is a tuple of model solution kind and filename. + """ + return self.model_solutions + + def get_additional_files(self) -> list[LocalFile]: + """ + Returns a list of additional files. + """ + return self.additional_files + def _process_prog_files(self): """ Process all files in the problem's program directory that are used. @@ -278,14 +313,26 @@ def _process_prog_files(self): for file in ("ingen", "inwer", "soc", "chk"): try: self.additional_files.append( - LocalFile.get_file_matching_extension( - self.get_prog_dir(), self.short_name + file, extensions - ).filename + LocalFile.get_file_matching_extension(self.get_prog_dir(), self.short_name + file, extensions) ) self.special_files[file] = True except FileNotFoundError: self.special_files[file] = False + def get_statements(self) -> dict[str, File]: + """ + Returns a dictionary of problem statements, where keys are language codes and values are files. + """ + return self.lang_statements + + def get_statement(self, lang: str | None = None) -> File: + """ + Returns the problem statement for a given language code. + """ + if lang is None: + return self.statement + return self.lang_statements.get(lang, None) + def _process_statements(self): """ Creates a problem statement from html or pdf source. @@ -294,6 +341,8 @@ def _process_statements(self): If `USE_SINOLPACK_MAKEFILES` is set to True in the OIOIOI settings, the pdf file will be compiled from a LaTeX source. """ + self.statement = None + self.lang_statements = {} docdir = self.get_doc_dir() if not os.path.exists(docdir): return @@ -302,7 +351,6 @@ def _process_statements(self): f"-{lang}" for lang, _ in self._get_from_django_settings("LANGUAGES", [("en", "English"), ("pl", "Polish")]) ] - self.lang_statements = {} for lang in lang_prefs: try: htmlzipfile = self.get_in_doc_dir(f"{self.short_name}zad{lang}.html.zip") @@ -333,13 +381,20 @@ def _process_statements(self): except FileNotFoundError: pass + def get_attachments(self) -> list[LocalFile]: + """ + Returns a list of attachments. + """ + return self.attachments + def _process_attachments(self): """ """ attachments_dir = self.get_attachments_dir() if not os.path.isdir(attachments_dir): + self.attachments = [] return self.attachments = [ - attachment + LocalFile(os.path.join(attachments_dir, attachment)) for attachment in os.listdir(attachments_dir) if os.path.isfile(os.path.join(attachments_dir, attachment)) ] diff --git a/tests/test_django/test_django/settings.py b/tests/test_django/test_django/settings.py index 68bec3e..f93df2b 100644 --- a/tests/test_django/test_django/settings.py +++ b/tests/test_django/test_django/settings.py @@ -38,6 +38,7 @@ "django.contrib.messages", "django.contrib.staticfiles", # Here add all sio3pack apps + "sio3pack.django.common", "sio3pack.django.sinolpack", ] diff --git a/tests/test_django/test_sio3pack/test_sinolpack.py b/tests/test_django/test_sio3pack/test_sinolpack.py index 6516081..77f0789 100644 --- a/tests/test_django/test_sio3pack/test_sinolpack.py +++ b/tests/test_django/test_sio3pack/test_sinolpack.py @@ -1,22 +1,78 @@ import pytest import sio3pack -from sio3pack.django.sinolpack.models import SinolpackPackage +from sio3pack.django.common.models import SIO3Package +from sio3pack.django.sinolpack.models import SinolpackAdditionalFile, SinolpackConfig, SinolpackModelSolution from sio3pack.packages import Sinolpack from tests.fixtures import Compression, PackageInfo, get_archived_package +from tests.utils import assert_contents_equal -@pytest.mark.django_db -@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) -def test_simple(get_archived_package): - package_info: PackageInfo = get_archived_package() +def _save_and_test_simple(package_info: PackageInfo) -> tuple[Sinolpack, SIO3Package]: assert package_info.type == "sinolpack" package = sio3pack.from_file(package_info.path) assert isinstance(package, Sinolpack) package.save_to_db(1) - assert SinolpackPackage.objects.filter(problem_id=1).exists() - db_package = SinolpackPackage.objects.get(problem_id=1) + assert SIO3Package.objects.filter(problem_id=1).exists() + db_package = SIO3Package.objects.get(problem_id=1) assert db_package.short_name == package.short_name + return package, db_package + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_simple(get_archived_package): + package_info: PackageInfo = get_archived_package() + package, db_package = _save_and_test_simple(package_info) + + assert package.get_title() == db_package.full_name with pytest.raises(sio3pack.PackageAlreadyExists): package.save_to_db(1) + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_config(get_archived_package): + package_info: PackageInfo = get_archived_package() + package, db_package = _save_and_test_simple(package_info) + config = SinolpackConfig.objects.get(package=db_package) + assert package.config == config.parsed_config + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_translated_titles(get_archived_package): + pytest.skip("Not implemented") + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_model_solutions(get_archived_package): + package_info: PackageInfo = get_archived_package() + package, db_package = _save_and_test_simple(package_info) + + model_solutions = package.get_model_solutions() + db_model_solutions = SinolpackModelSolution.objects.filter(package=db_package) + assert len(model_solutions) == db_model_solutions.count() + for order, (kind, solution) in enumerate(model_solutions): + ms = db_model_solutions.get(order_key=order) + assert ms.name == solution.filename + assert ms.kind == kind + assert_contents_equal(ms.source_file.read().decode("utf-8"), solution.read()) + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_additional_files(get_archived_package): + package_info: PackageInfo = get_archived_package() + package, db_package = _save_and_test_simple(package_info) + additional_files = package.get_additional_files() + db_additional_files = SinolpackAdditionalFile.objects.filter(package=db_package) + assert db_additional_files.count() == 1 + assert len(additional_files) == db_additional_files.count() + + for file in additional_files: + af = db_additional_files.get(name=file.filename) + assert_contents_equal(af.file.read().decode("utf-8"), file.read()) + assert af.name == file.filename diff --git a/tests/test_packages/simple/prog/abc.cpp b/tests/test_packages/simple/prog/abc.cpp new file mode 100644 index 0000000..98eaacf --- /dev/null +++ b/tests/test_packages/simple/prog/abc.cpp @@ -0,0 +1,3 @@ +#include + +int main() {} diff --git a/tests/test_packages/simple/prog/abc3.cpp b/tests/test_packages/simple/prog/abc3.cpp new file mode 100644 index 0000000..98eaacf --- /dev/null +++ b/tests/test_packages/simple/prog/abc3.cpp @@ -0,0 +1,3 @@ +#include + +int main() {} diff --git a/tests/test_packages/simple/prog/abcb1.cpp b/tests/test_packages/simple/prog/abcb1.cpp new file mode 100644 index 0000000..98eaacf --- /dev/null +++ b/tests/test_packages/simple/prog/abcb1.cpp @@ -0,0 +1,3 @@ +#include + +int main() {} diff --git a/tests/test_packages/simple/prog/abcchk.cpp b/tests/test_packages/simple/prog/abcchk.cpp new file mode 100644 index 0000000..05437c5 --- /dev/null +++ b/tests/test_packages/simple/prog/abcchk.cpp @@ -0,0 +1 @@ +main(){} diff --git a/tests/test_packages/simple/prog/abcs20.cpp b/tests/test_packages/simple/prog/abcs20.cpp new file mode 100644 index 0000000..98eaacf --- /dev/null +++ b/tests/test_packages/simple/prog/abcs20.cpp @@ -0,0 +1,3 @@ +#include + +int main() {} diff --git a/tests/utils.py b/tests/utils.py index e69de29..fc057ba 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -0,0 +1,8 @@ +def assert_contents_equal(content1, content2): + if isinstance(content1, bytes): + content1 = content1.decode("utf-8") + if isinstance(content2, bytes): + content2 = content2.decode("utf-8") + content1 = content1.replace("\r\n", "\n") + content2 = content2.replace("\r\n", "\n") + assert content1 == content2