diff --git a/.flake8 b/.flake8 index 857e5397f..85e70590c 100644 --- a/.flake8 +++ b/.flake8 @@ -23,6 +23,6 @@ exclude = ./dmoj/local_urls.py, # is actually a fragment to be included by settings.py ./.ci.settings.py, - # ignore migration files - ./judge/migrations/*.py, + # ignore migrations files + ./judge/migrations/*.py fc_* diff --git a/.gitmodules b/.gitmodules index f2315828c..61080b419 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ [submodule "resources/libs"] path = resources/libs url = https://github.com/qhhoj/site-assets.git - branch = master diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..f2899c9e7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +/build +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..a64cbe601 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "useTabs": false, + "tabWidth": 2, + "printWidth": 100, + "singleQuote": false, + "trailingComma": "all", + "bracketSameLine": false +} diff --git a/dmoj/settings.py b/dmoj/settings.py index 5cdae1367..5f48ee52a 100755 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -87,7 +87,7 @@ VNOJ_CONTEST_DURATION_LIMIT = 14 # Maximum number of test cases that a user can create for a problem # without the `create_mass_testcases` permission -VNOJ_TESTCASE_HARD_LIMIT = 300 +VNOJ_TESTCASE_HARD_LIMIT = 100 # If a user without the `create_mass_testcases` permission create more than this amount of test # they will receive a warning VNOJ_TESTCASE_SOFT_LIMIT = 50 @@ -100,6 +100,10 @@ VNOJ_TAG_PROBLEM_MIN_RATING = 1900 # Minimum rating to be able to tag a problem +VNOJ_SHOULD_BAN_FOR_CHEATING_IN_CONTESTS = False +VNOJ_CONTEST_CHEATING_BAN_MESSAGE = 'Banned for multiple cheating offenses during contests' +VNOJ_MAX_DISQUALIFICATIONS_BEFORE_BANNING = 3 + # List of subdomain that will be ignored in organization subdomain middleware VNOJ_IGNORED_ORGANIZATION_SUBDOMAINS = ['oj', 'www', 'localhost'] @@ -149,7 +153,7 @@ ('VNOJ', 'VNOJ'), ] -OJ_REQUESTS_TIMEOUT = 30 # in seconds +OJ_REQUESTS_TIMEOUT = 5 # in seconds OJAPI_CACHE_TIMEOUT = 3600 # Cache timeout for OJAPI data @@ -167,7 +171,7 @@ 'on_error': None, } -SITE_FULL_URL = 'https://qhhoj.com' +SITE_FULL_URL = 'https://qhhoj.com' # ie 'https://oj.vnoi.info', please remove the last / if needed ACE_URL = '//cdnjs.cloudflare.com/ajax/libs/ace/1.1.3' SELECT2_JS_URL = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js' @@ -702,6 +706,7 @@ 'social_core.backends.facebook.FacebookOAuth2', 'judge.social_auth.GitHubSecureEmailOAuth2', 'django.contrib.auth.backends.ModelBackend', + 'judge.ip_auth.IPBasedAuthBackend', ) SOCIAL_AUTH_PIPELINE = ( @@ -712,8 +717,9 @@ 'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.user.get_username', 'social_core.pipeline.social_auth.associate_by_email', - 'judge.social_auth.choose_username', + 'judge.social_auth.get_username_password', 'social_core.pipeline.user.create_user', + 'judge.social_auth.add_password', 'judge.social_auth.make_profile', 'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.load_extra_data', @@ -726,6 +732,8 @@ SOCIAL_AUTH_SLUGIFY_FUNCTION = 'judge.social_auth.slugify_username' SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['first_name', 'last_name'] +IP_BASED_AUTHENTICATION_HEADER = 'REMOTE_ADDR' + MOSS_API_KEY = None CELERY_WORKER_HIJACK_ROOT_LOGGER = False diff --git a/dmoj/urls.py b/dmoj/urls.py index 57586828e..61f407919 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -20,6 +20,7 @@ two_factor, user, widgets from judge.views.URL import redirect_url, shorten_url from judge.views.flatpages import FlatPageViewGenerator +from judge.views.magazine import MagazinePage from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ problem_data_file, problem_init_view from judge.views.register import ActivationView, RegistrationView @@ -288,6 +289,7 @@ def paged_list_view(view, name): lambda _, pk, suffix: HttpResponsePermanentRedirect('/organization/%s' % suffix)), path('organization/', include([ path('', organization.OrganizationHome.as_view(), name='organization_home'), + path('/', organization.OrganizationHome.as_view(), name='organization_home'), path('/users/', organization.OrganizationUsers.as_view(), name='organization_users'), path('/join', organization.JoinOrganization.as_view(), name='join_organization'), path('/leave', organization.LeaveOrganization.as_view(), name='leave_organization'), @@ -429,6 +431,7 @@ def paged_list_view(view, name): path('', shorten_url, name='shorten_url'), path('/', redirect_url, name='redirect_url'), ])), + path('magazine/', MagazinePage.as_view(), name='magazine'), ] favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png', diff --git a/judge/admin/contest.py b/judge/admin/contest.py index 5f916bceb..000bb0dd4 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -8,8 +8,10 @@ from django.shortcuts import get_object_or_404 from django.urls import path, reverse, reverse_lazy from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.html import format_html from django.utils.translation import gettext_lazy as _, ngettext +from django.views.decorators.http import require_POST from reversion.admin import VersionAdmin from django_ace import AceWidget @@ -73,14 +75,14 @@ class ContestProblemInline(SortableInlineAdminMixin, admin.TabularInline): def rejudge_column(self, obj): if obj.id is None: return '' - return format_html('{1}', + return format_html('{1}', reverse('admin:judge_contest_rejudge', args=(obj.contest.id, obj.id)), _('Rejudge')) @admin.display(description='') def rescore_column(self, obj): if obj.id is None: return '' - return format_html('Rescore', + return format_html('Rescore', reverse('admin:judge_contest_rescore', args=(obj.contest.id, obj.id))) @@ -100,7 +102,7 @@ class ContestAnnouncementInline(admin.StackedInline): def resend(self, obj): if obj.id is None: return 'Not available' - return format_html('Resend', + return format_html('Resend', reverse('admin:judge_contest_resend', args=(obj.contest.id, obj.id))) @@ -294,11 +296,13 @@ def get_urls(self): path('/resend//', self.resend_view, name='judge_contest_resend'), ] + super(ContestAdmin, self).get_urls() + @method_decorator(require_POST) def rejudge_view(self, request, contest_id, problem_id): contest = get_object_or_404(Contest, id=contest_id) - if not self.has_change_permission(request, contest): + if not request.user.is_staff or not self.has_change_permission(request, contest): raise PermissionDenied() - queryset = ContestSubmission.objects.filter(problem_id=problem_id).select_related('submission') + queryset = ContestSubmission.objects.filter(participation__contest_id=contest_id, + problem_id=problem_id).select_related('submission') for model in queryset: model.submission.judge(rejudge=True, rejudge_user=request.user) @@ -307,8 +311,13 @@ def rejudge_view(self, request, contest_id, problem_id): len(queryset)) % len(queryset)) return HttpResponseRedirect(reverse('admin:judge_contest_change', args=(contest_id,))) + @method_decorator(require_POST) def rescore_view(self, request, contest_id, problem_id): - queryset = ContestSubmission.objects.filter(problem_id=problem_id).select_related('submission') + contest = get_object_or_404(Contest, id=contest_id) + if not request.user.is_staff or not self.has_change_permission(request, contest): + raise PermissionDenied() + queryset = ContestSubmission.objects.filter(participation__contest_id=contest_id, + problem_id=problem_id).select_related('submission') for model in queryset: model.submission.update_contest() @@ -317,11 +326,16 @@ def rescore_view(self, request, contest_id, problem_id): len(queryset)) % len(queryset)) return HttpResponseRedirect(reverse('admin:judge_contest_change', args=(contest_id,))) + @method_decorator(require_POST) def resend_view(self, request, contest_id, announcement_id): + contest = get_object_or_404(Contest, id=contest_id) + if not request.user.is_staff or not self.has_change_permission(request, contest): + raise PermissionDenied() announcement = get_object_or_404(ContestAnnouncement, id=announcement_id) announcement.send() return HttpResponseRedirect(reverse('admin:judge_contest_change', args=(contest_id,))) + @method_decorator(require_POST) def rate_all_view(self, request): if not request.user.has_perm('judge.contest_rating'): raise PermissionDenied() @@ -333,6 +347,7 @@ def rate_all_view(self, request): rate_contest(contest) return HttpResponseRedirect(reverse('admin:judge_contest_changelist')) + @method_decorator(require_POST) def rate_view(self, request, id): if not request.user.has_perm('judge.contest_rating'): raise PermissionDenied() diff --git a/judge/admin/interface.py b/judge/admin/interface.py index 6fd74eda7..baa5ce520 100644 --- a/judge/admin/interface.py +++ b/judge/admin/interface.py @@ -89,6 +89,10 @@ def has_change_permission(self, request, obj=None): return request.user.has_perm('judge.change_blogpost') return obj.is_editable_by(request.user) + @admin.display(description=_('authors')) + def show_authors(self, obj): + return ', '.join(map(str, obj.authors.all())) + class SolutionForm(ModelForm): def __init__(self, *args, **kwargs): diff --git a/judge/admin/profile.py b/judge/admin/profile.py index 8d41c69c3..2e02690ad 100644 --- a/judge/admin/profile.py +++ b/judge/admin/profile.py @@ -59,9 +59,10 @@ def has_add_permission(self, request, obj=None): class ProfileAdmin(NoBatchDeleteMixin, VersionAdmin): - fields = ('user', 'display_rank', 'badges', 'display_badge', 'about', 'organizations', 'timezone', 'language', - 'ace_theme', 'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'allow_tagging', 'notes', - 'username_display_override', 'ban_reason', 'is_totp_enabled', 'user_script', 'current_contest') + fields = ('user', 'display_rank', 'badges', 'display_badge', 'about', 'organizations', 'vnoj_points', 'timezone', + 'language', 'ace_theme', 'math_engine', 'last_access', 'ip', 'mute', 'is_unlisted', 'allow_tagging', + 'notes', 'username_display_override', 'ban_reason', 'is_totp_enabled', 'ip_auth', 'user_script', + 'current_contest') readonly_fields = ('user',) list_display = ('admin_user_admin', 'email', 'is_totp_enabled', 'timezone_full', 'date_joined', 'last_access', 'ip', 'show_public') diff --git a/judge/admin/runtime.py b/judge/admin/runtime.py index e390d3d1b..ad70a8bc3 100644 --- a/judge/admin/runtime.py +++ b/judge/admin/runtime.py @@ -4,9 +4,11 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import path, reverse +from django.utils.decorators import method_decorator from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST from reversion.admin import VersionAdmin from django_ace import AceWidget @@ -93,18 +95,21 @@ def disconnect_judge(self, id, force=False): judge.disconnect(force=force) return HttpResponseRedirect(reverse('admin:judge_judge_changelist')) + @method_decorator(require_POST) def disconnect_view(self, request, id): judge = get_object_or_404(Judge, id=id) if not self.has_change_permission(request, judge): raise PermissionDenied() return self.disconnect_judge(id) + @method_decorator(require_POST) def terminate_view(self, request, id): judge = get_object_or_404(Judge, id=id) if not self.has_change_permission(request, judge): raise PermissionDenied() return self.disconnect_judge(id, force=True) + @method_decorator(require_POST) def disable_view(self, request, id): judge = get_object_or_404(Judge, id=id) if not self.has_change_permission(request, judge): diff --git a/judge/admin/submission.py b/judge/admin/submission.py index d782497fc..d5aea5c3d 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -9,8 +9,10 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import path, reverse +from django.utils.decorators import method_decorator from django.utils.html import format_html from django.utils.translation import gettext, gettext_lazy as _, ngettext, pgettext +from django.views.decorators.http import require_POST from reversion.admin import VersionAdmin from django_ace import AceWidget @@ -252,6 +254,7 @@ def get_urls(self): path('/judge/', self.judge_view, name='judge_submission_rejudge'), ] + super(SubmissionAdmin, self).get_urls() + @method_decorator(require_POST) def judge_view(self, request, id): if not request.user.has_perm('judge.rejudge_submission') or not request.user.has_perm('judge.edit_own_problem'): raise PermissionDenied() diff --git a/judge/bridge/judge_handler.py b/judge/bridge/judge_handler.py index db62bd93c..bbe484c98 100644 --- a/judge/bridge/judge_handler.py +++ b/judge/bridge/judge_handler.py @@ -76,6 +76,9 @@ def __init__(self, request, client_address, server, judges): self.judge = None self.judge_address = None + self._submission_cache_id = None + self._submission_cache = {} + def on_connect(self): self.timeout = 15 logger.info('Judge connected from: %s', self.client_address) diff --git a/judge/contest_format/ioi.py b/judge/contest_format/ioi.py index 58b9a2286..c562b29b8 100644 --- a/judge/contest_format/ioi.py +++ b/judge/contest_format/ioi.py @@ -2,7 +2,6 @@ from judge.contest_format.legacy_ioi import LegacyIOIContestFormat from judge.contest_format.registry import register_contest_format -# from judge.timezone import from_database_time @register_contest_format('ioi16') diff --git a/judge/ip_auth.py b/judge/ip_auth.py new file mode 100644 index 000000000..b493d994f --- /dev/null +++ b/judge/ip_auth.py @@ -0,0 +1,13 @@ +from django.contrib.auth.backends import ModelBackend + +from judge.models import Profile + + +class IPBasedAuthBackend(ModelBackend): + def authenticate(self, request, ip_auth): + user = None + try: + user = Profile.objects.select_related('user').get(ip_auth=ip_auth).user + except Profile.DoesNotExist: + pass + return user if self.user_can_authenticate(user) else None diff --git a/judge/management/commands/batchadduser.py b/judge/management/commands/batchadduser.py index 1a136064e..0d8369754 100644 --- a/judge/management/commands/batchadduser.py +++ b/judge/management/commands/batchadduser.py @@ -70,7 +70,7 @@ def add_user(username, fullname, password): usr.set_password(password) usr.save() - profile = Profile(user=usr, is_unlisted=True) + profile = Profile(user=usr) profile.language = Language.objects.get(key=settings.DEFAULT_USER_LANGUAGE) profile.save() diff --git a/judge/management/commands/submit_polygon_solutions.py b/judge/management/commands/submit_polygon_solutions.py index 0220374c9..71638f29f 100644 --- a/judge/management/commands/submit_polygon_solutions.py +++ b/judge/management/commands/submit_polygon_solutions.py @@ -48,22 +48,38 @@ def handle(self, *args, **options): if source_lang.startswith('cpp'): header = '/*\n' + '\n'.join(comments) + '\n*/\n\n' source_code = header + package.read(source_path).decode('utf-8') - language = Language.objects.get(key='CPP17') + language = Language.objects.get(key='CPP20') elif source_lang.startswith('java'): header = '/*\n' + '\n'.join(comments) + '\n*/\n\n' source_code = header + package.read(source_path).decode('utf-8') language = Language.objects.get(key='JAVA') + elif source_lang.startswith('pas'): + header = '{\n' + '\n'.join(comments) + '\n}\n\n' + source_code = header + package.read(source_path).decode('utf-8') + language = Language.objects.get(key='PAS') elif source_lang.startswith('python'): header = '"""\n' + '\n'.join(comments) + '\n"""\n\n' source_code = header + package.read(source_path).decode('utf-8') if source_lang == 'python.pypy2': language = Language.objects.get(key='PYPY') - elif source_lang == 'python.pypy3': + elif source_lang.startswith('python.pypy3'): language = Language.objects.get(key='PYPY3') elif source_lang == 'python.2': language = Language.objects.get(key='PY2') else: language = Language.objects.get(key='PY3') + elif source_lang.startswith('kotlin'): + header = '/*\n' + '\n'.join(comments) + '\n*/\n\n' + source_code = header + package.read(source_path).decode('utf-8') + language = Language.objects.get(key='KOTLIN') + elif source_lang == 'go': + header = '/*\n' + '\n'.join(comments) + '\n*/\n\n' + source_code = header + package.read(source_path).decode('utf-8') + language = Language.objects.get(key='GO') + elif source_lang == 'rust': + header = '/*\n' + '\n'.join(comments) + '\n*/\n\n' + source_code = header + package.read(source_path).decode('utf-8') + language = Language.objects.get(key='RUST') else: print('Unsupported language', source_lang) continue diff --git a/judge/middleware.py b/judge/middleware.py index 3d274a74f..533ac94f8 100644 --- a/judge/middleware.py +++ b/judge/middleware.py @@ -5,6 +5,7 @@ from urllib.parse import quote from django.conf import settings +from django.contrib import auth from django.contrib.auth.models import User from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache @@ -14,6 +15,7 @@ from django.utils.encoding import force_bytes from requests.exceptions import HTTPError +from judge.ip_auth import IPBasedAuthBackend from judge.models import MiscConfig, Organization try: @@ -71,6 +73,49 @@ def __call__(self, request): return self.get_response(request) +class IPBasedAuthMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + self.process_request(request) + return self.get_response(request) + + def process_request(self, request): + ip = request.META.get(settings.IP_BASED_AUTHENTICATION_HEADER, '') + if ip == '': + # Header doesn't exist, logging out + if request.user.is_authenticated: + self.logout(request) + return + + if request.user.is_authenticated: + # Retain the session if the IP field matches + if ip == request.user.profile.ip_auth: + return + + # The associated IP address doesn't match the header, logging out + self.logout(request) + + # Switch to the user associated with the IP address + user = auth.authenticate(request, ip_auth=ip) + if user: + # User is valid, logging the user in + request.user = user + auth.login(request, user) + + def logout(self, request): + # Logging the user out if the session used IP-based backend + try: + backend = auth.load_backend(request.session.get(auth.BACKEND_SESSION_KEY, '')) + except ImportError: + # Failed to load the backend, logout the user + auth.logout(request) + else: + if isinstance(backend, IPBasedAuthBackend): + auth.logout(request) + + class DMOJImpersonationMiddleware(object): def __init__(self, get_response): self.get_response = get_response diff --git a/judge/migrations/0203_add_field_vnoj_points.py b/judge/migrations/0203_add_field_vnoj_points.py new file mode 100644 index 000000000..7364d3fd6 --- /dev/null +++ b/judge/migrations/0203_add_field_vnoj_points.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.21 on 2024-03-14 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0202_import_polygon_package'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='vnoj_points', + field=models.IntegerField(default=0), + ), + ] diff --git a/judge/migrations/0204_increase_comment_page_max_length.py b/judge/migrations/0204_increase_comment_page_max_length.py new file mode 100644 index 000000000..d3d2142b0 --- /dev/null +++ b/judge/migrations/0204_increase_comment_page_max_length.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-06-25 01:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0203_add_field_vnoj_points'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='page', + field=models.CharField(db_index=True, max_length=34, validators=[django.core.validators.RegexValidator('^\\w+:[a-z0-9A-Z_]+$', 'Page code must be ^\\w+:[a-z0-9A-Z_]+$')], verbose_name='associated page'), + ), + migrations.AlterField( + model_name='commentlock', + name='page', + field=models.CharField(db_index=True, max_length=34, validators=[django.core.validators.RegexValidator('^\\w+:[a-z0-9A-Z_]+$', 'Page code must be ^\\w+:[a-z0-9A-Z_]+$')], verbose_name='associated page'), + ), + ] diff --git a/judge/migrations/0205_ip_based_auth.py b/judge/migrations/0205_ip_based_auth.py new file mode 100644 index 000000000..8b9c00a54 --- /dev/null +++ b/judge/migrations/0205_ip_based_auth.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.23 on 2024-06-23 18:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0204_increase_comment_page_max_length'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='ip_auth', + field=models.GenericIPAddressField(blank=True, null=True, unique=True, + verbose_name='IP-based authentication'), + ), + ] diff --git a/judge/models/comment.py b/judge/models/comment.py index d1d373838..319e2a35b 100644 --- a/judge/models/comment.py +++ b/judge/models/comment.py @@ -28,7 +28,7 @@ class Comment(MPTTModel): author = models.ForeignKey(Profile, verbose_name=_('commenter'), on_delete=CASCADE) time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True) - page = models.CharField(max_length=30, verbose_name=_('associated page'), db_index=True, + page = models.CharField(max_length=34, verbose_name=_('associated page'), db_index=True, validators=[comment_validator]) score = models.IntegerField(verbose_name=_('votes'), default=0) body = models.TextField(verbose_name=_('body of comment'), max_length=8192) @@ -205,7 +205,7 @@ class Meta: class CommentLock(models.Model): - page = models.CharField(max_length=30, verbose_name=_('associated page'), db_index=True, + page = models.CharField(max_length=34, verbose_name=_('associated page'), db_index=True, validators=[comment_validator]) class Meta: diff --git a/judge/models/contest.py b/judge/models/contest.py index df9719260..0ead9e046 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -619,6 +619,23 @@ def get_best_subtask_point(self): return format_data + def check_ban(self): + if not settings.VNOJ_SHOULD_BAN_FOR_CHEATING_IN_CONTESTS or self.contest.is_organization_private: + return + + disqualifications_count = ContestParticipation.objects.filter( + user=self.user, + contest__is_organization_private=False, + is_disqualified=True, + ).count() + if disqualifications_count >= settings.VNOJ_MAX_DISQUALIFICATIONS_BEFORE_BANNING and \ + not self.user.is_banned: + self.user.ban_user(settings.VNOJ_CONTEST_CHEATING_BAN_MESSAGE) + elif disqualifications_count < settings.VNOJ_MAX_DISQUALIFICATIONS_BEFORE_BANNING and \ + self.user.is_banned and self.user.ban_reason == settings.VNOJ_CONTEST_CHEATING_BAN_MESSAGE: + self.user.unban_user() + check_ban.alters_data = True + def set_disqualified(self, disqualified): self.is_disqualified = disqualified self.recompute_results() @@ -630,6 +647,7 @@ def set_disqualified(self, disqualified): self.contest.banned_users.add(self.user) else: self.contest.banned_users.remove(self.user) + self.check_ban() set_disqualified.alters_data = True @property diff --git a/judge/models/profile.py b/judge/models/profile.py index b07442820..c08c078e7 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -141,11 +141,14 @@ class Profile(models.Model): points = models.FloatField(default=0) performance_points = models.FloatField(default=0) contribution_points = models.IntegerField(default=0) + vnoj_points = models.IntegerField(default=0) problem_count = models.IntegerField(default=0) ace_theme = models.CharField(max_length=30, verbose_name=_('Ace theme'), choices=ACE_THEMES, default='auto') site_theme = models.CharField(max_length=10, verbose_name=_('site theme'), choices=SITE_THEMES, default='light') last_access = models.DateTimeField(verbose_name=_('last access time'), default=now) ip = models.GenericIPAddressField(verbose_name=_('last IP'), blank=True, null=True) + ip_auth = models.GenericIPAddressField(verbose_name=_('IP-based authentication'), + unique=True, blank=True, null=True) badges = models.ManyToManyField(Badge, verbose_name=_('badges'), blank=True, related_name='users') display_badge = models.ForeignKey(Badge, verbose_name=_('display badge'), null=True, on_delete=models.SET_NULL) organizations = SortedManyToManyField(Organization, verbose_name=_('organization'), blank=True, diff --git a/judge/models/submission.py b/judge/models/submission.py index 8092a9bd1..06469971f 100644 --- a/judge/models/submission.py +++ b/judge/models/submission.py @@ -77,7 +77,7 @@ class Submission(models.Model): date = models.DateTimeField(verbose_name=_('submission time'), auto_now_add=True, db_index=True) time = models.FloatField(verbose_name=_('execution time'), null=True) memory = models.FloatField(verbose_name=_('memory usage'), null=True) - points = models.IntegerField(verbose_name=_('points granted'), null=True) + points = models.FloatField(verbose_name=_('points granted'), null=True) language = models.ForeignKey(Language, verbose_name=_('submission language'), on_delete=models.CASCADE, db_index=False) status = models.CharField(verbose_name=_('status'), max_length=2, choices=STATUS, default='QU', db_index=True) diff --git a/judge/social_auth.py b/judge/social_auth.py index f8fcc2453..748121366 100644 --- a/judge/social_auth.py +++ b/judge/social_auth.py @@ -4,6 +4,7 @@ from urllib.parse import quote from django import forms +from django.contrib.auth import password_validation from django.contrib.auth.models import User from django.http import HttpResponseRedirect from django.shortcuts import render @@ -56,6 +57,15 @@ class UsernameForm(forms.Form): username = forms.RegexField(regex=r'^\w+$', max_length=30, label='Username', error_messages={'invalid': 'A username must contain letters, numbers, or underscores.'}) + +class SocialPostAuthForm(forms.Form): + username = forms.RegexField(regex=re.compile(r'^\w+$', re.ASCII), max_length=30, label='Username', + error_messages={'invalid': 'A username must contain letters, numbers, or underscores.'}) + password = forms.CharField(label='Password', strip=False, widget=forms.PasswordInput(), + help_text=password_validation.password_validators_help_text_html(), + validators=[password_validation.validate_password]) + password_confirm = forms.CharField(label='Retype password', widget=forms.PasswordInput(), strip=False) + def clean_username(self): if User.objects.filter(username=self.cleaned_data['username']).exists(): raise forms.ValidationError('Sorry, the username is taken.') @@ -76,6 +86,35 @@ def choose_username(backend, user, username=None, *args, **kwargs): 'title': 'Choose a username', 'form': form, }) + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get('password') + password_confirm = cleaned_data.get('password_confirm') + if password and password_confirm and password != password_confirm: + self.add_error('password_confirm', "Passwords didn't match") + + +@partial +def get_username_password(backend, user, username=None, *args, **kwargs): + if not user: + request = backend.strategy.request + if request.POST: + form = SocialPostAuthForm(request.POST) + if form.is_valid(): + return {'username': form.cleaned_data['username'], + 'password': form.cleaned_data['password']} + else: + form = SocialPostAuthForm(initial={'username': username}) + return render(request, 'registration/username_select.html', { + 'title': 'Set up your account', 'form': form, + }) + + +def add_password(user, password=None, *args, **kwargs): + if password: + user.set_password(password) + user.save() + @partial def make_profile(backend, user, response, is_new=False, *args, **kwargs): diff --git a/judge/views/magazine.py b/judge/views/magazine.py new file mode 100644 index 000000000..b4b352a48 --- /dev/null +++ b/judge/views/magazine.py @@ -0,0 +1,8 @@ +from django.views.generic import TemplateView + +from judge.utils.views import TitleMixin + + +class MagazinePage(TitleMixin, TemplateView): + title = 'VNOI Magazine 2024' + template_name = 'magazine.html' diff --git a/judge/views/organization.py b/judge/views/organization.py index 54fb59390..1bd68d70b 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -480,6 +480,7 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super(OrganizationHome, self).get_context_data(**kwargs) + context['page_prefix'] = reverse('organization_home', args=[self.object.slug]) + '/' context['first_page_href'] = reverse('organization_home', args=[self.object.slug]) context['title'] = self.object.name context['can_edit'] = self.can_edit_organization() diff --git a/judge/widgets/select2.py b/judge/widgets/select2.py index 8ae065985..77259c3cf 100644 --- a/judge/widgets/select2.py +++ b/judge/widgets/select2.py @@ -201,6 +201,7 @@ def build_attrs(self, base_attrs, extra_attrs=None): attrs['data-field_id'] = self.widget_id attrs.setdefault('data-ajax--url', self.get_url()) + attrs.setdefault('data-ajax--delay', 300) attrs.setdefault('data-ajax--cache', 'true') attrs.setdefault('data-ajax--type', 'GET') attrs.setdefault('data-minimum-input-length', 2) diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index a7824f7db..bdaa3c8a6 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -6153,6 +6153,44 @@ msgstr "" msgid "Get in touch with us" msgstr "" +#: templates/registration/activation_email.html:1 +#, python-format +msgid "Thanks for registering on the %(site_name)s! We're glad to have you." +msgstr "" + +#: templates/registration/activation_email.html:3 +#: templates/registration/activation_email.txt:1 +#, python-format +msgid "" +"Please activate your %(site_name)s account in the next %(expiration_days)d " +"days." +msgstr "" + +#: templates/registration/activation_email.html:5 +#: templates/registration/activation_email.txt:3 +msgid "Please click on the following link to activate your account:" +msgstr "" + +#: templates/registration/activation_email.html:10 +#: templates/registration/activation_email.txt:6 +msgid "Alternatively, you can reply to this message to activate your account." +msgstr "" + +#: templates/registration/activation_email.html:11 +#: templates/registration/activation_email.txt:7 +msgid "Your reply must keep the following text intact for this to work:" +msgstr "" + +#: templates/registration/activation_email.html:17 +msgid "See you soon!" +msgstr "" + +#: templates/registration/activation_email.html:19 +msgid "" +"If you have problems activating your account, feel free to shoot us a " +"message at: " +msgstr "" + #: templates/registration/activation_email_subject.txt:1 #, python-format msgid "Welcome to QHHOJ - Activate Your Account" @@ -6250,6 +6288,45 @@ msgstr "" msgid "If you have problems resetting your password, feel free to reply to this email." msgstr "" +#: templates/registration/password_reset_done.html:6 +#: templates/registration/password_reset_email.html:11 +msgid "" +"See you soon! If you have problems resetting your password, feel free to " +"shoot us a message at: " +msgstr "" + +#: templates/registration/password_reset_email.html:6 +#, python-format +msgid "" +"Forgot your password on the %(site_name)s? Don't worry!

