Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactive problems via I/O #378

Merged
merged 15 commits into from
Jun 29, 2024
Merged
1 change: 1 addition & 0 deletions oioioi/default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@
'oioioi.workers',
'oioioi.quizzes',
'oioioi._locale',
'oioioi.interactive',

'djsupervisor',
'registration',
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions oioioi/interactive/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class InteractiveConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "oioioi.interactive"
23 changes: 23 additions & 0 deletions oioioi/interactive/controllers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.utils.translation import gettext_lazy as _

from oioioi.filetracker.utils import django_to_filetracker_path
from oioioi.interactive.models import Interactor
from oioioi.programs.controllers import ProgrammingProblemController


class InteractiveProblemController(ProgrammingProblemController):
description = _("Interactive programming problem")

def fill_evaluation_environ(self, environ, submission, **kwargs):
super().fill_evaluation_environ(environ, submission, **kwargs)

interactor = Interactor.objects.get(problem=self.problem)
environ['interactor_file'] = django_to_filetracker_path(interactor.exe_file)

environ['task_type_suffix'] = '-interactive-exec'

def user_outs_exist(self):
return False

def allow_test_runs(self, request):
return False
41 changes: 41 additions & 0 deletions oioioi/interactive/fixtures/test_interactive.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[
{
"model": "contests.probleminstance",
"pk": 1,
"fields": {
"contest": null,
"round": null,
"problem": 1,
"short_name": "abc_main",
"submissions_limit": 10,
"needs_rejudge": false
}
},
{
"model": "contests.probleminstance",
"pk": 2,
"fields": {
"contest": "c",
"round": 1,
"problem": 1,
"short_name": "abc",
"submissions_limit": 10,
"needs_rejudge": false
}
},
{
"model": "problems.problem",
"pk": 1,
"fields": {
"legacy_name": "Test interactive",
"short_name": "abc",
"controller_name": "oioioi.interactive.controllers.InteractiveProblemController",
"contest": "c",
"author": 1000,
"visibility": "FR",
"package_backend_name": "oioioi.sinolpack.package.SinolPackageBackend",
"ascii_name": "abc",
"main_problem_instance": 1
}
}
]
Empty file.
30 changes: 30 additions & 0 deletions oioioi/interactive/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.8 on 2024-04-05 10:52

from django.db import migrations, models
import django.db.models.deletion
import oioioi.filetracker.fields
import oioioi.problems.models


class Migration(migrations.Migration):

initial = True

dependencies = [
('problems', '0031_auto_20220328_1124'),
]

operations = [
migrations.CreateModel(
name='Interactor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('exe_file', oioioi.filetracker.fields.FileField(blank=True, max_length=255, null=True, upload_to=oioioi.problems.models.make_problem_filename, verbose_name='interactive executable file')),
('problem', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='problems.problem')),
],
options={
'verbose_name': 'interactive executable file',
'verbose_name_plural': ('interactive executable files',),
},
),
]
Empty file.
19 changes: 19 additions & 0 deletions oioioi/interactive/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import models
from django.utils.translation import gettext_lazy as _

from oioioi.filetracker.fields import FileField
from oioioi.problems.models import Problem, make_problem_filename


class Interactor(models.Model):
problem = models.OneToOneField(Problem, on_delete=models.CASCADE)
MasloMaslane marked this conversation as resolved.
Show resolved Hide resolved
exe_file = FileField(
upload_to=make_problem_filename,
null=True,
blank=True,
verbose_name=_("interactive executable file"),
)

class Meta(object):
verbose_name = _("interactive executable file")
verbose_name_plural = _("interactive executable files"),
30 changes: 30 additions & 0 deletions oioioi/interactive/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from oioioi.base.tests import TestCase
from django.urls import reverse
from oioioi.contests.models import ProblemInstance
from oioioi.problems.models import Problem


class TestInteractiveProblemController(TestCase):
fixtures = ['test_users', 'test_contest', 'test_interactive']

def test_test_runs(self):
self.assertTrue(self.client.login(username='test_admin'))
self.client.get('/c/c/')
problem_instance = ProblemInstance.objects.all()[1]
url = reverse(
'oioioiadmin:contests_probleminstance_change', args=(problem_instance.id,)
)

response = self.client.get(url)
self.assertNotContains(response, "Test run config")

def test_advanced_settings(self):
self.assertTrue(self.client.login(username='test_admin'))
self.client.get('/c/c/')
problem = Problem.objects.all()[0]
url = reverse(
'oioioiadmin:problems_problem_change', args=(problem.id,)
)

