diff --git a/dmoj/settings.py b/dmoj/settings.py index c7649f6fa..79ddbda78 100755 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -28,6 +28,8 @@ DEBUG = True ALLOWED_HOSTS = [] +CORS_ALLOWED_ORIGINS = [] +CORS_URLS_REGEX = r'^/problem-export/' CSRF_FAILURE_VIEW = 'judge.views.widgets.csrf_failure' @@ -117,6 +119,14 @@ VNOJ_LONG_QUEUE_ALERT_THRESHOLD = 10 +# Transfer host +VNOJ_PROBLEM_ENABLE_IMPORT = False +VNOJ_PROBLEM_ENABLE_EXPORT = False +VNOJ_PROBLEM_IMPORT_HOST = 'https://oj.vnoi.info' +VNOJ_PROBLEM_IMPORT_SECRET = '' +VNOJ_PROBLEM_IMPORT_JUDGE_PREFIX = 'vnoj/' +VNOJ_PROBLEM_IMPORT_TIMEOUT = 5 # in seconds + CELERY_TIMEZONE = 'Asia/Ho_Chi_Minh' # Some problems have a lot of testcases, and each testcase @@ -326,6 +336,7 @@ 'judge.ProblemGroup', 'judge.ProblemType', 'judge.License', + 'judge.ProblemExportKey', ], }, { @@ -421,9 +432,11 @@ 'martor', 'adminsortable2', 'django_cleanup.apps.CleanupConfig', + 'corsheaders', ) MIDDLEWARE = ( + 'corsheaders.middleware.CorsMiddleware', 'judge.middleware.ShortCircuitMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -496,6 +509,7 @@ 'judge.template_context.site_theme', 'judge.template_context.misc_config', 'judge.template_context.math_setting', + 'judge.template_context.site_setting', 'social_django.context_processors.backends', 'social_django.context_processors.login_redirect', ], @@ -668,6 +682,7 @@ BRIDGED_JUDGE_PROXIES = None BRIDGED_DJANGO_ADDRESS = [('localhost', 9998)] BRIDGED_DJANGO_CONNECT = None +BRIDGED_MONITOR_UPDATE_URL = None # Event Server configuration EVENT_DAEMON_USE = False diff --git a/dmoj/urls.py b/dmoj/urls.py index c7e6d1c1b..0e612cdc0 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -21,6 +21,7 @@ from judge.views.magazine import MagazinePage from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ problem_data_file, problem_init_view +from judge.views.problem_transfer import ProblemExportSelect2View, ProblemExportView, ProblemImportView from judge.views.register import ActivationView, RegistrationView from judge.views.select2 import AssigneeSelect2View, CommentSelect2View, ContestSelect2View, \ ContestUserSearchSelect2View, OrganizationSelect2View, OrganizationUserSelect2View, ProblemSelect2View, \ @@ -421,6 +422,12 @@ def paged_list_view(view, name): ])), path('magazine/', MagazinePage.as_view(), name='magazine'), + + re_path('^problem-export/(?P[a-zA-Z0-9_-]{48})', include([ + path('', ProblemExportView.as_view(), name='problem_export'), + path('/select', ProblemExportSelect2View.as_view(), name='problem_export_select2_ajax'), + ])), + path('problem-import', ProblemImportView.as_view(), name='problem_import'), ] favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png', diff --git a/judge/admin/__init__.py b/judge/admin/__init__.py index c4e2751bd..9576921a8 100644 --- a/judge/admin/__init__.py +++ b/judge/admin/__init__.py @@ -7,7 +7,7 @@ from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin from judge.admin.interface import BlogPostAdmin, FlatPageAdmin, LicenseAdmin, LogEntryAdmin, NavigationBarAdmin from judge.admin.organization import OrganizationAdmin, OrganizationRequestAdmin -from judge.admin.problem import ProblemAdmin +from judge.admin.problem import ProblemAdmin, ProblemExportKeyAdmin from judge.admin.profile import ProfileAdmin, UserAdmin from judge.admin.runtime import JudgeAdmin, LanguageAdmin from judge.admin.submission import SubmissionAdmin @@ -16,7 +16,7 @@ from judge.admin.ticket import TicketAdmin from judge.models import Badge, BlogPost, Comment, CommentLock, Contest, ContestParticipation, \ ContestTag, Judge, Language, License, MiscConfig, NavigationBar, Organization, \ - OrganizationRequest, Problem, ProblemGroup, ProblemType, Profile, Submission, Tag, \ + OrganizationRequest, Problem, ProblemExportKey, ProblemGroup, ProblemType, Profile, Submission, Tag, \ TagGroup, TagProblem, Ticket admin.site.register(BlogPost, BlogPostAdmin) @@ -37,6 +37,7 @@ admin.site.register(Organization, OrganizationAdmin) admin.site.register(OrganizationRequest, OrganizationRequestAdmin) admin.site.register(Problem, ProblemAdmin) +admin.site.register(ProblemExportKey, ProblemExportKeyAdmin) admin.site.register(ProblemGroup, ProblemGroupAdmin) admin.site.register(ProblemType, ProblemTypeAdmin) admin.site.register(Profile, ProfileAdmin) diff --git a/judge/admin/problem.py b/judge/admin/problem.py index 0c1b29688..c12772424 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -234,3 +234,18 @@ def construct_change_message(self, request, form, *args, **kwargs): if form.cleaned_data.get('change_message'): return form.cleaned_data['change_message'] return super(ProblemAdmin, self).construct_change_message(request, form, *args, **kwargs) + + +class ProblemExportKeyAdmin(VersionAdmin): + fieldsets = ( + (None, {'fields': ('name', 'remaining_uses')}), + (_('Description'), {'fields': ('description',)}), + ) + list_display = ['name', 'remaining_uses'] + search_fields = ('name', 'description') + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if not change: + secret = obj.generate_secret() + self.message_user(request, gettext('Generated secret: %s') % secret) diff --git a/judge/balancer/balancer.py b/judge/balancer/balancer.py index b9826326e..eb0bb0d1e 100644 --- a/judge/balancer/balancer.py +++ b/judge/balancer/balancer.py @@ -64,7 +64,7 @@ def _try_judge(self): self.judge_to_bridge[judge.name] = bridge_id self.bridge_to_judge[bridge_id] = judge - packet['storage-namespace'] = self.config['bridges'][bridge_id].get('storage_namespace') + packet.setdefault('storage-namespace', self.config['bridges'][bridge_id].get('storage_namespace')) judge.submit(packet) def free_judge(self, judge): diff --git a/judge/balancer/judge_list.py b/judge/balancer/judge_list.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/judge/bridge/daemon.py b/judge/bridge/daemon.py index 00ee09464..ca0c87c88 100644 --- a/judge/bridge/daemon.py +++ b/judge/bridge/daemon.py @@ -18,20 +18,20 @@ def reset_judges(): Judge.objects.update(online=False, ping=None, load=None) -def judge_daemon(run_monitor=False, problem_storage_globs=None): +def judge_daemon(config): reset_judges() Submission.objects.filter(status__in=Submission.IN_PROGRESS_GRADING_STATUS) \ .update(status='IE', result='IE', error=None) judges = JudgeList() monitor = None - if run_monitor: + if 'run_monitor' in config: from judge.bridge.monitor import Monitor - monitor = Monitor(judges, problem_storage_globs or []) + monitor = Monitor(judges, **config) judge_server = Server( settings.BRIDGED_JUDGE_ADDRESS, - partial(JudgeHandler, judges=judges, ignore_problems_packet=run_monitor), + partial(JudgeHandler, judges=judges, ignore_problems_packet=('run_monitor' in config)), ) django_server = Server(settings.BRIDGED_DJANGO_ADDRESS, partial(DjangoHandler, judges=judges)) diff --git a/judge/bridge/judge_handler.py b/judge/bridge/judge_handler.py index f70eb9c45..6a312cca8 100644 --- a/judge/bridge/judge_handler.py +++ b/judge/bridge/judge_handler.py @@ -237,7 +237,7 @@ def submit(self, id, problem, language, source): data = self.get_related_submission_data(id) self._working = id self._no_response_job = threading.Timer(20, self._kill_if_no_response) - self.send({ + request = { 'name': 'submission-request', 'submission-id': id, 'problem-id': problem, @@ -254,7 +254,14 @@ def submit(self, id, problem, language, source): 'file-only': data.file_only, 'file-size-limit': data.file_size_limit, }, - }) + } + if '/' in problem: + storage_namespace, problem = problem.split('/') + request.update({ + 'storage-namespace': storage_namespace, + 'problem-id': problem, + }) + self.send(request) def _kill_if_no_response(self): logger.error('Judge failed to acknowledge submission: %s: %s', self.name, self._working) diff --git a/judge/bridge/monitor.py b/judge/bridge/monitor.py index 8006e21c8..fa6d365a5 100644 --- a/judge/bridge/monitor.py +++ b/judge/bridge/monitor.py @@ -3,9 +3,13 @@ import os import threading import time +from contextlib import closing +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from urllib.request import urlopen from django import db +from django.db.models import Q from judge.models import Problem @@ -27,6 +31,27 @@ logger = logging.getLogger('judge.monitor') +class JudgeControlRequestHandler(BaseHTTPRequestHandler): + signal = None + + def update_problems(self): + if self.signal is not None: + self.signal.set() + + def do_POST(self): + if self.path == '/update/problems': + logger.info('Problem update requested') + self.update_problems() + self.send_response(200) + self.end_headers() + self.wfile.write(b'As you wish.') + return + self.send_error(404) + + def do_GET(self): + self.send_error(404) + + def _ensure_connection(): db.connection.close_if_unusable_or_obsolete() @@ -49,48 +74,87 @@ class SendProblemsHandler(FileSystemEventHandler): EVENT_TYPE_CREATED, ) - def __init__(self, signal): + def __init__(self, signal, propagation_signal): self.signal = signal + self.propagation_signal = propagation_signal def on_any_event(self, event): if event.event_type not in self.ALLOWED_EVENT_TYPES: return self.signal.set() + self.propagation_signal.set() class Monitor: - def __init__(self, judges, problem_globs): + def __init__(self, judges, **config): if not has_watchdog_installed: raise ImportError('watchdog is not installed') self.judges = judges - self.problem_globs = problem_globs + self.problem_globs = [(entry['storage_namespaces'], entry['problem_storage_globs']) + for entry in config['run_monitor']] self.updater_exit = False self.updater_signal = threading.Event() + self.propagation_signal = threading.Event() self.updater = threading.Thread(target=self.updater_thread) + self.propagator = threading.Thread(target=self.propagate_update_signal) + + self.update_pings = config.get('update_pings') or [] + + api_listen = config.get('api_listen') + if api_listen: + api_listen = (api_listen['host'], api_listen['port']) - self._handler = SendProblemsHandler(self.updater_signal) + class Handler(JudgeControlRequestHandler): + signal = self.updater_signal + + self.api_server = HTTPServer(api_listen, Handler) + else: + self.api_server = None + + self._handler = SendProblemsHandler(self.updater_signal, self.propagation_signal) self._observer = Observer() - for root in set(map(find_glob_root, problem_globs)): + for _, dir_glob in self.problem_globs: + root = find_glob_root(dir_glob) self._observer.schedule(self._handler, root, recursive=True) logger.info('Scheduled for monitoring: %s', root) def update_supported_problems(self): problems = [] - for dir_glob in self.problem_globs: + for storage_namespace, dir_glob in self.problem_globs: for problem_config in glob.iglob(os.path.join(dir_glob, 'init.yml'), recursive=True): if os.access(problem_config, os.R_OK): problem_dir = os.path.dirname(problem_config) problem = os.path.basename(problem_dir) - problems.append(problem) + if storage_namespace: + problems.append(storage_namespace + '/' + problem) + else: + problems.append(problem) problems = set(problems) _ensure_connection() - problem_ids = list(Problem.objects.filter(code__in=list(problems)).values_list('id', flat=True)) + problem_ids = list(Problem.objects.filter(Q(code__in=list(problems)) | Q(judge_code__in=list(problems))) + .values_list('id', flat=True)) self.judges.update_problems_all(problems, problem_ids) + def propagate_update_signal(self): + while True: + self.propagation_signal.wait() + self.propagation_signal.clear() + if self.updater_exit: + return + + for url in self.update_pings: + logger.info('Pinging for problem update: %s', url) + try: + with closing(urlopen(url, data=b'')) as f: + f.read() + except Exception: + logger.exception('Failed to ping for problem update: %s', url) + time.sleep(3) + def updater_thread(self) -> None: while True: self.updater_signal.wait() @@ -106,8 +170,12 @@ def updater_thread(self) -> None: def start(self): self.updater.start() + self.propagator.start() self.updater_signal.set() try: + if self.api_server: + thread = threading.Thread(target=self.api_server.serve_forever) + thread.start() self._observer.start() except OSError: logger.exception('Failed to start problem monitor.') @@ -115,5 +183,8 @@ def start(self): def stop(self): self._observer.stop() self._observer.join(1) + if self.api_server: + self.api_server.shutdown() self.updater_exit = True self.updater_signal.set() + self.propagation_signal.set() diff --git a/judge/judgeapi.py b/judge/judgeapi.py index 1bdc14c54..18acad6a8 100644 --- a/judge/judgeapi.py +++ b/judge/judgeapi.py @@ -94,7 +94,7 @@ def judge_submission(submission, rejudge=False, batch_rejudge=False, judge_id=No response = judge_request({ 'name': 'submission-request', 'submission-id': submission.id, - 'problem-id': submission.problem.code, + 'problem-id': submission.problem.judge_code or submission.problem.code, 'language': submission.language.key, 'source': submission.source.source, 'judge-id': judge_id, diff --git a/judge/management/commands/runbridged.py b/judge/management/commands/runbridged.py index e7267f375..dd560b720 100644 --- a/judge/management/commands/runbridged.py +++ b/judge/management/commands/runbridged.py @@ -1,14 +1,17 @@ +import yaml from django.core.management.base import BaseCommand from judge.bridge.daemon import judge_daemon class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument('--monitor', action='store_true', default=False, - help='if specified, run a monitor to automatically update problems') - parser.add_argument('--problem-storage-globs', nargs='*', default=[], - help='globs to monitor for problem updates') + def add_arguments(self, parser) -> None: + parser.add_argument('-c', '--config', type=str, help='file to load bridged configurations from') def handle(self, *args, **options): - judge_daemon(options['monitor'], options['problem_storage_globs']) + if options['config']: + with open(options['config'], 'r') as f: + config = yaml.safe_load(f) + else: + config = {} + judge_daemon(config) diff --git a/judge/migrations/0210_problem_export.py b/judge/migrations/0210_problem_export.py new file mode 100644 index 000000000..bd97f0c82 --- /dev/null +++ b/judge/migrations/0210_problem_export.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.25 on 2024-10-10 09:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0209_judge_add_tiers'), + ] + + operations = [ + migrations.CreateModel( + name='ProblemExportKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='name')), + ('token', models.CharField(max_length=64, verbose_name='token')), + ('remaining_uses', models.IntegerField(verbose_name='remaining uses')), + ('description', models.TextField(blank=True, verbose_name='description')), + ], + ), + migrations.AddField( + model_name='problem', + name='judge_code', + field=models.CharField(blank=True, max_length=100, verbose_name='judge code'), + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 3644bc406..b01768483 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -5,8 +5,8 @@ from judge.models.contest import Contest, ContestAnnouncement, ContestMoss, ContestParticipation, ContestProblem, \ ContestSubmission, ContestTag, Rating from judge.models.interface import BlogPost, BlogVote, MiscConfig, NavigationBar, validate_regex -from judge.models.problem import LanguageLimit, License, Problem, ProblemClarification, ProblemGroup, \ - ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet +from judge.models.problem import LanguageLimit, License, Problem, ProblemClarification, ProblemExportKey, \ + ProblemGroup, ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \ problem_directory_file from judge.models.profile import Badge, Organization, OrganizationMonthlyUsage, OrganizationRequest, \ diff --git a/judge/models/problem.py b/judge/models/problem.py index 7a90824ae..664a4e231 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -1,5 +1,9 @@ +import base64 import errno +import hmac import json +import secrets +import struct from operator import attrgetter from django.conf import settings @@ -12,6 +16,7 @@ from django.db.models.functions import Coalesce from django.urls import reverse from django.utils import timezone +from django.utils.encoding import force_bytes from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -22,8 +27,8 @@ from judge.user_translations import gettext as user_gettext from judge.utils.url import get_absolute_pdf_url -__all__ = ['ProblemGroup', 'ProblemType', 'Problem', 'ProblemTranslation', 'ProblemClarification', 'License', - 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet'] +__all__ = ['ProblemGroup', 'ProblemType', 'Problem', 'ProblemTranslation', 'ProblemClarification', 'ProblemExportKey', + 'License', 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet'] def disallowed_characters_validator(text): @@ -185,6 +190,7 @@ class Problem(models.Model): is_public = models.BooleanField(verbose_name=_('publicly visible'), db_index=True, default=False) is_manually_managed = models.BooleanField(verbose_name=_('manually managed'), db_index=True, default=False, help_text=_('Whether judges should be allowed to manage data or not.')) + judge_code = models.CharField(max_length=100, verbose_name=_('judge code'), blank=True) date = models.DateTimeField(verbose_name=_('date of publishing'), null=True, blank=True, db_index=True, help_text=_( "Doesn't have the magic ability to auto-publish due to backward compatibility.")) @@ -697,3 +703,21 @@ class Meta: ) verbose_name = _('solution') verbose_name_plural = _('solutions') + + +class ProblemExportKey(models.Model): + name = models.CharField(max_length=100, verbose_name=_('name')) + token = models.CharField(max_length=64, verbose_name=_('token')) + remaining_uses = models.IntegerField(verbose_name=_('remaining uses')) + description = models.TextField(blank=True, verbose_name=_('description')) + + def __str__(self): + return self.name + + def generate_secret(self): + secret = secrets.token_bytes(32) + self.token = hmac.new(force_bytes(settings.SECRET_KEY), msg=secret, digestmod='sha256').hexdigest() + self.save(update_fields=['token']) + return base64.urlsafe_b64encode(struct.pack('>I32s', self.id, secret)).decode('utf-8') + + generate_secret.alters_data = True diff --git a/judge/template_context.py b/judge/template_context.py index e055cbdc7..42c4383cd 100644 --- a/judge/template_context.py +++ b/judge/template_context.py @@ -106,3 +106,7 @@ def math_setting(request): if engine == 'auto': engine = 'mml' if bool(settings.MATHOID_URL) and caniuse.mathml == SUPPORT else 'jax' return {'MATH_ENGINE': engine, 'REQUIRE_JAX': engine == 'jax', 'caniuse': caniuse} + + +def site_setting(request): + return {'VNOJ_PROBLEM_ENABLE_IMPORT': settings.VNOJ_PROBLEM_ENABLE_IMPORT} diff --git a/judge/views/problem_data.py b/judge/views/problem_data.py index f66f4f8dc..216687dd4 100644 --- a/judge/views/problem_data.py +++ b/judge/views/problem_data.py @@ -124,7 +124,7 @@ def _construct_form(self, i, **kwargs): class ProblemManagerMixin(LoginRequiredMixin, ProblemMixin, DetailView): def get_object(self, queryset=None): problem = super(ProblemManagerMixin, self).get_object(queryset) - if problem.is_manually_managed: + if problem.is_manually_managed or problem.judge_code: raise Http404() if self.request.user.is_superuser or problem.is_editable_by(self.request.user): return problem diff --git a/judge/views/problem_transfer.py b/judge/views/problem_transfer.py new file mode 100644 index 000000000..3ca398633 --- /dev/null +++ b/judge/views/problem_transfer.py @@ -0,0 +1,223 @@ +import base64 +import hmac +import logging +import struct +from contextlib import closing +from urllib.request import urlopen + +import requests +from celery import shared_task +from django.conf import settings +from django.contrib.auth.models import AnonymousUser, User +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.validators import RegexValidator +from django.db.models import Q +from django.forms import CharField, Form +from django.http import Http404, JsonResponse +from django.urls import reverse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.utils.encoding import force_bytes +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import FormView +from django.views.generic.list import BaseListView +from requests.exceptions import HTTPError, RequestException +from reversion import revisions + +from judge.models import Language, Problem, ProblemExportKey, ProblemGroup, ProblemType +from judge.utils.celery import redirect_to_task_status +from judge.utils.views import TitleMixin +from judge.widgets import HeavySelect2Widget + + +logger = logging.getLogger('judge.problem.transfer') + + +class ProblemExportMixin: + def setup(self, request, *args, **kwargs): + if not settings.VNOJ_PROBLEM_ENABLE_EXPORT: + raise Http404() + super().setup(request, *args, **kwargs) + try: + id, secret = struct.unpack('>I32s', base64.urlsafe_b64decode(kwargs['secret'])) + self.transfer_key = ProblemExportKey.objects.get(id=id) + + # compare key + digest = hmac.new(force_bytes(settings.SECRET_KEY), msg=secret, digestmod='sha256').hexdigest() + if not hmac.compare_digest(digest, self.transfer_key.token): + raise HTTPError() + + except (ProblemExportKey.DoesNotExist, HTTPError): + raise Http404('Key not found') + + +class ProblemExportSelect2View(ProblemExportMixin, BaseListView): + paginate_by = 20 + + def get_queryset(self): + if self.transfer_key.remaining_uses <= 0: + return Problem.objects.none() + return Problem.get_public_problems().filter(Q(judge_code='') & + (Q(code__icontains=self.term) | Q(name__icontains=self.term))) + + def get(self, request, *args, **kwargs): + self.request = request + self.term = kwargs.get('term', request.GET.get('term', '')) + self.object_list = self.get_queryset() + context = self.get_context_data() + + return JsonResponse({ + 'results': [ + { + 'text': f'{obj.name} ({obj.code})', + 'id': obj.code, + } for obj in context['object_list']], + 'more': context['page_obj'].has_next(), + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class ProblemExportView(ProblemExportMixin, View): + def get(self, request, *args, **kwargs): + return JsonResponse({ + 'name': self.transfer_key.name, + 'remaining_uses': self.transfer_key.remaining_uses, + }) + + def post(self, request, *args, **kwargs): + if self.transfer_key.remaining_uses <= 0: + raise PermissionDenied('No remaining uses') + + try: + code = request.POST.get('code', '') + if not code: + raise HTTPError() + problem = Problem.objects.get(code=code) + if not problem.is_accessible_by(AnonymousUser()): + raise HTTPError() + except (Problem.DoesNotExist, HTTPError): + raise Http404('Problem not found') + + self.transfer_key.remaining_uses -= 1 + self.transfer_key.save() + + return JsonResponse({ + 'code': problem.code, + 'name': problem.name, + 'description': problem.description, + 'time_limit': problem.time_limit, + 'memory_limit': problem.memory_limit, + 'points': problem.points, + 'partial': problem.partial, + 'short_circuit': problem.short_circuit, + }) + + +def get_problem_export_select_url(host=settings.VNOJ_PROBLEM_IMPORT_HOST, secret=settings.VNOJ_PROBLEM_IMPORT_SECRET): + return host + reverse('problem_export_select2_ajax', args=(secret,)) + + +def get_problem_export_url(host=settings.VNOJ_PROBLEM_IMPORT_HOST, secret=settings.VNOJ_PROBLEM_IMPORT_SECRET): + return host + reverse('problem_export', args=(secret,)) + + +class ProblemImportForm(Form): + problem = CharField(max_length=32, + validators=[RegexValidator('^[a-z0-9_]+$', _('Problem code must be ^[a-z0-9_]+$'))]) + new_code = CharField(max_length=32, validators=[RegexValidator('^[a-z0-9_]+$', + _('Problem code must be ^[a-z0-9_]+$'))]) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['problem'].widget = HeavySelect2Widget(data_url=get_problem_export_select_url(), + attrs={'style': 'width: 100%'}) + + def clean_new_code(self): + new_code = self.cleaned_data['new_code'] + if Problem.objects.filter(code=new_code).exists(): + raise ValidationError(_('Problem with code already exists.')) + return new_code + + def clean(self): + response = requests.get(get_problem_export_url(), timeout=settings.VNOJ_PROBLEM_IMPORT_TIMEOUT) + if not response.ok: + self.add_error(None, _('Bad request')) + elif response.json().get('remaining_uses') <= 0: + self.add_error('secret', _('No remaining uses')) + + +@shared_task(bind=True) +def import_problem(self, user_id, problem, new_code): + old_code = problem + response = requests.post(get_problem_export_url(), + data={'code': old_code}, + timeout=settings.VNOJ_PROBLEM_IMPORT_TIMEOUT) + + if not response.ok: + raise Http404() + problem_info = response.json() + problem = Problem() + problem.code = new_code + # Use the exported code + problem.judge_code = settings.VNOJ_PROBLEM_IMPORT_JUDGE_PREFIX + problem_info['code'] + problem.name = problem_info['name'] + problem.description = problem_info['description'] + problem.time_limit = problem_info['time_limit'] + problem.memory_limit = problem_info['memory_limit'] + problem.points = problem_info['points'] + problem.partial = problem_info['partial'] + problem.short_circuit = problem_info['short_circuit'] + problem.group = ProblemGroup.objects.order_by('id').first() # Uncategorized + problem.date = timezone.now() + problem.is_manually_managed = True + with revisions.create_revision(atomic=True): + problem.save() + problem.allowed_languages.set(Language.objects.filter(include_in_problem=True)) + problem.types.set([ProblemType.objects.order_by('id').first()]) # Uncategorized + user = User.objects.get(id=user_id) + problem.curators.add(user.profile) + revisions.set_user(user) + revisions.set_comment(_('Imported from %s%s') % ( + settings.VNOJ_PROBLEM_IMPORT_HOST, reverse('problem_detail', args=(old_code,)))) + url = settings.BRIDGED_MONITOR_UPDATE_URL + if url: + logger.info('Pinging for problem update: %s', url) + try: + with closing(urlopen(url, data=b'')) as f: + f.read() + except Exception: + logger.exception('Failed to ping for problem update: %s', url) + + +class ProblemImportView(TitleMixin, FormView): + title = _('Import Problem') + template_name = 'problem/import.html' + form_class = ProblemImportForm + + def form_valid(self, form): + status = import_problem.delay(user_id=self.request.user.id, **form.cleaned_data) + return redirect_to_task_status( + status, message=_('Importing %s...') % (form.cleaned_data['new_code'],), + redirect=reverse('problem_edit', args=(form.cleaned_data['new_code'],)), + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['host_url'] = settings.VNOJ_PROBLEM_IMPORT_HOST + try: + response = requests.get(get_problem_export_url(), timeout=settings.VNOJ_PROBLEM_IMPORT_TIMEOUT) + context['status'] = response.ok + if response.ok: + context['remaining_uses'] = response.json().get('remaining_uses') + except RequestException: + context['status'] = False + return context + + def dispatch(self, request, *args, **kwargs): + if not settings.VNOJ_PROBLEM_ENABLE_IMPORT: + raise Http404() + if not request.user.is_superuser: + raise PermissionDenied() + return super().dispatch(request, *args, **kwargs) diff --git a/requirements.txt b/requirements.txt index 382152e03..dc7e6500c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ Django>=3.2,<4 django-cleanup django_compressor>=3 +django-cors-headers django-mptt>=0.13 django-registration-redux>=2.10 django-reversion>=3.0.5,<4 diff --git a/templates/problem/import.html b/templates/problem/import.html new file mode 100644 index 000000000..0a1e39204 --- /dev/null +++ b/templates/problem/import.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block media %} + {{ form.media.css }} + +{% endblock %} + +{% block js_media %} + {{ form.media.js }} +{% endblock %} + +{% block title_ruler %}{% endblock %} + +{% block title_row %} + {% set tab = 'import' %} + {% include "problem/problem-list-tabs.html" %} +{% endblock %} + +{% block body %} +
+
+ Host: {{ host_url }} + {% if status %} + + {% else %} + + {% endif %} +
+ {% if status %} +
Remaining uses: {{ remaining_uses }}
+ {% endif %} +
+ {% if status and remaining_uses > 0 %} +
+
+ {% if form.non_field_errors() %} +
+ {{ form.non_field_errors() }} +
+ {% endif %} + {% csrf_token %} + {{ form.as_table() }}
+

+
+
+ {% endif %} +{% endblock %} diff --git a/templates/problem/problem-list-tabs.html b/templates/problem/problem-list-tabs.html index cc415067d..bd72bea87 100644 --- a/templates/problem/problem-list-tabs.html +++ b/templates/problem/problem-list-tabs.html @@ -12,6 +12,9 @@ {{ make_tab('suggest', 'fa-list', url('problem_suggest_list'), _('Suggest')) }} {% endif %} {% if request.user.is_superuser %} + {% if VNOJ_PROBLEM_ENABLE_IMPORT %} + {{ make_tab('import', 'fa-cloud-download', url('problem_import'), _('Import')) }} + {% endif %} {{ make_tab('admin', 'fa-edit', url('admin:judge_problem_changelist'), _('Admin')) }} {% endif %} {% endblock %}