\n" +"To reset the password for your account \"%(username)s\", click the button " +"below." +msgstr "" + +#: templates/registration/password_reset_email.txt:1 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" + +#: templates/registration/password_reset_email.txt:3 +msgid "Please go to the following page and choose a new password:" +msgstr "" + +#: templates/registration/password_reset_email.txt:7 +msgid "Your username, in case you've forgotten:" +msgstr "" + +#: templates/registration/password_reset_email.txt:9 +msgid "Thanks for using our site!" +msgstr "" + +#: templates/registration/password_reset_email.txt:11 +#, python-format +msgid "The %(site_name)s team" +msgstr "" + #: templates/registration/password_reset_subject.txt:1 #, python-format msgid "Your Account on QHHOJ - Reset Your Password" @@ -6270,6 +6347,12 @@ msgid "" "address you provided to confirm your registration." msgstr "" +#: templates/registration/registration_complete.html:4 +msgid "" +"See you soon! If you have problems activating your account, feel free to " +"shoot us a message at: " +msgstr "" + #: templates/registration/registration_form.html:137 #: templates/registration/registration_form.html:191 msgid "can be blank" diff --git a/locale/vi/LC_MESSAGES/django.po b/locale/vi/LC_MESSAGES/django.po index 2fa02309a..b8d48ecab 100644 --- a/locale/vi/LC_MESSAGES/django.po +++ b/locale/vi/LC_MESSAGES/django.po @@ -6439,6 +6439,52 @@ msgstr "Liên hệ với chúng mình" #, python-format msgid "Welcome to QHHOJ - Activate Your Account" msgstr "Chào mừng đến với QHHOJ - Vui lòng kích hoạt tài khoản của bạn" +#: templates/registration/activation_email.html:1 +#, python-format +msgid "Thanks for registering on the %(site_name)s! We're glad to have you." +msgstr "Cảm ơn bạn đã đăng ký tài khoản ở trang %(site_name)s!" + +#: templates/registration/activation_email.html:3 +#: templates/registration/activation_email.txt:1 +#, python-format +msgid "" +"Please activate your %(site_name)s account in the next %(expiration_days)d " +"days." +msgstr "" +"Hãy kích hoạt tài khoản %(site_name)s của bạn trong vòng %(expiration_days)d " +"tới" + +#: templates/registration/activation_email.html:5 +#: templates/registration/activation_email.txt:3 +msgid "Please click on the following link to activate your account:" +msgstr "Hãy nhấn vào link này để kích hoạt tài khoản:" + +#: templates/registration/activation_email.html:10 +#: templates/registration/activation_email.txt:6 +msgid "Alternatively, you can reply to this message to activate your account." +msgstr "Hoặc bạn có thể reply lại email này để kích hoạt." + +#: templates/registration/activation_email.html:11 +#: templates/registration/activation_email.txt:7 +msgid "Your reply must keep the following text intact for this to work:" +msgstr "Email reply của bạn cần có đoạn text sau:" + +#: templates/registration/activation_email.html:17 +msgid "See you soon!" +msgstr "Hẹn gặp lại!" + +#: templates/registration/activation_email.html:19 +msgid "" +"If you have problems activating your account, feel free to shoot us a " +"message at: " +msgstr "" +"Nếu bạn có vấn đề với việc kích hoạt tài khoản, hãy liên hệ với chúng mình " +"qua: " + +#: templates/registration/activation_email_subject.txt:1 +#, python-format +msgid "Activate your %(SITE_NAME)s account)" +msgstr "Kích hoạt tài khoản %(SITE_NAME)s" #: templates/registration/login.html:35 msgid "Invalid username or password." @@ -6538,6 +6584,57 @@ msgstr "Nếu bạn còn gặp vấn đề với việc đặt lại mật khẩ #, python-format msgid "Your Account on QHHOJ - Reset Your Password" msgstr "Tài khoản QHHOJ - Đặt lại mật khẩu" +#: templates/registration/password_reset_done.html:6 +#: templates/registration/password_reset_email.html:11 +msgid "" +"See you soon! If you have problems resetting your password, feel free to " +"shoot us a message at: " +msgstr "" +"Nếu bạn có vấn đề với việc đặt lại mật khẩu, hãy liên hệ với chúng mình qua: " + +#: templates/registration/password_reset_email.html:6 +#, python-format +msgid "Forgot your password on the %(site_name)s? Don't worry!" +msgstr "Bạn quên mất mật khẩu trên %(site_name)s? Đừng lo lắng!" + +#: templates/registration/password_reset_email.html:7 +#, python-format +msgid "" +"To reset the password for your account \"%(username)s\", click the button " +"below." +msgstr "" +"Để đặt lại mật khẩu của tài khoản \"%(username)s\", nhấn vào link phía dưới." + +#: templates/registration/password_reset_email.txt:1 +#, python-format +msgid "" +"You're receiving this email because you requested a password reset for your " +"user account at %(site_name)s." +msgstr "" +"Bạn nhận được email này bởi vì bạn đã yêu cầu đặt lại mật khẩu cho tài khoản " +"của mình tại %(site_name)s." + +#: templates/registration/password_reset_email.txt:3 +msgid "Please go to the following page and choose a new password:" +msgstr "Hãy đi đến trang sau và chọn một mật khẩu mới:" + +#: templates/registration/password_reset_email.txt:7 +msgid "Your username, in case you've forgotten:" +msgstr "Tên người dùng của bạn, trong trường hợp bạn quên:" + +#: templates/registration/password_reset_email.txt:9 +msgid "Thanks for using our site!" +msgstr "Cảm ơn đã sử dụng hệ thống của chúng tôi!" + +#: templates/registration/password_reset_email.txt:11 +#, python-format +msgid "The %(site_name)s team" +msgstr "Đội ngũ %(site_name)s" + +#: templates/registration/password_reset_subject.txt:1 +#, python-format +msgid "Password reset on %(site_name)s" +msgstr "Đặt lại mật khẩu tại %(site_name)s" #: templates/registration/profile_creation.html:44 #: templates/registration/username_select.html:7 @@ -6556,6 +6653,14 @@ msgstr "" "Cảm ơn bạn đã đăng ký. Một email đã được gửi đến cho bạn để xác nhận đăng ký " "của bạn." +#: templates/registration/registration_complete.html:4 +msgid "" +"See you soon! If you have problems activating your account, feel free to " +"shoot us a message at: " +msgstr "" +"Nếu bạn có vấn đề với việc kích hoạt tài khoản, hãy liên hệ với chúng mình " +"qua: " + #: templates/registration/registration_form.html:143 #: templates/registration/registration_form.html:197 msgid "can be blank" diff --git a/resources/base.scss b/resources/base.scss index 4fcee7239..a0cb05575 100644 --- a/resources/base.scss +++ b/resources/base.scss @@ -399,4 +399,4 @@ math { 100% { transform: scale(1, 1); } -} \ No newline at end of file +} diff --git a/resources/contest.scss b/resources/contest.scss index 34ef7e2d0..b2ec0655e 100644 --- a/resources/contest.scss +++ b/resources/contest.scss @@ -127,6 +127,7 @@ } .contest-tags { + padding-left: 0.75em; vertical-align: top; } diff --git a/resources/icons/favicon.ico b/resources/icons/favicon.ico new file mode 100644 index 000000000..a70ee261c Binary files /dev/null and b/resources/icons/favicon.ico differ diff --git a/resources/source_sans_pro.css b/resources/source_sans_pro.css new file mode 100644 index 000000000..c2d5cfd48 --- /dev/null +++ b/resources/source_sans_pro.css @@ -0,0 +1,63 @@ +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNa7lqDY.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qPK7lqDY.woff2) format('woff2'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNK7lqDY.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qO67lqDY.woff2) format('woff2'); + unicode-range: U+0370-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qN67lqDY.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNq7lqDY.woff2) format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/vnoj/sourcesanspro/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7l.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 000000000..c5c2e2ca1 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,15 @@ +{% extends "admin/base_site.html" %} + +{% block pretitle %}{{ block.super }} + + +{% endblock %} diff --git a/templates/admin/judge/contest/change_form.html b/templates/admin/judge/contest/change_form.html index 686d1fc41..b5e5d4c7e 100644 --- a/templates/admin/judge/contest/change_form.html +++ b/templates/admin/judge/contest/change_form.html @@ -21,7 +21,7 @@ {% block after_field_sets %}{{ block.super }} {% if original and original.is_rated and original.ended and perms.judge.contest_rating %} diff --git a/templates/admin/judge/contest/change_list.html b/templates/admin/judge/contest/change_list.html index ae409fc83..6095ef303 100644 --- a/templates/admin/judge/contest/change_list.html +++ b/templates/admin/judge/contest/change_list.html @@ -5,7 +5,7 @@ {{ block.super }} {% if not is_popup and perms.judge.contest_rating %}
  • - + {% trans "Rate all ratable contests" %}
  • diff --git a/templates/admin/judge/judge/change_form.html b/templates/admin/judge/judge/change_form.html index 738d8a5c3..a97af4a16 100644 --- a/templates/admin/judge/judge/change_form.html +++ b/templates/admin/judge/judge/change_form.html @@ -14,24 +14,24 @@ {% block after_field_sets %}{{ block.super }} {% if original %} {% if not original.is_disabled %} {% else %} diff --git a/templates/admin/judge/submission/change_form.html b/templates/admin/judge/submission/change_form.html index 0b5e5bf6c..96ebba97b 100644 --- a/templates/admin/judge/submission/change_form.html +++ b/templates/admin/judge/submission/change_form.html @@ -12,7 +12,7 @@ {% block after_field_sets %}{{ block.super }} {% if original and not original.is_locked %} diff --git a/templates/contest/list.html b/templates/contest/list.html index 9e3964c82..5343ca29b 100644 --- a/templates/contest/list.html +++ b/templates/contest/list.html @@ -70,12 +70,10 @@ {% macro contest_head(contest) %} {% spaceless %} - -
    + + {{- contest.name -}} + + {% if not contest.is_visible %} {{ _('hidden') }} @@ -101,13 +99,14 @@ {% endif %} {% for tag in contest.tags.all() %} - -  {{- tag.full_name -}} + + {{- tag.name -}} {% endfor %} -
    + {% endspaceless %} {% endmacro %} diff --git a/templates/contest/ranking.html b/templates/contest/ranking.html index 01cb9f9a8..8f5cace08 100644 --- a/templates/contest/ranking.html +++ b/templates/contest/ranking.html @@ -104,7 +104,8 @@ theme: '{{ DMOJ_SELECT2_THEME }}', placeholder: placeholder, ajax: { - url: '{{ url('contest_user_search_select2_ajax', contest.key) }}' + url: '{{ url('contest_user_search_select2_ajax', contest.key) }}', + delay: 300 }, minimumInputLength: 1, templateResult: function (data) { diff --git a/templates/leave-warning.html b/templates/leave-warning.html index 7d865ab8c..2a40540e7 100644 --- a/templates/leave-warning.html +++ b/templates/leave-warning.html @@ -1,21 +1,9 @@ diff --git a/templates/magazine.html b/templates/magazine.html new file mode 100644 index 000000000..71a284ed6 --- /dev/null +++ b/templates/magazine.html @@ -0,0 +1,366 @@ +{% extends "base.html" %} + +{% block body %} + + + + + + + +
    + + + + + + + + + +
    + +
    + + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/organization/requests/pending.html b/templates/organization/requests/pending.html index 892b78cca..518cd6665 100644 --- a/templates/organization/requests/pending.html +++ b/templates/organization/requests/pending.html @@ -1,5 +1,24 @@ {% extends "base.html" %} {% block body %} + {% include "messages.html" %} {% include "organization/requests/tabs.html" %} @@ -12,7 +31,7 @@ {{ _('User') }} {{ _('Time') }} {{ _('State') }} - {{ _('Reason') }} + {{ _('Reason') }} {% if formset.can_delete %} {{ _('Delete?') }} {% endif %} @@ -24,7 +43,7 @@ {{ form.instance.time|date(_("N j, Y, H:i")) }} {{ form.state }} - {{ form.instance.reason|truncatechars(50) }} + {{ form.instance.reason }} {% if formset.can_delete %} {{ form.DELETE }} {% endif %} diff --git a/templates/problem/data.html b/templates/problem/data.html index 22cce8b17..1aedde415 100644 --- a/templates/problem/data.html +++ b/templates/problem/data.html @@ -4,6 +4,7 @@ {{data_form.media.js}} {% include "leave-warning.html" %} + +{% endblock %} + {% block body %} -
    +
    + {% csrf_token %} - {{ form.as_table() }}
    - + +
    {{ form.username.label }}
    + {{ form.username }} + {% if form.username.errors %} +
    {{ form.username.errors }}
    + {% endif %} + +
    {{ form.password.label }} + (?) +
    + + {{ form.password }} + {% if form.password.errors %} +
    {{ form.password.errors }}
    + {% endif %} + +
    {{ form.password_confirm.label }}
    + {{ form.password_confirm }} + {% if form.password_confirm.errors %} +
    {{ form.password_confirm.errors }}
    + {% endif %} + +
    + +
    {% endblock %} diff --git a/templates/ticket/list.html b/templates/ticket/list.html index adef937d5..dc27f23cc 100644 --- a/templates/ticket/list.html +++ b/templates/ticket/list.html @@ -128,9 +128,9 @@ }; $('#filter-user').select2($.extend(true, {}, user_select2, - {ajax: {url: '{{ url('ticket_user_select2_ajax') }}'}})); + {ajax: {url: '{{ url('ticket_user_select2_ajax') }}', delay: 300}})); $('#filter-assignee').select2($.extend(true, {}, user_select2, - {ajax: {url: '{{ url('ticket_assignee_select2_ajax') }}'}})); + {ajax: {url: '{{ url('ticket_assignee_select2_ajax') }}', delay: 300}})); }); diff --git a/templates/user/base-users.html b/templates/user/base-users.html index f857ec5ed..4bb1858eb 100644 --- a/templates/user/base-users.html +++ b/templates/user/base-users.html @@ -14,7 +14,8 @@ theme: '{{ DMOJ_SELECT2_THEME }}', placeholder: '{{ _('Search by handle...') }}', ajax: { - url: '{{ url('user_search_select2_ajax') }}' + url: '{{ url('user_search_select2_ajax') }}', + delay: 300 }, minimumInputLength: 1, templateResult: function (data, container) { diff --git a/templates/user/contrib-list.html b/templates/user/contrib-list.html index c7fcea054..fdd326ce1 100644 --- a/templates/user/contrib-list.html +++ b/templates/user/contrib-list.html @@ -14,7 +14,8 @@ theme: '{{ DMOJ_SELECT2_THEME }}', placeholder: '{{ _('Search by handle...') }}', ajax: { - url: '{{ url('user_search_select2_ajax') }}' + url: '{{ url('user_search_select2_ajax') }}', + delay: 300 }, minimumInputLength: 1, templateResult: function (data, container) {