response = self.client.get(url)
self.assertContains(response, "Interactive programming problem")
6 changes: 6 additions & 0 deletions oioioi/problems/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,3 +727,9 @@ def get_notification_message_submission_judged(self, submission):
message += gettext_noop("The score is %(score)s.")

return message

def user_outs_exist(self):
return True

def allow_test_runs(self, request):
return True
3 changes: 2 additions & 1 deletion oioioi/programs/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,8 +727,9 @@ def render_report(self, request, report):
group_reports = dict((g.group, g) for g in group_reports)

picontroller = problem_instance.controller
pcontroller = problem_instance.problem.controller

allow_download_out = picontroller.can_generate_user_out(request, report)
allow_download_out = pcontroller.user_outs_exist() and picontroller.can_generate_user_out(request, report)
allow_test_comments = picontroller.can_see_test_comments(request, report)
all_outs_generated = allow_download_out

Expand Down
4 changes: 3 additions & 1 deletion oioioi/programs/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ def run_tests(env, kind=None, **kwargs):
not_to_judge.append(test_name)
continue
job = test_env.copy()
job['job_type'] = (env.get('exec_mode', '') + '-exec').lstrip('-')
job['job_type'] = (env.get('exec_mode', '') + env.get('task_type_suffix', '-exec')).lstrip('-')
if kind == 'INITIAL' or kind == 'EXAMPLE':
job['task_priority'] = EXAMPLE_TEST_TASK_PRIORITY
elif env['submission_kind'] == 'TESTRUN':
Expand All @@ -292,6 +292,8 @@ def run_tests(env, kind=None, **kwargs):
if env.get('save_outputs'):
job.setdefault('out_file', _make_filename(env, test_name + '.out'))
job['upload_out'] = True
if env.get('interactor_file'):
job['interactor_file'] = env['interactor_file']
job['untrusted_checker'] = env['untrusted_checker']
jobs[test_name] = job
extra_args = env.get('sioworkers_extra_args', {}).get(kind, {})
Expand Down
10 changes: 6 additions & 4 deletions oioioi/programs/templates/programs/report-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
{% if test.test %}
<small>
{% if is_admin %}
<a title='{% trans "Download output for this test" %}'
href="{% url 'download_output_file' test_id=test.test.id %}">
out
</a>
{% if test.test.output_file %}
<a title='{% trans "Download output for this test" %}'
href="{% url 'download_output_file' test_id=test.test.id %}">
out
</a>
{% endif %}
<a title='{% trans "Download input for this test" %}'
href="{% url 'download_input_file' test_id=test.test.id %}">
in
Expand Down
2 changes: 2 additions & 0 deletions oioioi/programs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ def download_output_file_view(request, test_id):
test = get_object_or_404(Test, id=test_id)
if not test.problem_instance.controller.can_see_test(request, test):
raise PermissionDenied
if not test.output_file:
raise Http404
return stream_file(test.output_file, strip_num_or_hash(test.output_file.name))


Expand Down
Binary file added oioioi/sinolpack/files/test_interactor_failure.tgz
Binary file not shown.
Binary file added oioioi/sinolpack/files/test_sigpipe_interactor.tgz
Binary file not shown.
Binary file added oioioi/sinolpack/files/test_simple_interactive.tgz
Binary file not shown.
59 changes: 52 additions & 7 deletions oioioi/sinolpack/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys
import tempfile
import zipfile
from enum import Enum

import chardet
import six
Expand All @@ -27,6 +28,7 @@
filetracker_to_django_file,
stream_file,
)
from oioioi.interactive.models import Interactor
from oioioi.problems.models import (
Problem,
ProblemAttachment,
Expand Down Expand Up @@ -60,6 +62,11 @@
PAS_EXTRA_ARGS = ['-Ci', '-Cr', '-Co', '-gl']


class TaskType(Enum):
STANDARD = 'standard'
INTERACTIVE = 'interactive'


def _stringify_keys(dictionary):
return dict((str(k), v) for k, v in dictionary.items())

Expand Down Expand Up @@ -111,7 +118,6 @@ def _remove_from_zip(zipfname, *filenames):


class SinolPackage(object):
controller_name = 'oioioi.sinolpack.controllers.SinolProblemController'
package_backend_name = 'oioioi.sinolpack.package.SinolPackageBackend'

def __init__(self, path, original_filename=None):
Expand Down Expand Up @@ -142,6 +148,7 @@ def __init__(self, path, original_filename=None):
self.restrict_html = (
settings.SINOLPACK_RESTRICT_HTML and not settings.USE_SINOLPACK_MAKEFILES
)
self.task_type = TaskType.STANDARD

def identify(self):
return self._find_main_dir() is not None
Expand Down Expand Up @@ -332,6 +339,8 @@ def unpack(self, env, package):
self.env = env
self.package = package

self._detect_task_type()

self._create_problem_or_reuse_if_exists(self.package.problem)
return self._extract_and_process_package()

Expand Down Expand Up @@ -370,7 +379,7 @@ def _create_problem_instance(self):
return Problem.create(
legacy_name=self.short_name,
short_name=self.short_name,
controller_name=self.controller_name,
controller_name=self._get_controller_name(),
contest=self.package.contest,
visibility=(
Problem.VISIBILITY_PUBLIC
Expand All @@ -380,6 +389,14 @@ def _create_problem_instance(self):
author=author,
)

def _get_controller_name(self):
if hasattr(self, 'controller_name'):
return self.controller_name
return {
TaskType.STANDARD: 'oioioi.sinolpack.controllers.SinolProblemController',
TaskType.INTERACTIVE: 'oioioi.interactive.controllers.InteractiveProblemController',
}[self.task_type]

def _extract_and_process_package(self):
tmpdir = tempfile.mkdtemp()
logger.info("%s: tmpdir is %s", self.filename, tmpdir)
Expand Down Expand Up @@ -431,6 +448,8 @@ def _process_package(self):
self._process_statements()
self._generate_tests()
self._process_checkers()
if self.task_type == TaskType.INTERACTIVE:
self._process_interactive_checkers()
self._process_model_solutions()
self._process_attachments()
self._save_original_package()
Expand All @@ -451,6 +470,11 @@ def _process_config_yml(self):
instance.save()
self.config = instance.parsed_config

@_describe_processing_error
def _detect_task_type(self):
if any(map(lambda name: self.short_name + 'soc' in name, self.archive.filenames())):
self.task_type = TaskType.INTERACTIVE

@_describe_processing_error
def _detect_full_name(self):
"""Sets the problem's full name from the ``config.yml`` (key ``title``)
Expand Down Expand Up @@ -712,7 +736,8 @@ def _generate_tests(self, total_score_if_auto=100):
self._verify_time_limits(sum_of_time_limits)

self._verify_inputs(created_tests)
self._generate_test_outputs(created_tests, outs_to_make)
if self.task_type == TaskType.STANDARD:
self._generate_test_outputs(created_tests, outs_to_make)
self._validate_tests(created_tests)
self._delete_non_existing_tests(created_tests)

Expand Down Expand Up @@ -848,7 +873,7 @@ def _validate_tests(self, created_tests):
:raises: :class:`~oioioi.problems.package.ProblemPackageError`
"""
for instance in created_tests:
if not instance.output_file:
if self.task_type == TaskType.STANDARD and not instance.output_file:
raise ProblemPackageError(
_("Missing out file for test %s") % instance.name
)
Expand Down Expand Up @@ -1207,9 +1232,29 @@ def _process_checkers(self):
instance.exe_file = self._find_checker_exec()
instance.save()

def _find_checker_exec(self):
checker_prefix = os.path.join(self.rootdir, 'prog', self.short_name + 'chk')
exe_candidates = [checker_prefix + '.e', checker_prefix + '.sh']
@_describe_processing_error
def _process_interactive_checkers(self):
interactor_name = '%ssoc.e' % (self.short_name)
out_name = _make_filename_in_job_dir(self.env, interactor_name)
instance = Interactor.objects.get_or_create(problem=self.problem)[0]
env = self._find_and_compile(
'soc',
command=interactor_name,
cwd=os.path.join(self.rootdir, 'prog'),
log_on_failure=False,
out_name=out_name,
)
if not self.use_make and env:
self._save_to_field(instance.exe_file, env['compiled_file'])
else:
instance.exe_file = self._find_checker_exec('soc', False)
instance.save()

def _find_checker_exec(self, name='chk', allow_sh=True):
checker_prefix = os.path.join(self.rootdir, 'prog', self.short_name + name)
exe_candidates = [checker_prefix + '.e']
if allow_sh:
exe_candidates.append(checker_prefix + '.sh')
for exe in exe_candidates:
if os.path.isfile(exe):
return File(open(exe, 'rb'))
Expand Down
Loading
Loading