diff --git a/.flake8 b/.flake8 index f9c313430..f467e0cc1 100644 --- a/.flake8 +++ b/.flake8 @@ -4,10 +4,12 @@ application-import-names = dmoj,judge,django_ace import-order-style = pycharm enable-extensions = G ignore = - W504, # line break occurred after a binary operator + # line break occurred after a binary operator + W504, # allow only generator_stop and annotations future imports FI10,FI11,FI12,FI13,FI14,FI15,FI16,FI17,FI18,FI55,FI58, - C814, # missing trailing comma in Python 2 only + # missing trailing comma in Python 2 only + C814, per-file-ignores = # F401: unused imports, ignore in all __init__.py # F403: import * @@ -20,7 +22,10 @@ per-file-ignores = # PyCharm likes to have double lines between class/def in an if statement. ./judge/widgets/pagedown.py:E303 exclude = - ./dmoj/local_settings.py, # belongs to the user - ./dmoj/local_urls.py, # belongs to the user - ./.ci.settings.py, # is actually a fragment to be included by settings.py + # belongs to the user + ./dmoj/local_settings.py, + # belongs to the user + ./dmoj/local_urls.py, + # is actually a fragment to be included by settings.py + ./.ci.settings.py, fc_* diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9168b332d..da35229b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,11 +4,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: '3.7' - name: Install flake8 run: pip install flake8 flake8-import-order flake8-future-import flake8-commas flake8-logging-format flake8-quotes - name: Lint with flake8 @@ -18,11 +18,11 @@ jobs: unit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: '3.7' - name: Cache pip uses: actions/cache@v2 with: @@ -43,7 +43,7 @@ jobs: styles: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Node 14 uses: actions/setup-node@v2 with: diff --git a/.github/workflows/caniuse.yml b/.github/workflows/caniuse.yml index 009efe939..976796407 100644 --- a/.github/workflows/caniuse.yml +++ b/.github/workflows/caniuse.yml @@ -7,12 +7,12 @@ jobs: if: github.repository == 'DMOJ/online-judge' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download Can I use... data run: | curl -s https://raw.githubusercontent.com/Fyrd/caniuse/master/data.json | python3 -m json.tool > resources/caniuse.json - name: Create pull request - uses: peter-evans/create-pull-request@v3 + uses: peter-evans/create-pull-request@v4 with: token: ${{ secrets.REPO_SCOPED_TOKEN }} author: dmoj-build diff --git a/.github/workflows/compilemessages.yml b/.github/workflows/compilemessages.yml index 94ca4325d..fbffa40e5 100644 --- a/.github/workflows/compilemessages.yml +++ b/.github/workflows/compilemessages.yml @@ -10,11 +10,11 @@ jobs: compilemessages: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: '3.7' - name: Checkout submodules run: | git submodule init diff --git a/.github/workflows/updatemessages.yml b/.github/workflows/updatemessages.yml index f6630ba60..03d0cae89 100644 --- a/.github/workflows/updatemessages.yml +++ b/.github/workflows/updatemessages.yml @@ -7,13 +7,13 @@ jobs: if: github.repository == 'DMOJ/online-judge' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python 3.7 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: '3.7' - name: Checkout submodules run: | git submodule init @@ -87,7 +87,7 @@ jobs: git reset --hard "$i18n_head" fi - name: Create pull request - uses: peter-evans/create-pull-request@v3 + uses: peter-evans/create-pull-request@v4 with: token: ${{ secrets.REPO_SCOPED_TOKEN }} author: dmoj-build diff --git a/.gitignore b/.gitignore index 9d43df90a..23bffc56c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,11 @@ dmoj-site.pid dmoj-site.sock uwsgi.ini .fuse_hidden* -resources/style.css -resources/content-description.css -resources/ranks.css -resources/table.css +resources/dark resources/martor-description.css +resources/select2-dmoj.css +resources/style.css +resources/vars.scss sass_processed bridge_log.txt _mytempfile/ diff --git a/README.md b/README.md index 29a1dd540..3f0e498dd 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,42 @@ # VNOJ: VNOI Online Judge [![Build Status](https://github.com/VNOI-Admin/OJ/workflows/build/badge.svg)](https://github.com/VNOI-Admin/OJ/actions/) [![AGPL License](https://img.shields.io/badge/license-AGPLv3.0-blue.svg)](http://www.gnu.org/licenses/agpl-3.0) [![Discord link](https://img.shields.io/discord/660930260405190688?color=%237289DA&label=Discord&logo=Discord)](https://discord.com/invite/TDyYVyd) -As a fork of [DMOJ](https://github.com/DMOJ/online-judge), VNOJ serves as the official online judge and programming contests of [VNOI](https://vnoi.info/). - +As a fork of [DMOJ](https://github.com/DMOJ/online-judge), VNOJ serves as the official online judge and programming contests of [VNOI](https://vnoi.info/). See it live at [oj.vnoi.info](http://oj.vnoi.info/)! ## Features + Checkout the features listed [here](https://github.com/DMOJ/online-judge#features). Addition features: -- Beside Python checkers [here](https://docs.dmoj.ca/#/problem_format/custom_checkers), we can write custom C++ checker using `testlib.h`. + +- Beside Python checkers [here](https://docs.dmoj.ca/#/problem_format/custom_checkers), we can write custom C++ checker using `testlib.h`. ## Installation + Check out the install documentation at [docs.dmoj.ca](https://docs.dmoj.ca/#/site/installation). Almost all installation steps is the same as the docs, there is one minor change: clone this repo instead of dmoj repo. ### Additional step in installation: + - You **have to** define `DMOJ_PROBLEM_DATA_ROOT` in `local_settings.py`, this is path to your problems tests folder. + - Considering to disable Full text search, please check [this issuse](https://github.com/VNOI-Admin/OJ/issues/4) for more information. + - To sync the caching of judge server and site, change cache framework (`CACHES`) to `memcached` or `redis` instead of the default (local-memory caching). -- The "home button" the admin dashboard (/admin) will redirect to `localhost:8081` if you use `python3 manage.py loaddata demo`, there is 2 ways to fix it: - 1. You can change that in [demo.json](judge/fixtures/demo.json) - 2. You can go to the admin page, scoll down to find the `Sites` settings and change `localhost:8081` to your domain. + +- The "home button" the admin dashboard (/admin) will redirect to `localhost:8081` if you use `python3 manage.py loaddata demo`, there is 2 ways to fix it: + + 1. You can change that in [demo.json](judge/fixtures/demo.json) + 2. You can go to the admin page, scoll down to find the `Sites` settings and change `localhost:8081` to your domain. + - To support `testlib.h`, you need to copy the [testlib.h](https://github.com/MikeMirzayanov/testlib/blob/master/testlib.h) to g++ include path in judge server. To speed up compiler time, you may create the precompiled header to `testlib.h`. ## Contributing ![PR's Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat) Take a look at [our contribution guideline](contributing.md). -If you found any bug, please feel free to contact us via Discord [![Discord Chat](https://img.shields.io/discord/660930260405190688?color=%237289DA&label=Discord&logo=Discord)](https://discord.gg/TDyYVyd) or open an issue. +If you found any bug, please feel free to contact us via Discord [![Discord Chat](https://img.shields.io/discord/660930260405190688?color=%237289DA&label=Discord&logo=Discord)](https://discord.gg/TDyYVyd) or open an issue. -Pull requests are welcome as well. Before you submitting your PR, please check your code with [flake8](https://flake8.pycqa.org/en/latest/) and format it if needed. +Pull requests are welcome as well. Before you submitting your PR, please check your code with [flake8](https://flake8.pycqa.org/en/latest/) and format it if needed. Translation contributions are also welcome. diff --git a/django_2_2_pymysql_patch.py b/django_2_2_pymysql_patch.py deleted file mode 100644 index db94d4772..000000000 --- a/django_2_2_pymysql_patch.py +++ /dev/null @@ -1,17 +0,0 @@ -import django -from django.utils.encoding import force_str - -if (2, 2) <= django.VERSION < (3,): - # Django 2.2.x is incompatible with PyMySQL. - # This monkey patch backports the Django 3.0+ code. - - from django.db.backends.mysql.operations import DatabaseOperations - - def last_executed_query(self, cursor, sql, params): - # With MySQLdb, cursor objects have an (undocumented) "_executed" - # attribute where the exact query sent to the database is saved. - # See MySQLdb/cursors.py in the source distribution. - # MySQLdb returns string, PyMySQL bytes. - return force_str(getattr(cursor, '_executed', None), errors='replace') - - DatabaseOperations.last_executed_query = last_executed_query diff --git a/django_ace/static/django_ace/widget.js b/django_ace/static/django_ace/widget.js index ce5cd830c..7da6f3243 100644 --- a/django_ace/static/django_ace/widget.js +++ b/django_ace/static/django_ace/widget.js @@ -76,6 +76,8 @@ editor = ace.edit(div), mode = widget.getAttribute('data-mode'), theme = widget.getAttribute('data-theme'), + default_light_theme = widget.getAttribute('data-default-light-theme'), + default_dark_theme = widget.getAttribute('data-default-dark-theme'), wordwrap = widget.getAttribute('data-wordwrap'), toolbar = prev(widget), main_block = toolbar.parentNode; @@ -98,6 +100,21 @@ } if (theme) { editor.setTheme("ace/theme/" + theme); + } else { + if (window.matchMedia) { + const setEditorTheme = function (is_dark) { + if (is_dark) { + editor.setTheme("ace/theme/" + default_dark_theme); + } else { + editor.setTheme("ace/theme/" + default_light_theme); + } + } + + setEditorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(ev) { + setEditorTheme(ev.matches); + }) + } } if (wordwrap == "true") { editor.getSession().setUseWrapMode(true); diff --git a/django_ace/widgets.py b/django_ace/widgets.py index 91369d039..2f521bf7f 100644 --- a/django_ace/widgets.py +++ b/django_ace/widgets.py @@ -42,6 +42,8 @@ def render(self, name, value, attrs=None, renderer=None): ace_attrs['data-mode'] = self.mode if self.theme: ace_attrs['data-theme'] = self.theme + ace_attrs['data-default-light-theme'] = settings.ACE_DEFAULT_LIGHT_THEME + ace_attrs['data-default-dark-theme'] = settings.ACE_DEFAULT_DARK_THEME if self.wordwrap: ace_attrs['data-wordwrap'] = 'true' diff --git a/dmoj/celery.py b/dmoj/celery.py index 96718eaff..e1da64064 100644 --- a/dmoj/celery.py +++ b/dmoj/celery.py @@ -17,7 +17,7 @@ # Load task modules from all registered Django app configs. app.autodiscover_tasks() -# Logger to enable errors be reported. +# Logger to enable reporting of errors. logger = logging.getLogger('judge.celery') diff --git a/dmoj/settings.py b/dmoj/settings.py index 858d09a45..e45ba5681 100755 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -2,16 +2,15 @@ Django settings for dmoj project. For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ +https://docs.djangoproject.com/en/3.2/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ +https://docs.djangoproject.com/en/3.2/ref/settings/ """ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import datetime import os -import tempfile from django.utils.translation import gettext_lazy as _ from django_jinja.builtins import DEFAULT_EXTENSIONS @@ -20,7 +19,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = '5*9f5q57mqmlz2#f$x1h76&jxy#yortjl1v+l*6hd18$d*yx#0' @@ -35,7 +34,7 @@ SITE_ID = 1 SITE_NAME = 'DMOJ' SITE_LONG_NAME = 'DMOJ: Modern Online Judge' -SITE_ADMIN_EMAIL = False +SITE_ADMIN_EMAIL = '' DMOJ_REQUIRE_STAFF_2FA = True # Display warnings that admins will not perform 2FA recovery. @@ -49,7 +48,7 @@ # Refer to dmoj.ca/post/103-point-system-rework DMOJ_PP_STEP = 0.98514 DMOJ_PP_ENTRIES = 300 -DMOJ_PP_BONUS_FUNCTION = lambda n: 0.05 * n # 15 * (1 - 0.997 ** n) # noqa: E731; 100 bai nua diem: 0.9930924 +DMOJ_PP_BONUS_FUNCTION = lambda n: 0.05 * n # 15 * (1 - 0.997 ** n) # noqa: E731; 100 bai nua diem: 0.9930924 VNOJ_ORG_PP_STEP = 0.95 VNOJ_ORG_PP_ENTRIES = 100 @@ -59,9 +58,9 @@ # Contribution points function # Both should be int -VNOJ_CP_COMMENT = 1 # Each comment vote equals 1 CP -VNOJ_CP_TICKET = 10 # Each good ticket equals CP -VNOJ_CP_PROBLEM = 20 # Each suggested problem equal 20 CP +VNOJ_CP_COMMENT = 1 # Each comment vote equals 1 CP +VNOJ_CP_TICKET = 10 # Each good ticket equals CP +VNOJ_CP_PROBLEM = 20 # Each suggested problem equal 20 CP VNOJ_HOMEPAGE_TOP_USERS_COUNT = 5 @@ -90,6 +89,8 @@ # 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 +# Minimum problem count required to interact (comment, vote and update profile) +VNOJ_INTERACT_MIN_PROBLEM_COUNT = 5 # Minimum problem count required to create new blogs VNOJ_BLOG_MIN_PROBLEM_COUNT = 10 @@ -107,7 +108,7 @@ 'regex': r'^https://codeforces\.com/problemset/problem/(?P\w+)/(?P\w+)$', 'codename': 'CF_%s_%s', 'judge': 'Codeforces', - }, + }, { 'regex': r'^https://codeforces\.com/contest/(?P\w+)/problem/(?P\w+)$', 'codename': 'CF_%s_%s', @@ -150,7 +151,7 @@ # Urls of discord webhook. # https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks DISCORD_WEBHOOK = { - 'default': None, # use this link if the specific link not found + 'default': None, # use this link if the specific link not found 'on_new_ticket': None, 'on_new_comment': None, 'on_new_problem': None, @@ -163,51 +164,62 @@ SITE_FULL_URL = None # ie 'https://oj.vnoi.info', please remove the last / if needed -NODEJS = '/usr/bin/node' -EXIFTOOL = '/usr/bin/exiftool' 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' -DEFAULT_SELECT2_CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css' +SELECT2_CSS_URL = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css' DMOJ_CAMO_URL = None DMOJ_CAMO_KEY = None DMOJ_CAMO_HTTPS = False DMOJ_CAMO_EXCLUDE = () + DMOJ_PROBLEM_DATA_ROOT = None + DMOJ_PROBLEM_MIN_TIME_LIMIT = 0 # seconds DMOJ_PROBLEM_MAX_TIME_LIMIT = 60 # seconds DMOJ_PROBLEM_MIN_MEMORY_LIMIT = 0 # kilobytes DMOJ_PROBLEM_MAX_MEMORY_LIMIT = 1048576 # kilobytes DMOJ_PROBLEM_MIN_PROBLEM_POINTS = 0 DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7 -DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’'} + +DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’', '−', 'ff', 'fi', 'fl', 'ffi', 'ffl'} DMOJ_RATING_COLORS = True DMOJ_EMAIL_THROTTLING = (10, 60) -VNOJ_DISCORD_WEBHOOK_THROTTLING = (10, 60) # Max 10 messages in 60 seconds -DMOJ_STATS_LANGUAGE_THRESHOLD = 10 -DMOJ_SUBMISSIONS_REJUDGE_LIMIT = 10 +VNOJ_DISCORD_WEBHOOK_THROTTLING = (10, 60) # Max 10 messages in 60 seconds + # Maximum number of submissions a single user can queue without the `spam_submission` permission DMOJ_SUBMISSION_LIMIT = 2 +DMOJ_SUBMISSIONS_REJUDGE_LIMIT = 10 + # Whether to allow users to view source code: 'all' | 'all-solved' | 'only-own' DMOJ_SUBMISSION_SOURCE_VISIBILITY = 'all-solved' DMOJ_BLOG_NEW_PROBLEM_COUNT = 7 -DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT = 7 DMOJ_TOTP_TOLERANCE_HALF_MINUTES = 1 DMOJ_SCRATCH_CODES_COUNT = 5 DMOJ_USER_MAX_ORGANIZATION_COUNT = 3 + # Whether to allow users to download their data DMOJ_USER_DATA_DOWNLOAD = False DMOJ_USER_DATA_CACHE = '' DMOJ_USER_DATA_INTERNAL = '' DMOJ_USER_DATA_DOWNLOAD_RATELIMIT = datetime.timedelta(days=1) + # Whether to allow contest authors to download contest data DMOJ_CONTEST_DATA_DOWNLOAD = False DMOJ_CONTEST_DATA_CACHE = '' DMOJ_CONTEST_DATA_INTERNAL = '' DMOJ_CONTEST_DATA_DOWNLOAD_RATELIMIT = datetime.timedelta(days=1) + DMOJ_COMMENT_VOTE_HIDE_THRESHOLD = -5 -DMOJ_PDF_PROBLEM_CACHE = '' -DMOJ_PDF_PROBLEM_TEMP_DIR = tempfile.gettempdir() +DMOJ_COMMENT_REPLY_TIMEFRAME = datetime.timedelta(days=365) + +DMOJ_PDF_PDFOID_URL = None +# Optional but recommended to save resources, path on disk to cache PDFs +DMOJ_PDF_PROBLEM_CACHE = None +# Optional, URL serving DMOJ_PDF_PROBLEM_CACHE with X-Accel-Redirect +DMOJ_PDF_PROBLEM_INTERNAL = None + +DMOJ_STATS_LANGUAGE_THRESHOLD = 10 DMOJ_STATS_SUBMISSION_RESULT_COLORS = { 'TLE': '#a3bcbd', 'AC': '#00a92a', @@ -220,6 +232,18 @@ DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 3600 DMOJ_PASSWORD_RESET_LIMIT_COUNT = 10 +# At the bare minimum, dark and light theme CSS file locations must be declared +DMOJ_THEME_CSS = { + 'light': 'style.css', + 'dark': 'dark/style.css', +} +# At the bare minimum, dark and light ace themes must be declared +DMOJ_THEME_DEFAULT_ACE_THEME = { + 'light': 'github', + 'dark': 'twilight', +} +DMOJ_SELECT2_THEME = 'dmoj' + MARKDOWN_STYLES = {} MARKDOWN_DEFAULT_STYLE = {} @@ -241,31 +265,11 @@ BAD_MAIL_PROVIDER_REGEX = () NOFOLLOW_EXCLUDED = set() -TIMEZONE_BG = None -TIMEZONE_MAP = None -TIMEZONE_DETECT_BACKEND = None +TIMEZONE_MAP = 'https://static.dmoj.ca/assets/earth.jpg' TERMS_OF_SERVICE_URL = None DEFAULT_USER_LANGUAGE = 'CPP17' -PHANTOMJS = '' -PHANTOMJS_PDF_ZOOM = 0.75 -PHANTOMJS_PDF_TIMEOUT = 5.0 -PHANTOMJS_PAPER_SIZE = 'Letter' - -SLIMERJS = '' -SLIMERJS_PDF_ZOOM = 0.75 -SLIMERJS_FIREFOX_PATH = '' -SLIMERJS_PAPER_SIZE = 'Letter' - -PUPPETEER_MODULE = '/usr/lib/node_modules/puppeteer' -PUPPETEER_PAPER_SIZE = 'Letter' - -USE_SELENIUM = False -SELENIUM_CUSTOM_CHROME_PATH = None -SELENIUM_CHROMEDRIVER_PATH = 'chromedriver' - -PYGMENT_THEME = 'pygment-github.css' INLINE_JQUERY = True INLINE_FONTAWESOME = True JQUERY_JS = '//ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js' @@ -298,6 +302,7 @@ 'children': [ 'judge.ProblemGroup', 'judge.ProblemType', + 'judge.License', ], }, { @@ -306,13 +311,13 @@ 'children': [ 'judge.TagGroup', 'judge.Tag', - ] + ], }, + ('judge.Submission', 'fa-check-square-o'), { - 'model': 'judge.Submission', - 'icon': 'fa-check-square-o', + 'model': 'judge.Language', + 'icon': 'fa-file-code-o', 'children': [ - 'judge.Language', 'judge.Judge', ], }, @@ -324,19 +329,20 @@ 'judge.ContestTag', ], }, + ('judge.Ticket', 'fa-bell'), { 'model': 'auth.User', 'icon': 'fa-user', 'children': [ + 'judge.Profile', 'auth.Group', 'registration.RegistrationProfile', ], }, { - 'model': 'judge.Profile', - 'icon': 'fa-user-plus', + 'model': 'judge.Organization', + 'icon': 'fa-users', 'children': [ - 'judge.Organization', 'judge.OrganizationRequest', 'judge.Badge', ], @@ -345,16 +351,20 @@ 'model': 'judge.NavigationBar', 'icon': 'fa-bars', 'children': [ - 'judge.MiscConfig', - 'judge.License', 'sites.Site', 'redirects.Redirect', ], }, ('judge.BlogPost', 'fa-rss-square'), - ('judge.Comment', 'fa-comment-o'), + { + 'model': 'judge.Comment', + 'icon': 'fa-comment-o', + 'children': [ + 'judge.CommentLock', + ], + }, ('flatpages.FlatPage', 'fa-file-text-o'), - ('judge.Solution', 'fa-pencil'), + ('judge.MiscConfig', 'fa-question-circle'), ], 'dashboard': { 'breadcrumbs': True, @@ -399,6 +409,7 @@ 'judge.middleware.APIMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'judge.middleware.MiscConfigMiddleware', 'judge.middleware.DMOJLoginMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -437,6 +448,7 @@ ROOT_URLCONF = 'dmoj.urls' LOGIN_REDIRECT_URL = '/user' WSGI_APPLICATION = 'dmoj.wsgi.application' +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' TEMPLATES = [ { @@ -459,6 +471,7 @@ 'judge.template_context.general_info', 'judge.template_context.site', 'judge.template_context.site_name', + 'judge.template_context.site_theme', 'judge.template_context.misc_config', 'judge.template_context.math_setting', 'social_django.context_processors.backends', @@ -473,6 +486,9 @@ 'judge.jinja2.DMOJExtension', 'judge.jinja2.spaceless.SpacelessExtension', ], + 'bytecode_cache': { + 'enabled': True, + }, }, }, { @@ -517,18 +533,19 @@ BLEACH_USER_SAFE_ATTRS = { '*': ['id', 'class', 'style', 'data', 'height'], - 'img': ['src', 'alt', 'title', 'width', 'height', 'data-src'], + 'img': ['src', 'alt', 'title', 'width', 'height', 'data-src', 'align'], 'a': ['href', 'alt', 'title'], 'iframe': ['src', 'height', 'width', 'allow'], 'abbr': ['title'], 'dfn': ['title'], 'time': ['datetime'], 'data': ['value'], - 'td': ['colspan', 'rowspan'], - 'th': ['colspan', 'rowspan'], + 'td': ['colspan', 'rowspan'], + 'th': ['colspan', 'rowspan'], 'audio': ['autoplay', 'controls', 'crossorigin', 'muted', 'loop', 'preload', 'src'], 'video': ['autoplay', 'controls', 'crossorigin', 'height', 'muted', 'loop', 'poster', 'preload', 'src', 'width'], 'source': ['src', 'srcset', 'type'], + 'li': ['value'], } MARKDOWN_STAFF_EDITABLE_STYLE = { @@ -610,7 +627,7 @@ SUBMISSION_FILE_UPLOAD_MEDIA_DIR = 'submission_file' # Database -# https://docs.djangoproject.com/en/2.2/ref/settings/#databases +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { 'default': { @@ -638,7 +655,7 @@ EVENT_DAEMON_CONTEST_KEY = '&w7hB-.9WnY2Jj^Qm+|?o6a' # Internationalization -# https://docs.djangoproject.com/en/2.2/topics/i18n/ +# https://docs.djangoproject.com/en/3.2/topics/i18n/ # Whatever you do, this better be one of the entries in `LANGUAGES`. LANGUAGE_CODE = 'en' @@ -652,7 +669,7 @@ SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ +# https://docs.djangoproject.com/en/3.2/howto/static-files/ DMOJ_RESOURCES = os.path.join(BASE_DIR, 'resources') STATICFILES_FINDERS = ( @@ -696,8 +713,6 @@ SOCIAL_AUTH_SLUGIFY_USERNAMES = True SOCIAL_AUTH_SLUGIFY_FUNCTION = 'judge.social_auth.slugify_username' -JUDGE_AMQP_PATH = None - MOSS_API_KEY = None CELERY_WORKER_HIJACK_ROOT_LOGGER = False @@ -706,8 +721,19 @@ DESCRIPTION_MAX_LENGTH = 200 +GROUP_PERMISSION_FOR_ORG_ADMIN = 'Org Admin' + try: with open(os.path.join(os.path.dirname(__file__), 'local_settings.py')) as f: exec(f.read(), globals()) except IOError: pass + +if DMOJ_PDF_PDFOID_URL: + # If a cache is configured, it must already exist and be a directory + assert DMOJ_PDF_PROBLEM_CACHE is None or os.path.isdir(DMOJ_PDF_PROBLEM_CACHE) + # If using X-Accel-Redirect, the cache directory must be configured + assert DMOJ_PDF_PROBLEM_INTERNAL is None or DMOJ_PDF_PROBLEM_CACHE is not None + +ACE_DEFAULT_LIGHT_THEME = DMOJ_THEME_DEFAULT_ACE_THEME['light'] +ACE_DEFAULT_DARK_THEME = DMOJ_THEME_DEFAULT_ACE_THEME['dark'] diff --git a/dmoj/urls.py b/dmoj/urls.py index f11e8d8c7..8e9bcf5cf 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -9,12 +9,12 @@ from django.urls import include, path, re_path, reverse from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ +from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.generic import RedirectView from martor.views import markdown_search_user from judge.feed import AtomBlogFeed, AtomCommentFeed, AtomProblemFeed, BlogFeed, CommentFeed, ProblemFeed -from judge.sitemap import BlogPostSitemap, ContestSitemap, HomePageSitemap, OrganizationSitemap, ProblemSitemap, \ - SolutionSitemap, UrlSitemap, UserSitemap +from judge.sitemap import sitemaps from judge.views import TitledTemplateView, api, blog, comment, contests, language, license, mailgun, organization, \ preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tag, tasks, ticket, \ two_factor, user, widgets @@ -56,12 +56,7 @@ template_name='registration/password_change_done.html', title=_('Password change successful'), ), name='password_change_done'), - path('password/reset/', user.CustomPasswordResetView.as_view( - template_name='registration/password_reset.html', - html_email_template_name='registration/password_reset_email.html', - email_template_name='registration/password_reset_email.txt', - title=_('Password reset'), - ), name='password_reset'), + path('password/reset/', user.CustomPasswordResetView.as_view(), name='password_reset'), re_path(r'^password/reset/confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', auth_views.PasswordResetConfirmView.as_view( template_name='registration/password_reset_confirm.html', @@ -124,7 +119,7 @@ def paged_list_view(view, name): path('', problem.ProblemDetail.as_view(), name='problem_detail'), path('/edit', problem.ProblemEdit.as_view(), name='problem_edit'), path('/editorial', problem.ProblemSolution.as_view(), name='problem_editorial'), - path('/raw', problem.ProblemRaw.as_view(), name='problem_raw'), + path('/raw', xframe_options_sameorigin(problem.ProblemRaw.as_view()), name='problem_raw'), path('/pdf', problem.ProblemPdfView.as_view(), name='problem_pdf'), path('/pdf/', problem.ProblemPdfView.as_view(), name='problem_pdf'), path('/clone', problem.ProblemClone.as_view(), name='problem_clone'), @@ -201,6 +196,7 @@ def paged_list_view(view, name): path('', user.UserAboutPage.as_view(), name='user_page'), path('/ban', user.UserBan.as_view(), name='user_ban'), path('/blog/', paged_list_view(user.UserBlogPage, 'user_blog')), + path('/comment/', paged_list_view(user.UserCommentPage, 'user_comment')), path('/solved/', include([ path('', user.UserProblemsPage.as_view(), name='user_problems'), path('ajax', user.UserPerformancePointsAjax.as_view(), name='user_pp_ajax'), @@ -234,13 +230,16 @@ def paged_list_view(view, name): path('contest/', include([ path('', contests.ContestDetail.as_view(), name='contest_view'), + path('/all', contests.ContestAllProblems.as_view(), name='contest_all_problems'), path('/edit', contests.EditContest.as_view(), name='contest_edit'), path('/moss', contests.ContestMossView.as_view(), name='contest_moss'), path('/moss/delete', contests.ContestMossDelete.as_view(), name='contest_moss_delete'), path('/announce', contests.ContestAnnounce.as_view(), name='contest_announce'), path('/clone', contests.ContestClone.as_view(), name='contest_clone'), path('/ranking/', contests.ContestRanking.as_view(), name='contest_ranking'), + path('/public_ranking/', contests.ContestPublicRanking.as_view(), name='contest_public_ranking'), path('/official_ranking/', contests.ContestOfficialRanking.as_view(), name='contest_official_ranking'), + path('/register', contests.ContestRegister.as_view(), name='contest_register'), path('/join', contests.ContestJoin.as_view(), name='contest_join'), path('/leave', contests.ContestLeave.as_view(), name='contest_leave'), path('/stats', contests.ContestStats.as_view(), name='contest_stats'), @@ -313,29 +312,19 @@ def paged_list_view(view, name): path('status/', status.status_all, name='status_all'), path('status/oj/', status.status_oj, name='status_oj'), - path('api/', include([ - path('contest/list', api.api_v1_contest_list), - path('contest/info/', api.api_v1_contest_detail), - path('problem/list', api.api_v1_problem_list), - path('problem/info/', api.api_v1_problem_info), - path('user/list', api.api_v1_user_list), - path('user/info/', api.api_v1_user_info), - path('user/submissions/', api.api_v1_user_submissions), - path('user/ratings/', api.api_v1_user_ratings), - path('v2/', include([ - path('contests', api.api_v2.APIContestList.as_view()), - path('contest/', api.api_v2.APIContestDetail.as_view()), - path('problems', api.api_v2.APIProblemList.as_view()), - path('problem/', api.api_v2.APIProblemDetail.as_view()), - path('users', api.api_v2.APIUserList.as_view()), - path('user/', api.api_v2.APIUserDetail.as_view()), - path('submissions', api.api_v2.APISubmissionList.as_view()), - path('submission/', api.api_v2.APISubmissionDetail.as_view()), - path('organizations', api.api_v2.APIOrganizationList.as_view()), - path('participations', api.api_v2.APIContestParticipationList.as_view()), - path('languages', api.api_v2.APILanguageList.as_view()), - path('judges', api.api_v2.APIJudgeList.as_view()), - ])), + path('api/v2/', include([ + path('contests', api.api_v2.APIContestList.as_view()), + path('contest/', api.api_v2.APIContestDetail.as_view()), + path('problems', api.api_v2.APIProblemList.as_view()), + path('problem/', api.api_v2.APIProblemDetail.as_view()), + path('users', api.api_v2.APIUserList.as_view()), + path('user/', api.api_v2.APIUserDetail.as_view()), + path('submissions', api.api_v2.APISubmissionList.as_view()), + path('submission/', api.api_v2.APISubmissionDetail.as_view()), + path('organizations', api.api_v2.APIOrganizationList.as_view()), + path('participations', api.api_v2.APIContestParticipationList.as_view()), + path('languages', api.api_v2.APILanguageList.as_view()), + path('judges', api.api_v2.APIJudgeList.as_view()), ])), path('posts/', paged_list_view(blog.PostList, 'blog_post_list')), @@ -356,7 +345,6 @@ def paged_list_view(view, name): path('rejudge', widgets.rejudge_submission, name='submission_rejudge'), path('single_submission', submission.single_submission, name='submission_single_query'), path('submission_testcases', submission.SubmissionTestCaseQuery.as_view(), name='submission_testcases_query'), - path('detect_timezone', widgets.DetectTimezone.as_view(), name='detect_timezone'), path('status-table', status.status_table, name='status_table'), path('template', problem.LanguageTemplateAjax.as_view(), name='language_template_ajax'), @@ -417,18 +405,7 @@ def paged_list_view(view, name): path('/notes', ticket.TicketNotesEditView.as_view(), name='ticket_notes'), ])), - path('sitemap.xml', sitemap, {'sitemaps': { - 'problem': ProblemSitemap, - 'user': UserSitemap, - 'home': HomePageSitemap, - 'contest': ContestSitemap, - 'organization': OrganizationSitemap, - 'blog': BlogPostSitemap, - 'solutions': SolutionSitemap, - 'pages': UrlSitemap([ - {'location': '/about/', 'priority': 0.9}, - ]), - }}), + path('sitemap.xml', sitemap, {'sitemaps': sitemaps}), path('judge-select2/', include([ path('profile/', UserSelect2View.as_view(), name='profile_select2'), diff --git a/dmoj/wsgi_async.py b/dmoj/wsgi_async.py index b62d3531a..ec114d1fd 100644 --- a/dmoj/wsgi_async.py +++ b/dmoj/wsgi_async.py @@ -9,6 +9,4 @@ import dmoj_install_pymysql # noqa: E402, F401, I100, I202, imported for side effect from django.core.wsgi import get_wsgi_application # noqa: E402, I100, I202, django must be imported here -# noinspection PyUnresolvedReferences -import django_2_2_pymysql_patch # noqa: E402, I100, F401, I202, imported for side effect application = get_wsgi_application() diff --git a/dmoj_bridge_async.py b/dmoj_bridge_async.py index dee411242..376f8cf8d 100644 --- a/dmoj_bridge_async.py +++ b/dmoj_bridge_async.py @@ -11,9 +11,6 @@ import django # noqa: E402, F401, I100, I202, django must be imported here django.setup() -# noinspection PyUnresolvedReferences -import django_2_2_pymysql_patch # noqa: E402, I100, F401, I202, imported for side effect - from judge.bridge.daemon import judge_daemon # noqa: E402, I100, I202, django code must be imported here if __name__ == '__main__': diff --git a/dmoj_celery.py b/dmoj_celery.py index d25a7452a..3f9701f17 100644 --- a/dmoj_celery.py +++ b/dmoj_celery.py @@ -8,8 +8,5 @@ # set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dmoj.settings') -# noinspection PyUnresolvedReferences -import django_2_2_pymysql_patch # noqa: E402, I100, F401, I202, imported for side effect - # noinspection PyUnresolvedReferences from dmoj.celery import app # noqa: E402, F401, imported for side effect diff --git a/dmoj_install_pymysql.py b/dmoj_install_pymysql.py index b42bed10e..068715508 100644 --- a/dmoj_install_pymysql.py +++ b/dmoj_install_pymysql.py @@ -1,4 +1,4 @@ import pymysql pymysql.install_as_MySQLdb() -pymysql.version_info = (1, 3, 13, 'final', 0) +pymysql.version_info = (1, 4, 0, 'final', 0) diff --git a/judge/admin/comments.py b/judge/admin/comments.py index e053a88dc..fb445df4d 100644 --- a/judge/admin/comments.py +++ b/judge/admin/comments.py @@ -1,3 +1,4 @@ +from django.db.models import F from django.forms import ModelForm from django.urls import reverse_lazy from django.utils.html import format_html @@ -20,9 +21,9 @@ class Meta: class CommentAdmin(VersionAdmin): fieldsets = ( (None, {'fields': ('author', 'page', 'parent', 'time', 'score', 'hidden')}), - ('Content', {'fields': ('body',)}), + (_('Content'), {'fields': ('body',)}), ) - list_display = ['author', 'linked_page', 'time'] + list_display = ['author', 'linked_page', 'time', 'score', 'hidden'] search_fields = ['author__user__username', 'page', 'body'] actions = ['hide_comment', 'unhide_comment'] list_filter = ['hidden'] @@ -37,6 +38,7 @@ def get_queryset(self, request): def hide_comment(self, request, queryset): count = queryset.update(hidden=True) + queryset.author.calculate_contribution_points() self.message_user(request, ngettext('%d comment successfully hidden.', '%d comments successfully hidden.', count) % count) @@ -44,6 +46,7 @@ def hide_comment(self, request, queryset): def unhide_comment(self, request, queryset): count = queryset.update(hidden=False) + queryset.author.calculate_contribution_points() self.message_user(request, ngettext('%d comment successfully unhidden.', '%d comments successfully unhidden.', count) % count) @@ -59,6 +62,7 @@ def linked_page(self, obj): linked_page.admin_order_field = 'page' def save_model(self, request, obj, form, change): - super(CommentAdmin, self).save_model(request, obj, form, change) + obj.revisions = F('revisions') + 1 + super().save_model(request, obj, form, change) if obj.hidden: obj.get_descendants().update(hidden=obj.hidden) diff --git a/judge/admin/contest.py b/judge/admin/contest.py index adad20e03..efd03ceb3 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -1,5 +1,4 @@ from adminsortable2.admin import SortableInlineAdminMixin -from django.conf.urls import url from django.contrib import admin from django.core.exceptions import PermissionDenied from django.db import connection, transaction @@ -7,7 +6,7 @@ from django.forms import ModelForm, ModelMultipleChoiceField from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 -from django.urls import reverse, reverse_lazy +from django.urls import path, reverse, reverse_lazy from django.utils import timezone from django.utils.html import format_html from django.utils.translation import gettext_lazy as _, ngettext @@ -64,7 +63,7 @@ class Meta: class ContestProblemInline(SortableInlineAdminMixin, admin.TabularInline): model = ContestProblem verbose_name = _('Problem') - verbose_name_plural = 'Problems' + verbose_name_plural = _('Problems') fields = ('problem', 'points', 'partial', 'is_pretested', 'max_submissions', 'output_prefix_override', 'order', 'rejudge_column', 'rescore_column') readonly_fields = ('rejudge_column', 'rescore_column') @@ -73,8 +72,8 @@ class ContestProblemInline(SortableInlineAdminMixin, admin.TabularInline): def rejudge_column(self, obj): if obj.id is None: return '' - return format_html('Rejudge', - reverse('admin:judge_contest_rejudge', args=(obj.contest.id, obj.id))) + return format_html('{1}', + reverse('admin:judge_contest_rejudge', args=(obj.contest.id, obj.id)), _('Rejudge')) rejudge_column.short_description = '' def rescore_column(self, obj): @@ -146,9 +145,10 @@ class ContestAdmin(NoBatchDeleteMixin, VersionAdmin): (_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'push_announcements', 'disallow_virtual', 'hide_problem_tags', 'hide_problem_authors', 'show_short_display', 'run_pretests_only', 'locked_after', 'scoreboard_visibility', - 'scoreboard_cache_timeout', 'show_submission_list', 'points_precision', - 'banned_judges')}), - (_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}), + 'ranking_access_code', 'scoreboard_cache_timeout', 'show_submission_list', + 'points_precision', 'banned_judges')}), + (_('Scheduling'), {'fields': ('start_time', 'end_time', 'registration_start', 'registration_end', + 'time_limit')}), (_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}), (_('Format'), {'fields': ('format_name', 'frozen_last_minutes', 'format_config', 'problem_label_script')}), (_('Rating'), {'fields': ('is_rated', 'rate_all', 'rating_floor', 'rating_ceiling', 'rate_exclude')}), @@ -287,11 +287,11 @@ def set_locked_after(self, contest, locked_after): def get_urls(self): return [ - url(r'^rate/all/$', self.rate_all_view, name='judge_contest_rate_all'), - url(r'^(\d+)/rate/$', self.rate_view, name='judge_contest_rate'), - url(r'^(\d+)/rejudge/(\d+)/$', self.rejudge_view, name='judge_contest_rejudge'), - url(r'^(\d+)/rescore/(\d+)/$', self.rescore_view, name='judge_contest_rescore'), - url(r'^(\d+)/resend/(\d+)/$', self.resend_view, name='judge_contest_resend'), + path('rate/all/', self.rate_all_view, name='judge_contest_rate_all'), + path('/rate/', self.rate_view, name='judge_contest_rate'), + path('/rejudge//', self.rejudge_view, name='judge_contest_rejudge'), + path('/rescore//', self.rescore_view, name='judge_contest_rescore'), + path('/resend//', self.resend_view, name='judge_contest_resend'), ] + super(ContestAdmin, self).get_urls() def rejudge_view(self, request, contest_id, problem_id): @@ -345,7 +345,9 @@ def get_form(self, request, obj=None, **kwargs): if 'problem_label_script' in form.base_fields: # form.base_fields['problem_label_script'] does not exist when the user has only view permission # on the model. - form.base_fields['problem_label_script'].widget = AceWidget('lua', request.profile.ace_theme) + form.base_fields['problem_label_script'].widget = AceWidget( + mode='lua', theme=request.profile.resolved_ace_theme, + ) perms = ('edit_own_contest', 'edit_all_contest') form.base_fields['curators'].queryset = Profile.objects.filter( diff --git a/judge/admin/interface.py b/judge/admin/interface.py index 80dce9f53..478f9a93f 100644 --- a/judge/admin/interface.py +++ b/judge/admin/interface.py @@ -25,7 +25,7 @@ def __init__(self, *args, **kwargs): self.__save_model_calls = 0 def linked_path(self, obj): - return format_html(u'{0}', obj.path) + return format_html('{0}', obj.path) linked_path.short_description = _('link path') def save_model(self, request, obj, form, change): @@ -77,7 +77,7 @@ class BlogPostAdmin(VersionAdmin): (_('Summary'), {'classes': ('collapse',), 'fields': ('summary',)}), ) prepopulated_fields = {'slug': ('title',)} - list_display = ('id', 'title', 'visible', 'sticky', 'publish_on') + list_display = ('id', 'title', 'visible', 'global_post', 'sticky', 'publish_on') list_display_links = ('id', 'title') ordering = ('-publish_on',) form = BlogPostForm diff --git a/judge/admin/organization.py b/judge/admin/organization.py index 2f73ac89a..c48a48429 100644 --- a/judge/admin/organization.py +++ b/judge/admin/organization.py @@ -2,7 +2,7 @@ from django.forms import ModelForm from django.urls import reverse_lazy from django.utils.html import format_html -from django.utils.translation import gettext, gettext_lazy as _, ungettext +from django.utils.translation import gettext, gettext_lazy as _, ngettext from reversion.admin import VersionAdmin from judge.models import Organization @@ -59,9 +59,9 @@ def recalculate_points(self, request, queryset): for org in queryset: org.calculate_points() count += 1 - self.message_user(request, ungettext('%d organization has scores recalculated.', - '%d organizations have scores recalculated.', - count) % count) + self.message_user(request, ngettext('%d organization has scores recalculated.', + '%d organizations have scores recalculated.', + count) % count) recalculate_points.short_description = _('Recalculate scores') diff --git a/judge/admin/problem.py b/judge/admin/problem.py index 30923a97e..59551eb5c 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -153,15 +153,12 @@ def get_actions(self, request): actions = super(ProblemAdmin, self).get_actions(request) if request.user.has_perm('judge.change_public_visibility'): - func, name, desc = self.get_action('make_public') + func, name, desc = self.get_action('make_public_and_update_publish_date') actions[name] = (func, name, desc) func, name, desc = self.get_action('make_private') actions[name] = (func, name, desc) - func, name, desc = self.get_action('update_publish_date') - actions[name] = (func, name, desc) - return actions def get_readonly_fields(self, request, obj=None): @@ -190,23 +187,16 @@ def _rescore(self, request, problem_id, publicy_changed=False): from judge.tasks import rescore_problem transaction.on_commit(rescore_problem.s(problem_id, publicy_changed).delay) - def update_publish_date(self, request, queryset): - count = queryset.update(date=timezone.now()) - self.message_user(request, ngettext("%d problem's publish date successfully updated.", - "%d problems' publish date successfully updated.", - count) % count) - - update_publish_date.short_description = _('Set publish date to now') - - def make_public(self, request, queryset): - count = queryset.update(is_public=True) + def make_public_and_update_publish_date(self, request, queryset): + count = queryset.update(is_public=True, date=timezone.now()) for problem_id in queryset.values_list('id', flat=True): self._rescore(request, problem_id, True) + self.message_user(request, ngettext('%d problem successfully marked as public.', '%d problems successfully marked as public.', count) % count) - make_public.short_description = _('Mark problems as public') + make_public_and_update_publish_date.short_description = _('Mark problems as public and set publish date to now') def make_private(self, request, queryset): count = queryset.update(is_public=False) diff --git a/judge/admin/profile.py b/judge/admin/profile.py index 6b265d1d5..31d3f8811 100644 --- a/judge/admin/profile.py +++ b/judge/admin/profile.py @@ -53,7 +53,7 @@ class WebAuthnInline(admin.TabularInline): readonly_fields = ('cred_id', 'public_key', 'counter') extra = 0 - def has_add_permission(self, request): + def has_add_permission(self, request, obj=None): return False @@ -121,8 +121,8 @@ def recalculate_points(self, request, queryset): for profile in queryset: profile.calculate_points() count += 1 - self.message_user(request, ngettext('%d user has scores recalculated.', - '%d users have scores recalculated.', + self.message_user(request, ngettext('%d user had scores recalculated.', + '%d users had scores recalculated.', count) % count) recalculate_points.short_description = _('Recalculate scores') @@ -140,5 +140,7 @@ def get_form(self, request, obj=None, **kwargs): form = super(ProfileAdmin, self).get_form(request, obj, **kwargs) if 'user_script' in form.base_fields: # form.base_fields['user_script'] does not exist when the user has only view permission on the model. - form.base_fields['user_script'].widget = AceWidget('javascript', request.profile.ace_theme) + form.base_fields['user_script'].widget = AceWidget( + mode='javascript', theme=request.profile.resolved_ace_theme, + ) return form diff --git a/judge/admin/runtime.py b/judge/admin/runtime.py index 417e22cb7..75a808fef 100644 --- a/judge/admin/runtime.py +++ b/judge/admin/runtime.py @@ -1,9 +1,8 @@ -from django.conf.urls import url from django.db.models import TextField from django.forms import ModelForm, TextInput from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 -from django.urls import reverse +from django.urls import path, reverse from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -36,7 +35,9 @@ def save_model(self, request, obj, form, change): def get_form(self, request, obj=None, **kwargs): form = super(LanguageAdmin, self).get_form(request, obj, **kwargs) if obj is not None: - form.base_fields['template'].widget = AceWidget(obj.ace, request.profile.ace_theme) + form.base_fields['template'].widget = AceWidget( + mode=obj.ace, theme=request.profile.resolved_ace_theme, + ) return form @@ -45,7 +46,7 @@ def render(self, name, value, attrs=None, renderer=None): text = super(TextInput, self).render(name, value, attrs) return mark_safe(text + format_html( """\ -Regenerate +{1} -""", name)) +""", name, _('Regenerate'))) class JudgeAdminForm(ModelForm): @@ -66,22 +67,24 @@ class Meta: class JudgeAdmin(VersionAdmin): form = JudgeAdminForm - readonly_fields = ('created', 'online', 'start_time', 'ping', 'load', 'last_ip', 'runtimes', 'problems') + readonly_fields = ('created', 'online', 'start_time', 'ping', 'load', 'last_ip', 'runtimes', 'problems', + 'is_disabled') fieldsets = ( - (None, {'fields': ('name', 'auth_key', 'is_blocked')}), + (None, {'fields': ('name', 'auth_key', 'is_blocked', 'is_disabled')}), (_('Description'), {'fields': ('description',)}), (_('Information'), {'fields': ('created', 'online', 'last_ip', 'start_time', 'ping', 'load')}), - (_('Capabilities'), {'fields': ('runtimes', 'problems')}), + (_('Capabilities'), {'fields': ('runtimes',)}), ) - list_display = ('name', 'online', 'start_time', 'ping', 'load', 'last_ip') + list_display = ('name', 'online', 'is_disabled', 'start_time', 'ping', 'load', 'last_ip') ordering = ['-online', 'name'] formfield_overrides = { TextField: {'widget': AdminMartorWidget}, } def get_urls(self): - return ([url(r'^(\d+)/disconnect/$', self.disconnect_view, name='judge_judge_disconnect'), - url(r'^(\d+)/terminate/$', self.terminate_view, name='judge_judge_terminate')] + + return ([path('/disconnect/', self.disconnect_view, name='judge_judge_disconnect'), + path('/terminate/', self.terminate_view, name='judge_judge_terminate'), + path('/disable/', self.disable_view, name='judge_judge_disable')] + super(JudgeAdmin, self).get_urls()) def disconnect_judge(self, id, force=False): @@ -95,6 +98,11 @@ def disconnect_view(self, request, id): def terminate_view(self, request, id): return self.disconnect_judge(id, force=True) + def disable_view(self, request, id): + judge = get_object_or_404(Judge, id=id) + judge.toggle_disabled() + return HttpResponseRedirect(reverse('admin:judge_judge_change', args=(judge.id,))) + def get_readonly_fields(self, request, obj=None): if obj is not None and obj.online: return self.readonly_fields + ('name',) diff --git a/judge/admin/submission.py b/judge/admin/submission.py index 5f474b1ae..db6e131f9 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -2,13 +2,13 @@ from operator import itemgetter from django.conf import settings -from django.conf.urls import url from django.contrib import admin, messages from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 +from django.urls import path, reverse from django.utils.html import format_html from django.utils.translation import gettext, gettext_lazy as _, ngettext, pgettext from reversion.admin import VersionAdmin @@ -107,8 +107,9 @@ class SubmissionSourceInline(admin.StackedInline): extra = 0 def get_formset(self, request, obj=None, **kwargs): - kwargs.setdefault('widgets', {})['source'] = AceWidget(mode=obj and obj.language.ace, - theme=request.profile.ace_theme) + kwargs.setdefault('widgets', {})['source'] = AceWidget( + mode=obj and obj.language.ace, theme=request.profile.resolved_ace_theme, + ) return super().get_formset(request, obj, **kwargs) @@ -244,14 +245,15 @@ def language_column(self, obj): def judge_column(self, obj): if obj.is_locked: - return format_html('') + return format_html('', _('Locked')) else: - return format_html('', obj.id) + return format_html('', _('Rejudge'), + reverse('admin:judge_submission_rejudge', args=(obj.id,))) judge_column.short_description = '' def get_urls(self): return [ - url(r'^(\d+)/judge/$', self.judge_view, name='judge_submission_rejudge'), + path('/judge/', self.judge_view, name='judge_submission_rejudge'), ] + super(SubmissionAdmin, self).get_urls() def judge_view(self, request, id): diff --git a/judge/bridge/base_handler.py b/judge/bridge/base_handler.py index c3fbd40d8..e3ed9c574 100644 --- a/judge/bridge/base_handler.py +++ b/judge/bridge/base_handler.py @@ -35,7 +35,7 @@ class Disconnect(Exception): # making it impossible to inherit __init__ sanely. While it lets you # use setup(), most tools will complain about uninitialized variables. # This metaclass will allow sane __init__ behaviour while also magically -# calling the methods that handles the request. +# calling the methods that handle the request. class RequestHandlerMeta(type): def __call__(cls, *args, **kwargs): handler = super().__call__(*args, **kwargs) @@ -141,6 +141,9 @@ def on_disconnect(self): def on_timeout(self): pass + def on_cleanup(self): + pass + def handle(self): try: tag = self.read_size() @@ -187,6 +190,8 @@ def handle(self): if e.__class__.__name__ == 'cancel_wait_ex': return raise + finally: + self.on_cleanup() def send(self, data): compressed = zlib.compress(data.encode('utf-8')) diff --git a/judge/bridge/django_handler.py b/judge/bridge/django_handler.py index f5fe7a466..914507444 100644 --- a/judge/bridge/django_handler.py +++ b/judge/bridge/django_handler.py @@ -2,6 +2,8 @@ import logging import struct +from django import db + from judge.bridge.base_handler import Disconnect, ZlibPacketHandler logger = logging.getLogger('judge.bridge') @@ -16,6 +18,7 @@ def __init__(self, request, client_address, server, judges): 'submission-request': self.on_submission, 'terminate-submission': self.on_termination, 'disconnect-judge': self.on_disconnect_request, + 'disable-judge': self.on_disable_judge, } self.judges = judges @@ -53,8 +56,13 @@ def on_disconnect_request(self, data): force = data['force'] self.judges.disconnect(judge_id, force=force) + def on_disable_judge(self, data): + judge_id = data['judge-id'] + is_disabled = data['is-disabled'] + self.judges.update_disable_judge(judge_id, is_disabled) + def on_malformed(self, packet): logger.error('Malformed packet: %s', packet) - def on_close(self): - self._to_kill = False + def on_cleanup(self): + db.connection.close() diff --git a/judge/bridge/judge_handler.py b/judge/bridge/judge_handler.py index 29fb49e7c..ed05e8752 100644 --- a/judge/bridge/judge_handler.py +++ b/judge/bridge/judge_handler.py @@ -32,10 +32,7 @@ def _ensure_connection(): - try: - db.connection.cursor().execute('SELECT 1').fetchall() - except Exception: - db.connection.close() + db.connection.close_if_unusable_or_obsolete() def get_submission_file_url(source): @@ -83,6 +80,7 @@ def __init__(self, request, client_address, server, judges): self.time_delta = None self.load = 1e100 self.name = None + self.is_disabled = False self.batch_id = None self.in_batch = False self._stop_ping = threading.Event() @@ -118,15 +116,20 @@ def on_disconnect(self): def _authenticate(self, id, key): try: - judge = Judge.objects.get(name=id, is_blocked=False) + judge = Judge.objects.get(name=id) except Judge.DoesNotExist: - result = False - else: - result = hmac.compare_digest(judge.auth_key, key) + return False - if not result: + if not hmac.compare_digest(judge.auth_key, key): + logger.warning('Judge authentication failure: %s', self.client_address) json_log.warning(self._make_json_log(action='auth', judge=id, info='judge failed authentication')) - return result + return False + + if judge.is_blocked: + json_log.warning(self._make_json_log(action='auth', judge=id, info='judge authenticated but is blocked')) + return False + + return True def _connected(self): judge = self.judge = Judge.objects.get(name=self.name) @@ -135,6 +138,9 @@ def _connected(self): judge.problems.set(Problem.objects.filter(code__in=list(self.problems.keys()))) judge.runtimes.set(Language.objects.filter(key__in=list(self.executors.keys()))) + # Cache is_disabled for faster access + self.is_disabled = judge.is_disabled + # Delete now in case we somehow crashed and left some over from the last connection RuntimeVersion.objects.filter(judge=judge).delete() versions = [] @@ -172,7 +178,6 @@ def on_handshake(self, packet): return if not self._authenticate(packet['id'], packet['key']): - logger.warning('Authentication failure: %s', self.client_address) self.close() return @@ -189,7 +194,8 @@ def on_handshake(self, packet): self._connected() def can_judge(self, problem, executor, judge_id=None): - return problem in self.problems and executor in self.executors and (not judge_id or self.name == judge_id) + return problem in self.problems and executor in self.executors and \ + ((not judge_id and not self.is_disabled) or self.name == judge_id) @property def working(self): @@ -326,7 +332,7 @@ def on_packet(self, data): logger.exception('Error in packet handling (Judge-side): %s', self.name) self._packet_exception() # You can't crash here because you aren't so sure about the judges - # not being malicious or simply malforms. THIS IS A SERVER! + # not being malicious or simply malformed. THIS IS A SERVER! def _packet_exception(self): json_log.exception(self._make_json_log(sub=self._working, info='packet processing exception')) @@ -655,3 +661,6 @@ def _post_update_submission(self, id, state, done=False): 'organizations': [x[0] for x in Profile.objects.get(id=data['user_id']).organizations.values_list('id')], }) + + def on_cleanup(self): + db.connection.close() diff --git a/judge/bridge/judge_list.py b/judge/bridge/judge_list.py index edf72b5c1..b7c542d0e 100644 --- a/judge/bridge/judge_list.py +++ b/judge/bridge/judge_list.py @@ -33,8 +33,8 @@ def _handle_free_judge(self, judge): while node: if isinstance(node.value, PriorityMarker): priority = node.value.priority + 1 - elif priority >= REJUDGE_PRIORITY and len(self.judges) > 1 and sum( - not judge.working for judge in self.judges) <= 1: + elif priority >= REJUDGE_PRIORITY and self.count_not_disabled() > 1 and sum( + not judge.working and not judge.is_disabled for judge in self.judges) <= 1: return else: id, problem, language, source, judge_id, banned_judges = node.value @@ -52,6 +52,9 @@ def _handle_free_judge(self, judge): break node = node.next + def count_not_disabled(self): + return sum(not judge.is_disabled for judge in self.judges) + def register(self, judge): with self.lock: # Disconnect all judges with the same name, see @@ -69,6 +72,12 @@ def update_problems(self, judge): with self.lock: self._handle_free_judge(judge) + def update_disable_judge(self, judge_id, is_disabled): + with self.lock: + for judge in self.judges: + if judge.name == judge_id: + judge.is_disabled = is_disabled + def remove(self, judge): with self.lock: sub = judge.get_current_submission() @@ -124,21 +133,21 @@ def judge(self, id, problem, language, source, judge_id, priority, banned_judges candidates = [ judge for judge in self.judges - if not judge.working and - judge.name not in banned_judges and + if judge.name not in banned_judges and judge.can_judge(problem, language, judge_id) ] + available = [judge for judge in candidates if not judge.working and not judge.is_disabled] if judge_id: - logger.info('Specified judge %s is%savailable', judge_id, ' ' if candidates else ' not ') + logger.info('Specified judge %s is%savailable', judge_id, ' ' if available else ' not ') else: - logger.info('Free judges: %d', len(candidates)) + logger.info('Free judges: %d', len(available)) - if len(self.judges) > 1 and len(candidates) == 1 and priority >= REJUDGE_PRIORITY: - candidates = [] + if len(candidates) > 1 and len(available) == 1 and priority >= REJUDGE_PRIORITY: + available = [] - if candidates: + if available: # Schedule the submission on the judge reporting least load. - judge = min(candidates, key=lambda judge: (judge.load, random())) + judge = min(available, key=lambda judge: (judge.load, random())) logger.info('Dispatched submission %d to: %s', id, judge.name) self.submission_map[id] = judge try: diff --git a/judge/comments.py b/judge/comments.py index e63bae3d3..4e8d88dcc 100644 --- a/judge/comments.py +++ b/judge/comments.py @@ -3,12 +3,13 @@ from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.db.models import Count -from django.db.models.expressions import Value +from django.db.models import FilteredRelation, Q +from django.db.models.expressions import F, Value from django.db.models.functions import Coalesce from django.forms import ModelForm from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, HttpResponseRedirect from django.urls import reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.generic import View @@ -18,8 +19,7 @@ from reversion.models import Revision, Version from judge.dblock import LockModel -from judge.models import Comment, CommentLock, CommentVote -from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join +from judge.models import Comment, CommentLock from judge.widgets import HeavyPreviewPageDownWidget @@ -46,9 +46,9 @@ def clean(self): if profile.mute: suffix_msg = '' if profile.ban_reason is None else _(' Reason: ') + profile.ban_reason raise ValidationError(_('Your part is silent, little toad.') + suffix_msg) - elif not self.request.user.is_staff and not profile.has_any_solves: - raise ValidationError(_('You need to have solved at least one problem ' - 'before your voice can be heard.')) + elif profile.is_new_user: + raise ValidationError(_('You need to have solved at least %d problems ' + 'before your voice can be heard.') % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT) return super(CommentForm, self).clean() @@ -79,10 +79,14 @@ def post(self, request, *args, **kwargs): try: parent = int(parent) except ValueError: + return HttpResponseBadRequest() + try: + parent_comment = Comment.objects.get(hidden=False, id=parent, page=page) + except Comment.DoesNotExist: return HttpResponseNotFound() - else: - if not Comment.objects.filter(hidden=False, id=parent, page=page).exists(): - return HttpResponseNotFound() + if not (self.request.user.has_perm('judge.change_comment') or + parent_comment.time > timezone.now() - settings.DMOJ_COMMENT_REPLY_TIMEFRAME): + return HttpResponseForbidden() form = CommentForm(request, request.POST) if form.is_valid(): @@ -110,15 +114,19 @@ def get_context_data(self, **kwargs): queryset = Comment.objects.filter(hidden=False, page=self.get_comment_page()) context['has_comments'] = queryset.exists() context['comment_lock'] = self.is_comment_locked() - queryset = queryset.select_related('author__user', 'author__display_badge') \ - .defer('author__about').annotate(revisions=Count('versions')) + queryset = queryset.select_related('author__user', 'author__display_badge').defer('author__about') if self.request.user.is_authenticated: - queryset = queryset.annotate(vote_score=Coalesce(RawSQLColumn(CommentVote, 'score'), Value(0))) profile = self.request.profile - unique_together_left_join(queryset, CommentVote, 'comment', 'voter', profile.id) - context['is_new_user'] = not self.request.user.is_staff and not profile.has_any_solves + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) + context['is_new_user'] = profile.is_new_user + context['interact_min_problem_count_msg'] = \ + _('You need to have solved at least %d problems before your voice can be heard.') \ + % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT context['comment_list'] = queryset context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD + context['reply_cutoff'] = timezone.now() - settings.DMOJ_COMMENT_REPLY_TIMEFRAME return context diff --git a/judge/contest_format/atcoder.py b/judge/contest_format/atcoder.py index 18dd22977..0c9aef66e 100644 --- a/judge/contest_format/atcoder.py +++ b/judge/contest_format/atcoder.py @@ -95,7 +95,7 @@ def update_participation(self, participation): participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) if format_data: penalty = format_html(' ({penalty})', @@ -103,6 +103,7 @@ def display_user_problem(self, participation, contest_problem, frozen=False): return format_html( '{points}{penalty}
{time}
', state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solves.get(str(contest_problem.id), None) == participation.id else '') + self.best_solution_state(format_data['points'], contest_problem.points)), url=reverse('contest_user_submissions', args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]), diff --git a/judge/contest_format/base.py b/judge/contest_format/base.py index 98a9e2c26..14377255a 100644 --- a/judge/contest_format/base.py +++ b/judge/contest_format/base.py @@ -48,14 +48,29 @@ def update_participation(self, participation): raise NotImplementedError() @abstractmethod - def display_user_problem(self, participation, contest_problem, frozen=False): + def get_first_solves_and_total_ac(self, problems, participations, frozen=False): + """ + Returns two dictionaries mapping ContestProblem to the first ContestParticipation that solves it + and the total number of accepted submissions. + + :param problems: A list of ContestProblem objects. + :param participations: A list of ContestParticipation objects. + :param frozen: Whether the ranking is frozen or not. Only useful for ICPC/VNOJ format. + :return: A tuple of two dictionaries. First one maps ContestProblem's ID to ContestParticipation's ID, + or None if no solves yet. Second one maps ContestProblem's ID to total number of accepted submissions. + """ + raise NotImplementedError() + + @abstractmethod + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): """ Returns the HTML fragment to show a user's performance on an individual problem. This is expected to use information from the format_data field instead of computing it from scratch. :param participation: The ContestParticipation object linking the user to the contest. :param contest_problem: The ContestProblem object representing the problem in question. - :param frozen: Whether the ranking is frozen or not. Only useful for ICPC format. + :param first_solves: The first dictionary returned by get_first_solves_and_total_ac. + :param frozen: Whether the ranking is frozen or not. Only useful for ICPC/VNOJ format. :return: An HTML fragment, marked as safe for Jinja2. """ raise NotImplementedError() @@ -67,7 +82,7 @@ def display_participation_result(self, participation, frozen=False): information from the format_data field instead of computing it from scratch. :param participation: The ContestParticipation object. - :param frozen: Whether the ranking is frozen or not. Only useful for ICPC format. + :param frozen: Whether the ranking is frozen or not. Only useful for ICPC/VNOJ format. :return: An HTML fragment, marked as safe for Jinja2. """ raise NotImplementedError() diff --git a/judge/contest_format/default.py b/judge/contest_format/default.py index 2316575b1..c6cdaf62e 100644 --- a/judge/contest_format/default.py +++ b/judge/contest_format/default.py @@ -45,12 +45,40 @@ def update_participation(self, participation): participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def get_first_solves_and_total_ac(self, problems, participations, frozen=False): + first_solves = {} + total_ac = {} + + for problem in problems: + problem_id = str(problem.id) + min_time = None + first_solves[problem_id] = None + total_ac[problem_id] = 0 + + for participation in participations: + format_data = (participation.format_data or {}).get(problem_id) + if format_data: + points = format_data['points'] + time = format_data['time'] + + if points == problem.points: + total_ac[problem_id] += 1 + + # Only acknowledge first solves for live participations + if participation.virtual == 0 and (min_time is None or min_time > time): + min_time = time + first_solves[problem_id] = participation.id + + return first_solves, total_ac + + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) + if format_data: return format_html( - u'{points}
{time}
', + '{points}
{time}
', state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solves.get(str(contest_problem.id), None) == participation.id else '') + self.best_solution_state(format_data['points'], contest_problem.points)), url=reverse('contest_user_submissions', args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]), @@ -62,7 +90,7 @@ def display_user_problem(self, participation, contest_problem, frozen=False): def display_participation_result(self, participation, frozen=False): return format_html( - u'{points}
{cumtime}
', + '{points}
{cumtime}
', url=reverse('contest_all_user_submissions', args=[self.contest.key, participation.user.user.username]), points=floatformat(participation.score, -self.contest.points_precision), diff --git a/judge/contest_format/ecoo.py b/judge/contest_format/ecoo.py index 1c7aacab3..1d2d0fcc3 100644 --- a/judge/contest_format/ecoo.py +++ b/judge/contest_format/ecoo.py @@ -98,7 +98,7 @@ def update_participation(self, participation): participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) if format_data: bonus = format_html(' +{bonus}', @@ -107,6 +107,7 @@ def display_user_problem(self, participation, contest_problem, frozen=False): return format_html( '{points}{bonus}
{time}
', state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solves.get(str(contest_problem.id), None) == participation.id else '') + self.best_solution_state(format_data['points'], contest_problem.points)), url=reverse('contest_user_submissions', args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]), diff --git a/judge/contest_format/icpc.py b/judge/contest_format/icpc.py index 4a565b793..374d7bf08 100644 --- a/judge/contest_format/icpc.py +++ b/judge/contest_format/icpc.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.html import format_html from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _, gettext_lazy, ungettext +from django.utils.translation import gettext as _, gettext_lazy, ngettext from judge.contest_format.default import DefaultContestFormat from judge.contest_format.registry import register_contest_format @@ -144,7 +144,34 @@ def update_participation(self, participation): participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def get_first_solves_and_total_ac(self, problems, participations, frozen=False): + first_solves = {} + total_ac = {} + + prefix = 'frozen_' if frozen else '' + for problem in problems: + problem_id = str(problem.id) + min_time = None + first_solves[problem_id] = None + total_ac[problem_id] = 0 + + for participation in participations: + format_data = (participation.format_data or {}).get(problem_id) + if format_data: + points = format_data[prefix + 'points'] + time = format_data['time'] + + if points == problem.points: + total_ac[problem_id] += 1 + + # Only acknowledge first solves for live participations + if participation.virtual == 0 and (min_time is None or min_time > time): + min_time = time + first_solves[problem_id] = participation.id + + return first_solves, total_ac + + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) if format_data: # This prefix is used to help get the correct data from the format_data dictionary @@ -163,6 +190,7 @@ def display_user_problem(self, participation, contest_problem, frozen=False): # The cell will have `pending` css class if there is a new score-changing submission after the frozen time state = (('pending ' if frozen and format_data['is_frozen'] else '') + ('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solves.get(str(contest_problem.id), None) == participation.id else '') + self.best_solution_state(format_data[prefix + 'points'], contest_problem.points)) url = reverse('contest_user_submissions', args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]) @@ -217,7 +245,7 @@ def get_short_form_display(self): penalty = self.config['penalty'] if penalty: - yield ungettext( + yield ngettext( 'Each submission before the first maximum score submission will incur a **penalty of %d minute**.', 'Each submission before the first maximum score submission will incur a **penalty of %d minutes**.', penalty, @@ -229,7 +257,7 @@ def get_short_form_display(self): 'a non-zero score, followed by the time of the last score altering submission.') if self.contest.frozen_last_minutes: - yield ungettext( + yield ngettext( 'The scoreboard will be frozen in the **last %d minute**.', 'The scoreboard will be frozen in the **last %d minutes**.', self.contest.frozen_last_minutes, diff --git a/judge/contest_format/legacy_ioi.py b/judge/contest_format/legacy_ioi.py index e264543d6..b3688a3a8 100644 --- a/judge/contest_format/legacy_ioi.py +++ b/judge/contest_format/legacy_ioi.py @@ -16,9 +16,11 @@ @register_contest_format('ioi') class LegacyIOIContestFormat(DefaultContestFormat): name = gettext_lazy('IOI (pre-2016)') - config_defaults = {'cumtime': False} + config_defaults = {'cumtime': False, 'last_score_altering': False} """ cumtime: Specify True if time penalties are to be computed. Defaults to False. + last_score_altering: Specify True if ties are to be broken by the time of the last score altering submission. + Defaults to False. """ @classmethod @@ -42,6 +44,7 @@ def __init__(self, contest, config): def update_participation(self, participation): cumtime = 0 + last_submission_time = 0 score = 0 format_data = {} @@ -53,9 +56,11 @@ def update_participation(self, participation): .values_list('problem_id', 'time', 'points')) for problem_id, time, points in queryset: - if self.config['cumtime']: + if points: dt = (time - participation.start).total_seconds() - if points: + if self.config['last_score_altering']: + last_submission_time = max(last_submission_time, dt) + if self.config['cumtime']: cumtime += dt else: dt = 0 @@ -63,40 +68,76 @@ def update_participation(self, participation): format_data[str(problem_id)] = {'points': points, 'time': dt} score += points - participation.cumtime = max(cumtime, 0) + participation.cumtime = max(cumtime, 0) if self.config['cumtime'] else last_submission_time participation.score = round(score, self.contest.points_precision) - participation.tiebreaker = 0 + participation.tiebreaker = last_submission_time participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def get_first_solves_and_total_ac(self, problems, participations, frozen=False): + first_solves = {} + total_ac = {} + + show_time = self.config['cumtime'] or self.config.get('last_score_altering', False) + for problem in problems: + problem_id = str(problem.id) + min_time = None + first_solves[problem_id] = None + total_ac[problem_id] = 0 + + for participation in participations: + format_data = (participation.format_data or {}).get(problem_id) + if format_data: + points = format_data['points'] + time = format_data['time'] + + if points == problem.points: + total_ac[problem_id] += 1 + + # Only acknowledge first solves for live participations + if show_time and participation.virtual == 0 and (min_time is None or min_time > time): + min_time = time + first_solves[problem_id] = participation.id + + return first_solves, total_ac + + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) if format_data: + show_time = self.config['cumtime'] or self.config.get('last_score_altering', False) return format_html( '{points}
{time}
', state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solves.get(str(contest_problem.id), None) == participation.id else '') + self.best_solution_state(format_data['points'], contest_problem.points)), url=reverse('contest_user_submissions', args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]), points=floatformat(format_data['points'], -self.contest.points_precision), - time=nice_repr(timedelta(seconds=format_data['time']), 'noday') if self.config['cumtime'] else '', + time=nice_repr(timedelta(seconds=format_data['time']), 'noday') if show_time else '', ) else: return mark_safe('') def display_participation_result(self, participation, frozen=False): + show_time = self.config['cumtime'] or self.config.get('last_score_altering', False) return format_html( '{points}
{cumtime}
', url=reverse('contest_all_user_submissions', args=[self.contest.key, participation.user.user.username]), points=floatformat(participation.score, -self.contest.points_precision), - cumtime=nice_repr(timedelta(seconds=participation.cumtime), 'noday') if self.config['cumtime'] else '', + cumtime=nice_repr(timedelta(seconds=participation.cumtime), 'noday') if show_time else '', ) def get_short_form_display(self): yield _('The maximum score submission for each problem will be used.') - if self.config['cumtime']: + if self.config['last_score_altering']: + if self.config['cumtime']: + yield _('Ties will be broken by the sum of the last score altering submission time on problems with ' + 'a non-zero score, followed by the time of the last score altering submission.') + else: + yield _('Ties will be broken by the time of the last score altering submission.') + elif self.config['cumtime']: yield _('Ties will be broken by the sum of the last score altering submission time on problems with a ' 'non-zero score.') else: diff --git a/judge/contest_format/vnoj.py b/judge/contest_format/vnoj.py index 73e5dedc8..80f11894f 100644 --- a/judge/contest_format/vnoj.py +++ b/judge/contest_format/vnoj.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.html import format_html from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _, gettext_lazy, ungettext +from django.utils.translation import gettext as _, gettext_lazy, ngettext from judge.contest_format.default import DefaultContestFormat from judge.contest_format.registry import register_contest_format @@ -34,7 +34,7 @@ SELECT MIN(csub.date) FROM judge_contestsubmission ccs LEFT OUTER JOIN judge_submission csub ON (csub.id = ccs.submission_id) - WHERE ccs.problem_id = cp.id AND ccs.participation_id = %s AND ccs.points = MAX(cs.points)AND csub.date < %s + WHERE ccs.problem_id = cp.id AND ccs.participation_id = %s AND ccs.points = MAX(cs.points) AND csub.date < %s ) AS `time`, cp.id AS `prob` FROM judge_contestproblem cp INNER JOIN judge_contestsubmission cs ON (cs.problem_id = cp.id AND cs.participation_id = %s) LEFT OUTER JOIN @@ -163,38 +163,91 @@ def update_participation(self, participation): participation.format_data = format_data participation.save() - def display_user_problem(self, participation, contest_problem, frozen=False): + def get_first_solves_and_total_ac(self, problems, participations, frozen=False): + first_solves = {} + total_ac = {} + + for problem in problems: + problem_id = str(problem.id) + min_time = None + first_solves[problem_id] = None + total_ac[problem_id] = 0 + + for participation in participations: + format_data = (participation.format_data or {}).get(problem_id) + if format_data: + has_pending = bool(format_data.get('pending', 0)) + prefix = 'frozen_' if frozen and has_pending else '' + points = format_data[prefix + 'points'] + time = format_data[prefix + 'time'] + + if points == problem.points: + total_ac[problem_id] += 1 + + # Only acknowledge first solves for live participations + if participation.virtual == 0 and (min_time is None or min_time > time): + min_time = time + first_solves[problem_id] = participation.id + + return first_solves, total_ac + + def display_user_problem(self, participation, contest_problem, first_solves, frozen=False): format_data = (participation.format_data or {}).get(str(contest_problem.id)) if format_data: + first_solved = first_solves.get(str(contest_problem.id), None) == participation.id + url = reverse('contest_user_submissions', + args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]) + + if not frozen: + # Fast path for non-frozen contests + penalty = format_html( + ' ({penalty})', + penalty=floatformat(format_data['penalty']), + ) if format_data['penalty'] else '' + + state = (('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solved else '') + + self.best_solution_state(format_data['points'], contest_problem.points)) + + points = floatformat(format_data['points'], -self.contest.points_precision) + time = nice_repr(timedelta(seconds=format_data['time']), 'noday') + + return format_html( + '
{points}{penalty}
' + '
{time}
', + state=state, + url=url, + points=points, + penalty=penalty, + time=time, + ) + # This prefix is used to help get the correct data from the format_data dictionary has_pending = bool(format_data.get('pending', 0)) - - prefix = 'frozen_' if frozen and has_pending else '' + prefix = 'frozen_' if has_pending else '' # AC before frozen_time - if format_data[prefix + 'points'] == contest_problem.points: + if has_pending and format_data[prefix + 'points'] == contest_problem.points: + has_pending = False prefix = '' - frozen = False penalty = format_html( ' ({penalty})', penalty=floatformat(format_data[prefix + 'penalty']), ) if format_data[prefix + 'penalty'] else '' - state = (('pending ' if frozen and has_pending else '') + + state = (('pending ' if has_pending else '') + ('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') + + ('first-solve ' if first_solved else '') + self.best_solution_state(format_data[prefix + 'points'], contest_problem.points)) - url = reverse('contest_user_submissions', - args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]) - points = floatformat(format_data[prefix + 'points'], -self.contest.points_precision) time = nice_repr(timedelta(seconds=format_data[prefix + 'time']), 'noday') - pending = format_html(' [{pending}]', - pending=floatformat(format_data['pending'])) if frozen and has_pending else '' + pending = format_html(' [{pending}]', + pending=floatformat(format_data['pending'])) if has_pending else '' - if frozen and has_pending: + if has_pending: time = '?' # hide penalty if there are pending submissions penalty = '' @@ -227,7 +280,7 @@ def display_participation_result(self, participation, frozen=False): points = participation.score cumtime = participation.cumtime return format_html( - u'{points}
{cumtime}
', + '{points}
{cumtime}
', url=reverse('contest_all_user_submissions', args=[self.contest.key, participation.user.user.username]), points=floatformat(points, -self.contest.points_precision), @@ -239,7 +292,7 @@ def get_short_form_display(self): penalty = self.config['penalty'] if penalty: - yield ungettext( + yield ngettext( 'Each submission before the first maximum score submission will incur a **penalty of %d minute**.', 'Each submission before the first maximum score submission will incur a **penalty of %d minutes**.', penalty, @@ -258,7 +311,7 @@ def get_short_form_display(self): 'a non-zero score, followed by the time of the last score altering submission.') if self.contest.frozen_last_minutes: - yield ungettext( + yield ngettext( 'The scoreboard will be frozen in the **last %d minute**.', 'The scoreboard will be frozen in the **last %d minutes**.', self.contest.frozen_last_minutes, diff --git a/judge/fixtures/language_all.json b/judge/fixtures/language_all.json index 042292728..7e39b32ed 100644 --- a/judge/fixtures/language_all.json +++ b/judge/fixtures/language_all.json @@ -819,8 +819,8 @@ "model": "judge.language", "pk": 61, "fields": { - "key": "JAVA9", - "name": "Java 9", + "key": "JAVA", + "name": "Java", "short_name": null, "common_name": "Java", "ace": "java", @@ -951,23 +951,6 @@ "include_in_problem": true } }, -{ - "model": "judge.language", - "pk": 71, - "fields": { - "key": "JAVA10", - "name": "Java 10", - "short_name": null, - "common_name": "Java", - "ace": "java", - "pygments": "java", - "template": "import java.io.*;\r\nimport java.util.*;\r\n\r\npublic class Main {\r\n public static void main(String[] args) {\r\n\r\n }\r\n}", - "info": "", - "description": "", - "extension": "java", - "include_in_problem": true - } -}, { "model": "judge.language", "pk": 72, @@ -985,23 +968,6 @@ "include_in_problem": true } }, -{ - "model": "judge.language", - "pk": 74, - "fields": { - "key": "JAVA11", - "name": "Java 11", - "short_name": null, - "common_name": "Java", - "ace": "java", - "pygments": "java", - "template": "import java.io.*;\r\nimport java.util.*;\r\n\r\npublic class Main {\r\n public static void main(String[] args) {\r\n\r\n }\r\n}", - "info": "", - "description": "", - "extension": "java", - "include_in_problem": true - } -}, { "model": "judge.language", "pk": 75, diff --git a/judge/fixtures/language_small.json b/judge/fixtures/language_small.json index e528123fd..271269109 100644 --- a/judge/fixtures/language_small.json +++ b/judge/fixtures/language_small.json @@ -9,10 +9,12 @@ "common_name": "C++", "ace": "c_cpp", "pygments": "cpp", - "template": "#include \r\n\r\nint main() {\r\n return 0;\r\n}", + "template": "#include \r\n\r\nusing namespace std;\r\n\r\nint main() {\r\n ios_base::sync_with_stdio(false);\r\n cin.tie(NULL);\r\n \r\n return 0;\r\n}", "info": "", "description": "Compile options: `g++ -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "cpp", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -30,6 +32,8 @@ "info": "", "description": "Compile options: `g++ -std=c++11 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "cpp", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -47,6 +51,8 @@ "info": "", "description": "Compile options: `g++ -std=c++14 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "cpp", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -64,6 +70,8 @@ "info": "", "description": "Compile options: `g++ -std=c++17 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "cpp", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -81,6 +89,8 @@ "info": "", "description": "Compile options: `gcc -std=c99 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "c", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -98,6 +108,8 @@ "info": "", "description": "Compile options: `gcc -std=c11 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "c", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -115,6 +127,8 @@ "info": "", "description": "Compile options: `fpc -Fe/dev/stderr -O2`", "extension": "pas", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -130,8 +144,10 @@ "pygments": "python", "template": "", "info": "", - "description": "Compile options: `python -BS`", + "description": "Compile options: `python -m compileall -q`", "extension": "py", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -147,8 +163,10 @@ "pygments": "python3", "template": "", "info": "", - "description": "Compile options: `python3 -BS`", + "description": "Compile options: `python3 -m compileall -q`", "extension": "py", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -166,6 +184,8 @@ "info": "", "description": "Compile options: `javac8 -encoding UTF-8 -profile compact1`", "extension": "java", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -183,6 +203,8 @@ "info": "", "description": "Run options: `cat`", "extension": "txt", + "file_only": false, + "file_size_limit": 0, "include_in_problem": false } }, @@ -198,7 +220,7 @@ "pygments": "text", "template": "", "info": "", - "description": "Run options: `scratch-run`", + "description": "Check options: `scratch-run --check`", "extension": "sb3", "file_only": true, "file_size_limit": 1, @@ -238,6 +260,8 @@ "info": "", "description": "Compile options: `g++ -std=c++20 -Wall -DONLINE_JUDGE -O2 -lm -fmax-errors=5 -march=native -s`", "extension": "cpp", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -253,8 +277,10 @@ "pygments": "kotlin", "template": "", "info": "", - "description": "Compile options: `kotlinc`", + "description": "Compile options: `kotlinc -include-runtime`", "extension": "kt", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -270,8 +296,10 @@ "pygments": "python", "template": "", "info": "", - "description": "Compile options: `pypy -BS`", + "description": "Compile options: `pypy -m compileall -q`", "extension": "py", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -287,8 +315,10 @@ "pygments": "python", "template": "", "info": "", - "description": "Compile options: `pypy3 -BS`", + "description": "Compile options: `pypy3 -m compileall -q`", "extension": "py", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } }, @@ -296,16 +326,18 @@ "model": "judge.language", "pk": 18, "fields": { - "key": "JAVA17", - "name": "Java 17", + "key": "JAVA", + "name": "Java 19", "short_name": null, "common_name": "Java", "ace": "java", "pygments": "java", "template": "import java.io.*;\r\nimport java.util.*;\r\n\r\npublic class Main {\r\n public static void main(String[] args) {\r\n\r\n }\r\n}", "info": "", - "description": "Compile options: `javac17 -encoding UTF-8`", + "description": "Compile options: `javac19 -encoding UTF-8`", "extension": "java", + "file_only": false, + "file_size_limit": 0, "include_in_problem": true } } diff --git a/judge/forms.py b/judge/forms.py index 8ef59f83e..d2e5daca0 100755 --- a/judge/forms.py +++ b/judge/forms.py @@ -16,7 +16,8 @@ from django.forms.widgets import DateTimeInput from django.template.defaultfilters import filesizeformat from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy as _, ngettext_lazy from django_ace import AceWidget from judge.models import BlogPost, Contest, ContestAnnouncement, ContestProblem, Language, LanguageLimit, \ @@ -31,23 +32,21 @@ TOTP_CODE_LENGTH: { 'regex_validator': RegexValidator( f'^[0-9]{{{TOTP_CODE_LENGTH}}}$', - _(f'Two-factor authentication tokens must be {TOTP_CODE_LENGTH} decimal digits.'), + format_lazy(ngettext_lazy('Two-factor authentication tokens must be {count} decimal digit.', + 'Two-factor authentication tokens must be {count} decimal digits.', + TOTP_CODE_LENGTH), count=TOTP_CODE_LENGTH), ), 'verify': lambda code, profile: not profile.check_totp_code(code), 'err': _('Invalid two-factor authentication token.'), }, 16: { - 'regex_validator': RegexValidator('^[A-Z0-9]{16}$', _('Scratch codes must be 16 base32 characters.')), + 'regex_validator': RegexValidator('^[A-Z0-9]{16}$', _('Scratch codes must be 16 Base32 characters.')), 'verify': lambda code, profile: code not in json.loads(profile.scratch_codes), 'err': _('Invalid scratch code.'), }, } -def fix_unicode(string, unsafe=tuple('\u202a\u202b\u202d\u202e')): - return string + (sum(k in unsafe for k in string) - string.count('\u202c')) * '\u202c' - - class ProfileForm(ModelForm): if newsletter_id is not None: newsletter = forms.BooleanField(label=_('Subscribe to contest updates'), initial=False, required=False) @@ -55,13 +54,14 @@ class ProfileForm(ModelForm): class Meta: model = Profile - fields = ['about', 'display_badge', 'organizations', 'timezone', 'language', 'ace_theme', 'user_script'] + fields = ['about', 'display_badge', 'organizations', 'timezone', 'language', 'ace_theme', + 'site_theme', 'user_script'] widgets = { 'display_badge': Select2Widget(attrs={'style': 'width:200px'}), - 'user_script': AceWidget(theme='github'), 'timezone': Select2Widget(attrs={'style': 'width:200px'}), 'language': Select2Widget(attrs={'style': 'width:200px'}), 'ace_theme': Select2Widget(attrs={'style': 'width:200px'}), + 'site_theme': Select2Widget(attrs={'style': 'width:200px'}), } # Make sure that users cannot change their `about` in contest mode @@ -81,8 +81,9 @@ class Meta: ) def clean_about(self): - if 'about' in self.changed_data and not self.instance.has_any_solves: - raise ValidationError(_('You must solve at least one problem before you can update your profile.')) + if 'about' in self.changed_data and not self.instance.has_enough_solves: + raise ValidationError(_('You must solve at least %d problems before you can update your profile.') + % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT) return self.cleaned_data['about'] def clean(self): @@ -90,8 +91,9 @@ def clean(self): max_orgs = settings.DMOJ_USER_MAX_ORGANIZATION_COUNT if sum(org.is_open for org in organizations) > max_orgs: - raise ValidationError( - _('You may not be part of more than {count} public organizations.').format(count=max_orgs)) + raise ValidationError(ngettext_lazy('You may not be part of more than {count} public organization.', + 'You may not be part of more than {count} public organizations.', + max_orgs).format(count=max_orgs)) return self.cleaned_data @@ -399,9 +401,16 @@ def get_choices(): class OrganizationForm(ModelForm): class Meta: model = Organization - fields = ['name', 'slug', 'is_open', 'about', 'logo_override_image'] + fields = ['name', 'slug', 'is_open', 'about', 'logo_override_image', 'admins'] if HeavyPreviewPageDownWidget is not None: widgets = {'about': HeavyPreviewPageDownWidget(preview=reverse_lazy('organization_preview'))} + if HeavySelect2MultipleWidget is not None: + widgets.update({ + 'admins': HeavySelect2MultipleWidget( + data_view='profile_select2', + attrs={'style': 'width: 100%'}, + ), + }) class CustomAuthenticationForm(AuthenticationForm): @@ -451,7 +460,7 @@ def widget_attrs(self, widget): class TOTPForm(Form): TOLERANCE = settings.DMOJ_TOTP_TOLERANCE_HALF_MINUTES - totp_or_scratch_code = NoAutoCompleteCharField(required=False) + totp_or_scratch_code = NoAutoCompleteCharField(required=False, widget=forms.TextInput(attrs={'autofocus': True})) def __init__(self, *args, **kwargs): self.profile = kwargs.pop('profile') diff --git a/judge/highlight_code.py b/judge/highlight_code.py index 3b0ba946d..a37572a6d 100644 --- a/judge/highlight_code.py +++ b/judge/highlight_code.py @@ -1,10 +1,10 @@ -from django.utils.html import escape, mark_safe +from django.utils.html import format_html, mark_safe __all__ = ['highlight_code'] def _make_pre_code(code): - return mark_safe('
' + escape(code) + '
') + return format_html('
{0}
', code) try: diff --git a/judge/jinja2/datetime.py b/judge/jinja2/datetime.py index 5022fcf3e..60b3f9753 100644 --- a/judge/jinja2/datetime.py +++ b/judge/jinja2/datetime.py @@ -2,6 +2,9 @@ from django.template.defaultfilters import date, time from django.templatetags.tz import localtime +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.timezone import utc from django.utils.translation import gettext as _ from . import registry @@ -22,11 +25,8 @@ def wrapper(datetime, *args, **kwargs): @registry.function -@registry.render_with('widgets/relative-time.html') def relative_time(time, **kwargs): - return { - 'time': time, - 'format': kwargs.get('format', _('N j, Y, g:i a')), - 'rel_format': kwargs.get('rel', _('{time}')), - 'abs_format': kwargs.get('abs', _('on {time}')), - } + abs_time = date(time, kwargs.get('format', _('N j, Y, g:i a'))) + return mark_safe(f'' + f'{escape(kwargs.get("abs", _("on {time}")).replace("{time}", abs_time))}') diff --git a/judge/jinja2/markdown/__init__.py b/judge/jinja2/markdown/__init__.py index 81ce017cb..e33c675d6 100644 --- a/judge/jinja2/markdown/__init__.py +++ b/judge/jinja2/markdown/__init__.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse import markdown2 +from bleach.css_sanitizer import CSSSanitizer from bleach.sanitizer import Cleaner from django.conf import settings from lxml import html @@ -29,8 +30,9 @@ def get_cleaner(name, params): if name in cleaner_cache: return cleaner_cache[name] - if params.get('styles') is True: - params['styles'] = all_styles + styles = params.pop('styles', None) + if styles: + params['css_sanitizer'] = CSSSanitizer(allowed_css_properties=all_styles if styles is True else styles) if params.pop('mathml', False): params['tags'] = params.get('tags', []) + mathml_tags diff --git a/judge/jinja2/reference.py b/judge/jinja2/reference.py index 084a5bba9..4b5a99914 100644 --- a/judge/jinja2/reference.py +++ b/judge/jinja2/reference.py @@ -5,6 +5,7 @@ from ansi2html import Ansi2HTMLConverter from django.contrib.auth.models import AbstractUser from django.urls import reverse +from django.utils.html import escape from django.utils.safestring import mark_safe from lxml.html import Element @@ -18,12 +19,12 @@ def get_user(username, data): if not data: - element = Element('span') + element = Element('span', {'class': 'deleted-user'}) element.text = username return element element = Element('span', {'class': Profile.get_user_css_class(*data)}) - link = Element('a', {'href': reverse('user_page', args=[username])}) + link = Element('a', {'href': reverse('user_page', args=[username]), 'style': 'display: inline-block;'}) link.text = username element.append(link) return element @@ -142,7 +143,6 @@ def item_title(item): @registry.function -@registry.render_with('user/link.html') def link_user(user): if isinstance(user, Profile): user, profile = user.user, user @@ -152,7 +152,18 @@ def link_user(user): user, profile = user.user, user else: raise ValueError('Expected profile or user, got %s' % (type(user),)) - return {'user': user, 'profile': profile} + + if isinstance(profile, Profile) and profile.display_badge: + display_badge_img = f'' + else: + display_badge_img = '' + + return mark_safe(f'' + f'' + f'{escape(profile.display_name)}{display_badge_img}') @registry.function diff --git a/judge/jinja2/submission.py b/judge/jinja2/submission.py index 68460ea03..e92df7367 100644 --- a/judge/jinja2/submission.py +++ b/judge/jinja2/submission.py @@ -19,7 +19,10 @@ def submission_layout(submission, profile_id, user, completed_problem_ids, edita can_view = False can_edit = False - if user.has_perm('judge.edit_all_problem') or problem_id in editable_problem_ids: + if (user.has_perm('judge.edit_all_problem') or + (user.has_perm('judge.edit_public_problem') and submission.problem.is_public) or + # We try to avoid evaluating this as much as possible to keep it lazy. + problem_id in editable_problem_ids): can_view = True can_edit = True elif user.has_perm('judge.view_all_submission'): diff --git a/judge/jinja2/timedelta.py b/judge/jinja2/timedelta.py index 03fd9dbb2..f29403371 100644 --- a/judge/jinja2/timedelta.py +++ b/judge/jinja2/timedelta.py @@ -22,7 +22,7 @@ def seconds(timedelta): return timedelta.total_seconds() -@registry.filter +@registry.function @registry.render_with('time-remaining-fragment.html') def as_countdown(timedelta): return {'countdown': timedelta} diff --git a/judge/judgeapi.py b/judge/judgeapi.py index 56064c9bb..8650153bb 100644 --- a/judge/judgeapi.py +++ b/judge/judgeapi.py @@ -117,6 +117,10 @@ def disconnect_judge(judge, force=False): judge_request({'name': 'disconnect-judge', 'judge-id': judge.name, 'force': force}, reply=False) +def update_disable_judge(judge): + judge_request({'name': 'disable-judge', 'judge-id': judge.name, 'is-disabled': judge.is_disabled}) + + def abort_submission(submission): from .models import Submission # We only want to try to abort a submission if it's still grading, otherwise this can lead to fully graded diff --git a/judge/management/commands/export_contest_submissions_details.py b/judge/management/commands/export_contest_submissions_details.py new file mode 100644 index 000000000..2d00b65b7 --- /dev/null +++ b/judge/management/commands/export_contest_submissions_details.py @@ -0,0 +1,57 @@ +import csv + +from django.core.management.base import BaseCommand, CommandError + +from judge.models import Contest, Submission, SubmissionTestCase +from judge.utils.raw_sql import use_straight_join +from judge.views.submission import submission_related + + +class Command(BaseCommand): + help = 'export contest submissions details' + + def add_arguments(self, parser): + parser.add_argument('key', help='contest key') + parser.add_argument('output', help='output file') + + def handle(self, *args, **options): + contest_key = options['key'] + + contest = Contest.objects.filter(key=contest_key).first() + if contest is None: + raise CommandError('contest not found') + + queryset = Submission.objects.all() + use_straight_join(queryset) + queryset = submission_related(queryset.order_by('-id')) + queryset = queryset.filter(contest_object=contest) + + self.export_queryset_to_output(queryset, options['output']) + + def export_queryset_to_output(self, queryset, output_file): + fout = open(output_file, 'w', newline='') + + writer = csv.DictWriter(fout, fieldnames=['username', 'problem', 'submission', 'testcase', + 'points', 'time', 'memory', 'feedback']) + writer.writeheader() + + for submission in queryset: + testcases = SubmissionTestCase.objects.filter(submission=submission) + + for testcase in testcases: + case_id = f'case{testcase.case}' + if submission.batch: + case_id += f'/batch{testcase.batch}' + + writer.writerow({ + 'username': submission.user.username, + 'problem': submission.problem.code, + 'submission': submission.id, + 'testcase': case_id, + 'points': testcase.points, + 'time': testcase.time, + 'memory': testcase.memory, + 'feedback': testcase.extended_feedback, + }) + + fout.close() diff --git a/judge/management/commands/generate_sitemap.py b/judge/management/commands/generate_sitemap.py new file mode 100644 index 000000000..d71912b18 --- /dev/null +++ b/judge/management/commands/generate_sitemap.py @@ -0,0 +1,69 @@ +import sys +from pathlib import Path +from urllib.parse import urljoin + +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from django.template.loader import get_template + +from judge.sitemap import sitemaps + + +class Command(BaseCommand): + requires_system_checks = False + + def add_arguments(self, parser): + parser.add_argument('directory', help='directory to generate the sitemap in') + parser.add_argument('-s', '--site', type=int, help='ID of the site to generate the sitemap for') + parser.add_argument('-p', '--protocol', default='https', help='protocol to use for links') + parser.add_argument('-d', '--subdir', '--subdirectory', default='sitemaps', + help='subdirectory for individual sitemap files') + parser.add_argument('-P', '--prefix', help='URL prefix for individual sitemaps') + + def handle(self, *args, **options): + directory = Path(options['directory']) + protocol = options['protocol'] + subdirectory = options['subdir'] + verbose = options['verbosity'] > 1 + + try: + site = Site.objects.get(id=options['site']) if options['site'] else Site.objects.get_current() + except Site.DoesNotExist: + self.stderr.write('Pass a valid site ID for -s/--site.') + sys.exit(1) + + if site is None: + self.stderr.write('Pass -s/--site to set a site ID.') + sys.exit(1) + + prefix = options['prefix'] or f'{protocol}://{site.domain}/{subdirectory}/' + if not prefix.endswith('/'): + self.stderr.write('-P/--prefix needs to end with a / or bad things will happen.') + sys.exit(1) + + maps = [] + maps_dir = directory / subdirectory + maps_dir.mkdir(parents=True, exist_ok=True) + + map_template = get_template('sitemap.xml') + index_template = get_template('sitemap_index.xml') + + for name, sitemap in sitemaps.items(): + if callable(sitemap): + sitemap = sitemap() + + for page in range(1, sitemap.paginator.num_pages + 1): + file = f'sitemap-{name}-{page}.xml' + if verbose: + self.stdout.write(f'Rendering sitemap {file}...\n') + + urls = sitemap.get_urls(page=page, site=site, protocol=protocol) + with open(maps_dir / file, 'w', encoding='utf-8') as f: + f.write(map_template.render({'urlset': urls})) + maps.append(file) + + if verbose: + self.stdout.write('Rendering sitemap index file...') + + with open(directory / 'sitemap.xml', 'w', encoding='utf-8') as f: + f.write(index_template.render({'sitemaps': [urljoin(prefix, file) for file in maps]})) diff --git a/judge/management/commands/import_polygon_package.py b/judge/management/commands/import_polygon_package.py index 8217f8f9b..d1128e7f3 100644 --- a/judge/management/commands/import_polygon_package.py +++ b/judge/management/commands/import_polygon_package.py @@ -11,49 +11,70 @@ from django.conf import settings from django.contrib.sites.models import Site from django.core.files import File +from django.core.files.storage import default_storage from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.urls import reverse -from django.utils import translation +from django.utils import timezone, translation from lxml import etree as ET -from judge.models import Language, Problem, ProblemData, ProblemGroup, ProblemTestCase, ProblemTranslation, ProblemType +from judge.models import Language, Problem, ProblemData, ProblemGroup, ProblemTestCase, ProblemTranslation, \ + ProblemType, Profile, Solution from judge.utils.problem_data import ProblemDataCompiler from judge.views.widgets import django_uploader -PANDOC_FILTER = """ -local List = require 'pandoc.List' - -function normalize_quote(text) +PANDOC_FILTER = r""" +local function normalize_quote(text) -- These four quotes are disallowed characters. -- See DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS - text = text:gsub('\\u{2018}', "'") -- left single quote - text = text:gsub('\\u{2019}', "'") -- right single quote - text = text:gsub('\\u{201C}', '"') -- left double quote - text = text:gsub('\\u{201D}', '"') -- right double quote + text = text:gsub('\u{2018}', "'") -- left single quote + text = text:gsub('\u{2019}', "'") -- right single quote + text = text:gsub('\u{201C}', '"') -- left double quote + text = text:gsub('\u{201D}', '"') -- right double quote + return text +end + +local function escape_html_content(text) + -- Escape HTML/Markdown/MathJax syntax characters + text = text:gsub('&', '&') -- must be first + text = text:gsub('<', "<") + text = text:gsub('>', ">") + text = text:gsub('*', '\\*') + text = text:gsub('_', '\\_') + text = text:gsub('%$', '%$') + text = text:gsub('~', '~') return text end function Math(m) -- Fix math delimiters local delimiter = m.mathtype == 'InlineMath' and '~' or '$$' - return pandoc.RawInline('markdown', delimiter .. m.text .. delimiter) + return pandoc.RawInline('html', delimiter .. m.text .. delimiter) end function Image(el) - -- And line breaks before the image - return {pandoc.RawInline('markdown', '\\n\\n'), el} + -- And blank lines before and after the image for caption to work + return {pandoc.RawInline('markdown', '\n\n'), el, pandoc.RawInline('markdown', '\n\n')} end function Code(el) - -- Normalize quotes - el.text = normalize_quote(el.text) - return el + -- Normalize quotes and render similar to Codeforces + local text = normalize_quote(el.text) + text = escape_html_content(text) + return pandoc.RawInline('html', '' .. text .. '') end function CodeBlock(el) -- Normalize quotes el.text = normalize_quote(el.text) + + -- Set language to empty string if it's nil + -- This is a hack to force backtick code blocks instead of indented code blocks + -- See https://github.com/jgm/pandoc/issues/7033 + if el.classes[1] == nil then + el.classes[1] = '' + end + return el end @@ -67,31 +88,134 @@ end function Str(el) - -- en dash and em dash would still show up correctly if we don't escape - -- them, but they would be hardly noticeable while editing. - el.text = el.text:gsub('\\u{2013}', '–') - el.text = el.text:gsub('\\u{2014}', '—') - -- Normalize quotes el.text = normalize_quote(el.text) - return el + -- en dash/em dash/non-breaking space would still show up correctly if we don't escape them, + -- but they would be hardly noticeable while editing. + local res = {} + local part = '' + for c in el.text:gmatch(utf8.charpattern) do + if c == '\u{2013}' then + -- en dash + if part ~= '' then + table.insert(res, pandoc.Str(part)) + part = '' + end + table.insert(res, pandoc.RawInline('html', '–')) + elseif c == '\u{2014}' then + -- em dash + if part ~= '' then + table.insert(res, pandoc.Str(part)) + part = '' + end + table.insert(res, pandoc.RawInline('html', '—')) + elseif c == '\u{00A0}' then + -- Non-breaking space + if part ~= '' then + table.insert(res, pandoc.Str(part)) + part = '' + end + table.insert(res, pandoc.RawInline('html', ' ')) + else + part = part .. c + end + end + if part ~= '' then + table.insert(res, pandoc.Str(part)) + end + + return res end function Div(el) - -- Currently only used for
- -- FIXME: What about other classes? - local res = List:new{} - table.insert(res, pandoc.RawBlock('markdown', '<' .. el.classes[1] .. '>')) - for _, block in ipairs(el.content) do - table.insert(res, block) + if el.classes[1] == 'center' then + local res = {} + table.insert(res, pandoc.RawBlock('markdown', '<' .. el.classes[1] .. '>')) + for _, block in ipairs(el.content) do + table.insert(res, block) + end + table.insert(res, pandoc.RawBlock('markdown', '')) + return res + + elseif el.classes[1] == 'epigraph' then + local filter = { + Math = Math, + Code = Code, + Quoted = Quoted, + Str = Str, + Para = function (s) + return pandoc.Plain(s.content) + end, + Span = function (s) + return s.content + end + } + + function renderHTML(el) + local doc = pandoc.Pandoc({el}) + local rendered = pandoc.write(doc:walk(filter), 'html') + return pandoc.RawBlock('markdown', rendered) + end + + local res = {} + table.insert(res, pandoc.RawBlock('markdown', '
')) + if el.content[1] then + table.insert(res, renderHTML(el.content[1])) + end + table.insert(res, pandoc.RawBlock('markdown', '
')) + if el.content[2] then + table.insert(res, renderHTML(el.content[2])) + end + table.insert(res, pandoc.RawBlock('markdown', '
')) + return res end - table.insert(res, pandoc.RawBlock('markdown', '')) - return res + + return nil end """ +# Polygon uses some custom macros: https://polygon.codeforces.com/docs/statements-tex-manual +# For example, \bf is deprecated in modern LaTeX, but Polygon treats it the same as \textbf +# and recommends writing \bf{...} instead of \textbf{...} for brevity. +# Similar for \it, \tt, \t +# We just redefine them to their modern counterparts. +# Note that this would break {\bf abcd}, but AFAIK Polygon never recommends that so it's fine. +TEX_MACROS = r""" +\renewcommand{\bf}{\textbf} +\renewcommand{\it}{\textit} +\renewcommand{\tt}{\texttt} +\renewcommand{\t}{\texttt} +""" + + +def pandoc_tex_to_markdown(tex): + tex = TEX_MACROS + tex + with tempfile.TemporaryDirectory() as tmp_dir: + with open(os.path.join(tmp_dir, 'temp.tex'), 'w', encoding='utf-8') as f: + f.write(tex) + + with open(os.path.join(tmp_dir, 'filter.lua'), 'w', encoding='utf-8') as f: + f.write(PANDOC_FILTER) + + subprocess.run( + ['pandoc', '--lua-filter=filter.lua', '-t', 'gfm', '-o', 'temp.md', 'temp.tex'], + cwd=tmp_dir, + check=True, + ) + + with open(os.path.join(tmp_dir, 'temp.md'), 'r', encoding='utf-8') as f: + md = f.read() + + return md + + +def pandoc_get_version(): + parts = subprocess.check_output(['pandoc', '--version']).decode().splitlines()[0].split(' ')[1].split('.') + return tuple(map(int, parts)) + + def parse_checker(problem_meta, root, package): checker = root.find('.//checker') if checker is None: @@ -167,50 +291,125 @@ def parse_tests(problem_meta, root, package): print(f'Time limit: {problem_meta["time_limit"]}s') print(f'Memory limit: {problem_meta["memory_limit"] // 1024}MB') - problem_meta['cases'] = [] + problem_meta['cases_data'] = [] problem_meta['batches'] = {} + problem_meta['normal_cases'] = [] problem_meta['zipfile'] = os.path.join(problem_meta['tmp_dir'].name, 'tests.zip') - tests_zip = zipfile.ZipFile(problem_meta['zipfile'], 'w') - input_path_pattern = testset.find('input-path-pattern').text - answer_path_pattern = testset.find('answer-path-pattern').text - total_points = 0 + # Tests can be aggregated into batches (called groups in Polygon). + # Each batch can have one of two point policies: + # - complete-group: contestant gets points only if all tests in the batch are solved. + # - each-test: contestant gets points for each test solved + # Our judge only supports complete-group batches. + # For each-test batches, their tests are added as normal tests. + # Each batch can also have a list of dependencies, which are other batches + # that must be fully solved before the batch is run. + # To support dependencies, we just add all dependent tests before the actual tests. + # (There is actually a more elegant way to do this by using field `dependencies` in init.yml, + # but site does not support it yet) + # Our judge does cache result for each test, so the same test will not be run twice. + # In addition, we only support dependencies for complete-group batches. + # (Technically, we could support dependencies for each-test batch by splitting it + # into multiple complete-group batches, but that's too complicated) groups = testset.find('groups') if groups is not None: for group in groups.getchildren(): + name = group.get('name') + points = int(float(group.get('points', 0))) points_policy = group.get('points-policy') - if points_policy == 'complete-group': - points = int(float(group.get('points', 0))) - problem_meta['batches'][group.get('name')] = {'points': points, 'cases': []} - total_points += points - - for i, test in enumerate(testset.find('tests').getchildren(), start=1): - input_path = input_path_pattern % i - answer_path = answer_path_pattern % i - points = int(float(test.get('points', 0))) - total_points += points - - tests_zip.writestr(f'{i:02d}.inp', package.read(input_path)) - tests_zip.writestr(f'{i:02d}.out', package.read(answer_path)) - - group = test.get('group', '') - if group in problem_meta['batches']: - problem_meta['batches'][group]['cases'].append({ - 'input_file': f'{i:02d}.inp', - 'output_file': f'{i:02d}.out', - }) - else: - problem_meta['cases'].append({ - 'input_file': f'{i:02d}.inp', - 'output_file': f'{i:02d}.out', + dependencies = group.find('dependencies') + if dependencies is None: + dependencies = [] + else: + dependencies = [d.get('group') for d in dependencies.getchildren()] + + assert points_policy in ['complete-group', 'each-test'] + if points_policy == 'each-test' and len(dependencies) > 0: + raise CommandError('dependencies are only supported for batches with complete-group point policy') + + problem_meta['batches'][name] = { + 'name': name, + 'points': points, + 'points_policy': points_policy, + 'dependencies': dependencies, + 'cases': [], + } + + with zipfile.ZipFile(problem_meta['zipfile'], 'w') as tests_zip: + input_path_pattern = testset.find('input-path-pattern').text + answer_path_pattern = testset.find('answer-path-pattern').text + for i, test in enumerate(testset.find('tests').getchildren()): + points = int(float(test.get('points', 0))) + input_path = input_path_pattern % (i + 1) + answer_path = answer_path_pattern % (i + 1) + input_file = f'{(i + 1):02d}.inp' + output_file = f'{(i + 1):02d}.out' + + tests_zip.writestr(input_file, package.read(input_path)) + tests_zip.writestr(output_file, package.read(answer_path)) + + problem_meta['cases_data'].append({ + 'index': i, + 'input_file': input_file, + 'output_file': output_file, 'points': points, }) - tests_zip.close() + group = test.get('group', '') + if group in problem_meta['batches']: + problem_meta['batches'][group]['cases'].append(i) + else: + problem_meta['normal_cases'].append(i) + + def get_tests_by_batch(name): + batch = problem_meta['batches'][name] + + if len(batch['dependencies']) == 0: + return batch['cases'] + + # Polygon guarantees no cycles + cases = set(batch['cases']) + for dependency in batch['dependencies']: + cases.update(get_tests_by_batch(dependency)) - print(f'Found {len(testset.find("tests").getchildren())} testcases!') + batch['dependencies'] = [] + batch['cases'] = list(cases) + return batch['cases'] + each_test_batches = [] + for batch in problem_meta['batches'].values(): + if batch['points_policy'] == 'each-test': + each_test_batches.append(batch['name']) + problem_meta['normal_cases'] += batch['cases'] + continue + + batch['cases'] = get_tests_by_batch(batch['name']) + + for batch in each_test_batches: + del problem_meta['batches'][batch] + + # Ignore zero-point batches + zero_point_batches = [name for name, batch in problem_meta['batches'].items() if batch['points'] == 0] + if len(zero_point_batches) > 0: + print('Found zero-point batches:', ', '.join(zero_point_batches)) + print('Would you like ignore them (y/n)? ', end='', flush=True) + if input().lower() in ['y', 'yes']: + problem_meta['batches'] = { + name: batch for name, batch in problem_meta['batches'].items() if batch['points'] > 0 + } + print(f'Ignored {len(zero_point_batches)} zero-point batches') + + # Sort tests by index + problem_meta['normal_cases'].sort() + for batch in problem_meta['batches'].values(): + batch['cases'].sort() + + print(f'Found {len(testset.find("tests").getchildren())} tests!') + print(f'Parsed as {len(problem_meta["batches"])} batches and {len(problem_meta["normal_cases"])} normal tests!') + + total_points = (sum(b['points'] for b in problem_meta['batches'].values()) + + sum(problem_meta['cases_data'][i]['points'] for i in problem_meta['normal_cases'])) if total_points == 0: print('Total points is zero. Set partial to False') problem_meta['partial'] = False @@ -233,38 +432,46 @@ def parse_tests(problem_meta, root, package): problem_meta['grader_args']['io_output_file'] = io_output_file -def pandoc_tex_to_markdown(tex): - tmp_dir = tempfile.TemporaryDirectory() - with open(os.path.join(tmp_dir.name, 'temp.tex'), 'w') as f: - f.write(tex) - with open(os.path.join(tmp_dir.name, 'filter.lua'), 'w') as f: - f.write(PANDOC_FILTER) - subprocess.run(['pandoc', '--lua-filter=filter.lua', '-t', 'gfm', '-o', 'temp.md', 'temp.tex'], cwd=tmp_dir.name) - with open(os.path.join(tmp_dir.name, 'temp.md'), 'r') as f: - md = f.read() - tmp_dir.cleanup() +def parse_statements(problem_meta, root, package): + # Set default values + problem_meta['name'] = '' + problem_meta['description'] = '' + problem_meta['translations'] = [] + problem_meta['tutorial'] = '' - return md + def process_images(text): + image_cache = problem_meta['image_cache'] + def save_image(image_path): + norm_path = os.path.normpath(os.path.join(statement_folder, image_path)) + sha1 = hashlib.sha1() + sha1.update(package.open(norm_path, 'r').read()) + sha1 = sha1.hexdigest() -def parse_statements(problem_meta, root, package): - image_cache = {} - - def save_image(image_path): - norm_path = os.path.normpath(os.path.join(statement_folder, image_path)) - sha1 = hashlib.sha1() - sha1.update(package.open(norm_path, 'r').read()) - sha1 = sha1.hexdigest() - - if sha1 not in image_cache: - image = File( - file=package.open(norm_path, 'r'), - name=os.path.basename(image_path), + if sha1 not in image_cache: + image = File( + file=package.open(norm_path, 'r'), + name=os.path.basename(image_path), + ) + data = json.loads(django_uploader(image)) + image_cache[sha1] = data['link'] + + return image_cache[sha1] + + for image_path in set(re.findall(r'!\[image\]\((.+?)\)', text)): + text = text.replace( + f'![image]({image_path})', + f'![image]({save_image(image_path)})', ) - data = json.loads(django_uploader(image)) - image_cache[sha1] = data['link'] - return image_cache[sha1] + for img_tag in set(re.findall(r'<\s*img[^>]*>', text)): + image_path = re.search(r'<\s*img[^>]+src\s*=\s*(["\'])(.*?)\1[^>]*>', img_tag).group(2) + text = text.replace( + img_tag, + img_tag.replace(image_path, save_image(image_path)), + ) + + return text def parse_problem_properties(problem_properties): description = '' @@ -297,20 +504,6 @@ def parse_problem_properties(problem_properties): description += '\n## Notes\n\n' description += pandoc_tex_to_markdown(problem_properties['notes']) - # Images - for image_path in set(re.findall(r'!\[image\]\((.+?)\)', description)): - description = description.replace( - f'![image]({image_path})', - f'![image]({save_image(image_path)})', - ) - - for img_tag in set(re.findall(r'<\s*img[^>]*>', description)): - image_path = re.search(r'<\s*img[^>]+src\s*=\s*(["\'])(.*?)\1[^>]*>', description).group(2) - description = description.replace( - img_tag, - img_tag.replace(image_path, save_image(image_path)), - ) - return description def input_choice(prompt, choices): @@ -325,14 +518,12 @@ def input_choice(prompt, choices): if len(statements) == 0: print('Statement not found! Would you like to skip statement (y/n)? ', end='', flush=True) if input().lower() in ['y', 'yes']: - problem_meta['name'] = '' - problem_meta['description'] = '' - problem_meta['translations'] = [] return raise CommandError('statement not found') translations = [] + tutorials = [] for statement in statements: language = statement.get('language', 'unknown') statement_folder = os.path.dirname(statement.get('path')) @@ -346,9 +537,18 @@ def input_choice(prompt, choices): description = parse_problem_properties(problem_properties) translations.append({ 'language': language, - 'description': description, + 'description': process_images(description), }) + tutorial = problem_properties['tutorial'] + if isinstance(tutorial, str) and tutorial != '': + print(f'Converting tutorial in language {language} to Markdown') + tutorial = pandoc_tex_to_markdown(tutorial) + tutorials.append({ + 'language': language, + 'tutorial': tutorial, + }) + if len(translations) > 1: languages = [t['language'] for t in translations] print('Multilingual statements found:', languages) @@ -356,7 +556,16 @@ def input_choice(prompt, choices): else: main_language = translations[0]['language'] - problem_meta['translations'] = [] + if len(tutorials) > 1: + languages = [t['language'] for t in tutorials] + print('Multilingual tutorials found:', languages) + main_language = input_choice('Please select one as the sole tutorial: ', languages) + problem_meta['tutorial'] = next(t for t in tutorials if t['language'] == main_language)['tutorial'] + elif len(tutorials) > 0: + problem_meta['tutorial'] = tutorials[0]['tutorial'] + + # Process images for only the selected tutorial + problem_meta['tutorial'] = process_images(problem_meta['tutorial']) for t in translations: language = t['language'] @@ -396,6 +605,8 @@ def create_problem(problem_meta): ) problem.save() problem.allowed_languages.set(Language.objects.filter(include_in_problem=True)) + problem.authors.set(problem_meta['authors']) + problem.curators.set(problem_meta['curators']) problem.types.set([ProblemType.objects.order_by('id').first()]) # Uncategorized problem.save() @@ -407,6 +618,14 @@ def create_problem(problem_meta): description=tran['description'], ).save() + if problem_meta['tutorial'] != '': + Solution( + problem=problem, + is_public=False, + publish_on=timezone.now(), + content=problem_meta['tutorial'], + ).save() + with open(problem_meta['zipfile'], 'rb') as f: problem_data = ProblemData( problem=problem, @@ -436,8 +655,9 @@ def create_problem(problem_meta): start_batch = ProblemTestCase(dataset=problem, order=order, type='S', points=batch['points'], is_pretest=False) start_batch.save() - for case_data in batch['cases']: + for case_index in batch['cases']: order += 1 + case_data = problem_meta['cases_data'][case_index] case = ProblemTestCase( dataset=problem, order=order, @@ -452,8 +672,9 @@ def create_problem(problem_meta): end_batch = ProblemTestCase(dataset=problem, order=order, type='E', is_pretest=False) end_batch.save() - for case_data in problem_meta['cases']: + for case_index in problem_meta['normal_cases']: order += 1 + case_data = problem_meta['cases_data'][case_index] case = ProblemTestCase( dataset=problem, order=order, @@ -480,6 +701,8 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('package', help='path to package in zip format') parser.add_argument('code', help='problem code') + parser.add_argument('--authors', help='username of problem author', nargs='*') + parser.add_argument('--curators', help='username of problem curator', nargs='*') def handle(self, *args, **options): # Force using English @@ -488,6 +711,8 @@ def handle(self, *args, **options): # Check if pandoc is available if not shutil.which('pandoc'): raise CommandError('pandoc not installed') + if pandoc_get_version() < (3, 0, 0): + raise CommandError('pandoc version must be at least 3.0.0') # Let's validate the problem code right now. # We don't want to have done everything and still fail because @@ -503,10 +728,33 @@ def handle(self, *args, **options): root = ET.fromstring(package.read('problem.xml')) + problem_authors_args = options['authors'] or [] + problem_authors = [] + for username in problem_authors_args: + try: + profile = Profile.objects.get(user__username=username) + except Profile.DoesNotExist: + raise CommandError(f'user {username} does not exist') + + problem_authors.append(profile) + + problem_curators_args = options['curators'] or [] + problem_curators = [] + for username in problem_curators_args: + try: + profile = Profile.objects.get(user__username=username) + except Profile.DoesNotExist: + raise CommandError(f'user {username} does not exist') + + problem_curators.append(profile) + # A dictionary to hold all problem information. problem_meta = {} + problem_meta['image_cache'] = {} problem_meta['code'] = problem_code problem_meta['tmp_dir'] = tempfile.TemporaryDirectory() + problem_meta['authors'] = problem_authors + problem_meta['curators'] = problem_curators try: parse_checker(problem_meta, root, package) @@ -514,6 +762,11 @@ def handle(self, *args, **options): parse_statements(problem_meta, root, package) create_problem(problem_meta) except Exception: + # Remove imported images + for image_url in problem_meta['image_cache'].values(): + path = default_storage.path(os.path.join(settings.MARTOR_UPLOAD_MEDIA_DIR, os.path.basename(image_url))) + os.remove(path) + raise finally: problem_meta['tmp_dir'].cleanup() diff --git a/judge/management/commands/render_pdf.py b/judge/management/commands/render_pdf.py index eff831c3f..7f34d36f4 100644 --- a/judge/management/commands/render_pdf.py +++ b/judge/management/commands/render_pdf.py @@ -1,15 +1,10 @@ -import os -import shutil -import sys - from django.conf import settings from django.core.management.base import BaseCommand from django.template.loader import get_template from django.utils import translation from judge.models import Problem, ProblemTranslation -from judge.pdf_problems import DefaultPdfMaker, PhantomJSPdfMaker, PuppeteerPDFRender, SeleniumPDFRender, \ - SlimerJSPdfMaker +from judge.utils.pdfoid import render_pdf class Command(BaseCommand): @@ -17,15 +12,8 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('code', help='code of problem to render') - parser.add_argument('directory', nargs='?', help='directory to store temporaries') parser.add_argument('-l', '--language', default=settings.LANGUAGE_CODE, help='language to render PDF in') - parser.add_argument('-p', '--phantomjs', action='store_const', const=PhantomJSPdfMaker, - default=DefaultPdfMaker, dest='engine') - parser.add_argument('-s', '--slimerjs', action='store_const', const=SlimerJSPdfMaker, dest='engine') - parser.add_argument('-c', '--chrome', '--puppeteer', action='store_const', - const=PuppeteerPDFRender, dest='engine') - parser.add_argument('-S', '--selenium', action='store_const', const=SeleniumPDFRender, dest='engine') def handle(self, *args, **options): try: @@ -39,22 +27,14 @@ def handle(self, *args, **options): except ProblemTranslation.DoesNotExist: trans = None - directory = options['directory'] - with options['engine'](directory, clean_up=directory is None) as maker, \ - translation.override(options['language']): + with open(problem.code + '.pdf', 'wb') as f, translation.override(options['language']): problem_name = problem.name if trans is None else trans.name - maker.html = get_template('problem/raw.html').render({ - 'problem': problem, - 'problem_name': problem_name, - 'description': problem.description if trans is None else trans.description, - 'url': '', - 'math_engine': maker.math_engine, - }).replace('"//', '"https://').replace("'//", "'https://") - maker.title = problem_name - for file in ('style.css', 'pygment-github.css', 'mathjax_config.js'): - maker.load(file, os.path.join(settings.DMOJ_RESOURCES, file)) - maker.make(debug=True) - if not maker.success: - print(maker.log, file=sys.stderr) - elif directory is None: - shutil.move(maker.pdffile, problem.code + '.pdf') + f.write(render_pdf( + html=get_template('problem/raw.html').render({ + 'problem': problem, + 'problem_name': problem_name, + 'description': problem.description if trans is None else trans.description, + 'url': '', + }).replace('"//', '"https://').replace("'//", "'https://"), + title=problem_name, + )) diff --git a/judge/middleware.py b/judge/middleware.py index d7527ef79..3c76040fd 100644 --- a/judge/middleware.py +++ b/judge/middleware.py @@ -6,11 +6,20 @@ from django.conf import settings from django.contrib.auth.models import User +from django.contrib.sites.shortcuts import get_current_site +from django.core.cache import cache from django.http import HttpResponse, HttpResponseRedirect from django.urls import Resolver404, resolve, reverse from django.utils.encoding import force_bytes from requests.exceptions import HTTPError +from judge.models import MiscConfig + +try: + import uwsgi +except ImportError: + uwsgi = None + class ShortCircuitMiddleware: def __init__(self, get_response): @@ -36,6 +45,10 @@ def __call__(self, request): request.official_contest_mode = settings.VNOJ_OFFICIAL_CONTEST_MODE if request.user.is_authenticated: profile = request.profile = request.user.profile + if uwsgi: + uwsgi.set_logvar('username', request.user.username) + uwsgi.set_logvar('language', request.LANGUAGE_CODE) + logout_path = reverse('auth_logout') login_2fa_path = reverse('login_2fa') webauthn_path = reverse('webauthn_assert') @@ -63,6 +76,8 @@ def __init__(self, get_response): def __call__(self, request): if request.user.is_impersonate: + if uwsgi: + uwsgi.set_logvar('username', f'{request.impersonator.username} as {request.user.username}') request.no_profile_update = True request.profile = request.user.profile return self.get_response(request) @@ -123,3 +138,46 @@ def __call__(self, request): response.status_code = 401 return response return self.get_response(request) + + +class MiscConfigDict(dict): + __slots__ = ('language', 'site', 'backing') + + def __init__(self, language='', domain=None): + self.language = language + self.site = domain + self.backing = None + super().__init__() + + def __missing__(self, key): + if self.backing is None: + cache_key = 'misc_config' + backing = cache.get(cache_key) + if backing is None: + backing = dict(MiscConfig.objects.values_list('key', 'value')) + cache.set(cache_key, backing, 86400) + self.backing = backing + + keys = ['%s.%s' % (key, self.language), key] if self.language else [key] + if self.site is not None: + keys = ['%s:%s' % (self.site, key) for key in keys] + keys + + for attempt in keys: + result = self.backing.get(attempt) + if result is not None: + break + else: + result = '' + + self[key] = result + return result + + +class MiscConfigMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + domain = get_current_site(request).domain + request.misc_config = MiscConfigDict(language=request.LANGUAGE_CODE, domain=domain) + return self.get_response(request) diff --git a/judge/migrations/0001_squashed_0084_contest_formats.py b/judge/migrations/0001_squashed_0084_contest_formats.py index 22aa65974..1996c5b9b 100644 --- a/judge/migrations/0001_squashed_0084_contest_formats.py +++ b/judge/migrations/0001_squashed_0084_contest_formats.py @@ -103,7 +103,7 @@ class Migration(migrations.Migration): ('hide_problem_tags', models.BooleanField(default=False, help_text='Whether problem tags should be hidden by default.', verbose_name='hide problem tags')), ('run_pretests_only', models.BooleanField(default=False, help_text='Whether judges should grade pretests only, versus all testcases. Commonly set during a contest, then unset prior to rejudging user submissions when the contest ends.', verbose_name='run pretests only')), ('og_image', models.CharField(blank=True, default='', max_length=150, verbose_name='OpenGraph image')), - ('logo_override_image', models.CharField(blank=True, default='', help_text='This image will replace the default site logo for users inside the contest.', max_length=150, verbose_name='Logo override image')), + ('logo_override_image', models.CharField(blank=True, default='', help_text='This image will replace the default site logo for users inside the contest.', max_length=150, verbose_name='logo override image')), ('user_count', models.IntegerField(default=0, verbose_name='the amount of live participants')), ('summary', models.TextField(blank=True, help_text='Plain-text, shown in meta description tag, e.g. for social media.', verbose_name='contest summary')), ('access_code', models.CharField(blank=True, default='', help_text='An optional code to prompt contestants before they are allowed to join the contest. Leave it blank to disable.', max_length=255, verbose_name='access code')), @@ -180,7 +180,7 @@ class Migration(migrations.Migration): name='Judge', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Server name, hostname-style', max_length=50, unique=True)), + ('name', models.CharField(help_text='Server name, hostname-style.', max_length=50, unique=True, verbose_name='judge name')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='time of creation')), ('auth_key', models.CharField(help_text='A key to authenticated this judge', max_length=100, verbose_name='authentication key')), ('is_blocked', models.BooleanField(default=False, help_text='Whether this judge should be blocked from connecting, even if its key is correct.', verbose_name='block judge')), @@ -189,7 +189,7 @@ class Migration(migrations.Migration): ('ping', models.FloatField(null=True, verbose_name='response time')), ('load', models.FloatField(help_text='Load for the last minute, divided by processors to be fair.', null=True, verbose_name='system load')), ('description', models.TextField(blank=True, verbose_name='description')), - ('last_ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='Last connected IP')), + ('last_ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='last connected IP')), ], options={ 'ordering': ['name'], @@ -238,8 +238,8 @@ class Migration(migrations.Migration): ('key', models.CharField(max_length=20, unique=True, validators=[django.core.validators.RegexValidator('^[-\\w.]+$', 'License key must be ^[-\\w.]+$')], verbose_name='key')), ('link', models.CharField(max_length=256, verbose_name='link')), ('name', models.CharField(max_length=256, verbose_name='full name')), - ('display', models.CharField(blank=True, help_text='Displayed on pages under this license', max_length=256, verbose_name='short name')), - ('icon', models.CharField(blank=True, help_text='URL to the icon', max_length=256, verbose_name='icon')), + ('display', models.CharField(blank=True, help_text='Displayed on pages under this license.', max_length=256, verbose_name='short name')), + ('icon', models.CharField(blank=True, help_text='URL to the icon.', max_length=256, verbose_name='icon')), ('text', models.TextField(verbose_name='license text')), ], options={ @@ -251,8 +251,8 @@ class Migration(migrations.Migration): name='MiscConfig', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.CharField(db_index=True, max_length=30)), - ('value', models.TextField(blank=True)), + ('key', models.CharField(db_index=True, max_length=30, verbose_name='key')), + ('value', models.TextField(blank=True, verbose_name='value')), ], options={ 'verbose_name_plural': 'miscellaneous configuration', @@ -284,13 +284,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=128, verbose_name='organization title')), - ('slug', models.SlugField(help_text='Organization name shown in URL', max_length=128, verbose_name='organization slug')), - ('short_name', models.CharField(help_text='Displayed beside user name during contests', max_length=20, verbose_name='short name')), + ('slug', models.SlugField(help_text='Organization name shown in URLs.', max_length=128, verbose_name='organization slug')), + ('short_name', models.CharField(help_text='Displayed beside user name during contests.', max_length=20, verbose_name='short name')), ('about', models.TextField(verbose_name='organization description')), ('creation_date', models.DateTimeField(auto_now_add=True, verbose_name='creation date')), ('is_open', models.BooleanField(default=True, help_text='Allow joining organization', verbose_name='is open organization?')), - ('slots', models.IntegerField(blank=True, help_text='Maximum amount of users in this organization, only applicable to private organizations', null=True, verbose_name='maximum size')), - ('access_code', models.CharField(blank=True, help_text='Student access code', max_length=7, null=True, verbose_name='access code')), + ('slots', models.IntegerField(blank=True, help_text='Maximum amount of users in this organization, only applicable to private organizations.', null=True, verbose_name='maximum size')), + ('access_code', models.CharField(blank=True, help_text='Student access code.', max_length=7, null=True, verbose_name='access code')), ], options={ 'ordering': ['name'], @@ -344,7 +344,7 @@ class Migration(migrations.Migration): ('partial', models.BooleanField(default=False, verbose_name='allows partial points')), ('is_public', models.BooleanField(db_index=True, default=False, verbose_name='publicly visible')), ('is_manually_managed', models.BooleanField(db_index=True, default=False, help_text='Whether judges should be allowed to manage data or not', verbose_name='manually managed')), - ('date', models.DateTimeField(blank=True, db_index=True, help_text="Doesn't have magic ability to auto-publish due to backward compatibility", null=True, verbose_name='date of publishing')), + ('date', models.DateTimeField(blank=True, db_index=True, help_text="Doesn't have the magic ability to auto-publish due to backward compatibility.", null=True, verbose_name='date of publishing')), ('og_image', models.CharField(blank=True, max_length=150, verbose_name='OpenGraph image')), ('summary', models.TextField(blank=True, help_text='Plain-text, shown in meta description tag, e.g. for social media.', verbose_name='problem summary')), ('user_count', models.IntegerField(default=0, help_text='The number of users who solved the problem.', verbose_name='number of users')), @@ -377,7 +377,7 @@ class Migration(migrations.Migration): ('output_limit', models.IntegerField(blank=True, null=True, verbose_name='output limit length')), ('feedback', models.TextField(blank=True, verbose_name='init.yml generation feedback')), ('checker', models.CharField(blank=True, choices=[('standard', 'Standard'), ('floats', 'Floats'), ('floatsabs', 'Floats (absolute)'), ('floatsrel', 'Floats (relative)'), ('rstripped', 'Non-trailing spaces'), ('sorted', 'Unordered'), ('identical', 'Byte identical'), ('linecount', 'Line-by-line')], max_length=10, verbose_name='checker')), - ('checker_args', models.TextField(blank=True, help_text='checker arguments as a JSON object', verbose_name='checker arguments')), + ('checker_args', models.TextField(blank=True, help_text='Checker arguments as a JSON object.', verbose_name='checker arguments')), ('problem', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='data_files', to='judge.Problem', verbose_name='problem')), ], ), @@ -408,7 +408,7 @@ class Migration(migrations.Migration): ('output_prefix', models.IntegerField(blank=True, null=True, verbose_name='output prefix length')), ('output_limit', models.IntegerField(blank=True, null=True, verbose_name='output limit length')), ('checker', models.CharField(blank=True, choices=[('standard', 'Standard'), ('floats', 'Floats'), ('floatsabs', 'Floats (absolute)'), ('floatsrel', 'Floats (relative)'), ('rstripped', 'Non-trailing spaces'), ('sorted', 'Unordered'), ('identical', 'Byte identical'), ('linecount', 'Line-by-line')], max_length=10, verbose_name='checker')), - ('checker_args', models.TextField(blank=True, help_text='checker arguments as a JSON object', verbose_name='checker arguments')), + ('checker_args', models.TextField(blank=True, help_text='Checker arguments as a JSON object.', verbose_name='checker arguments')), ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cases', to='judge.Problem', verbose_name='problem data set')), ], ), @@ -456,9 +456,9 @@ class Migration(migrations.Migration): ('is_unlisted', models.BooleanField(default=False, help_text='User will not be ranked.', verbose_name='unlisted user')), ('rating', models.IntegerField(default=None, null=True)), ('user_script', models.TextField(blank=True, default='', help_text='User-defined JavaScript for site customization.', max_length=65536, verbose_name='user script')), - ('math_engine', models.CharField(choices=[('tex', 'Leave as LaTeX'), ('svg', 'SVG with PNG fallback'), ('mml', 'MathML only'), ('jax', 'MathJax with SVG/PNG fallback'), ('auto', 'Detect best quality')], default='auto', help_text='the rendering engine used to render math', max_length=4, verbose_name='math engine')), + ('math_engine', models.CharField(choices=[('tex', 'Leave as LaTeX'), ('svg', 'SVG with PNG fallback'), ('mml', 'MathML only'), ('jax', 'MathJax with SVG/PNG fallback'), ('auto', 'Detect best quality')], default='auto', help_text='The rendering engine used to render math.', max_length=4, verbose_name='math engine')), ('is_totp_enabled', models.BooleanField(default=False, help_text='check to enable TOTP-based two factor authentication', verbose_name='2FA enabled')), - ('totp_key', judge.models.profile.EncryptedNullCharField(blank=True, help_text='32 character base32-encoded key for TOTP', max_length=32, null=True, validators=[django.core.validators.RegexValidator('^$|^[A-Z2-7]{32}$', 'TOTP key must be empty or base32')], verbose_name='TOTP key')), + ('totp_key', judge.models.profile.EncryptedNullCharField(blank=True, help_text='32-character Base32-encoded key for TOTP.', max_length=32, null=True, validators=[django.core.validators.RegexValidator('^$|^[A-Z2-7]{32}$', 'TOTP key must be empty or Base32.')], verbose_name='TOTP key')), ('notes', models.TextField(blank=True, help_text='Notes for administrators regarding this user.', null=True, verbose_name='internal notes')), ('current_contest', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='judge.ContestParticipation', verbose_name='current contest')), ('language', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.Language', verbose_name='preferred language')), @@ -647,7 +647,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='organization', name='admins', - field=models.ManyToManyField(help_text='Those who can edit this organization', related_name='admin_of', to='judge.Profile', verbose_name='administrators'), + field=models.ManyToManyField(help_text='Those who can edit this organization.', related_name='admin_of', to='judge.Profile', verbose_name='administrators'), ), migrations.AddField( model_name='organization', diff --git a/judge/migrations/0086_rating_ceiling.py b/judge/migrations/0086_rating_ceiling.py index a544a2197..6ccd39088 100644 --- a/judge/migrations/0086_rating_ceiling.py +++ b/judge/migrations/0086_rating_ceiling.py @@ -13,11 +13,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='contest', name='rating_ceiling', - field=models.IntegerField(blank=True, help_text='Rating ceiling for contest', null=True, verbose_name='rating ceiling'), + field=models.IntegerField(blank=True, help_text='Do not rate users who have a higher rating.', null=True, verbose_name='rating ceiling'), ), migrations.AddField( model_name='contest', name='rating_floor', - field=models.IntegerField(blank=True, help_text='Rating floor for contest', null=True, verbose_name='rating floor'), + field=models.IntegerField(blank=True, help_text='Do not rate users who have a lower rating.', null=True, verbose_name='rating floor'), ), ] diff --git a/judge/migrations/0095_organization_logo_override.py b/judge/migrations/0095_organization_logo_override.py index b1d86eb24..71d5dee28 100644 --- a/judge/migrations/0095_organization_logo_override.py +++ b/judge/migrations/0095_organization_logo_override.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='organization', name='logo_override_image', - field=models.CharField(blank=True, default='', help_text='This image will replace the default site logo for users viewing the organization.', max_length=150, verbose_name='Logo override image'), + field=models.CharField(blank=True, default='', help_text='This image will replace the default site logo for users viewing the organization.', max_length=150, verbose_name='logo override image'), ), ] diff --git a/judge/migrations/0102_api_token.py b/judge/migrations/0102_api_token.py index 1e4c2da9c..2aabcbf78 100644 --- a/judge/migrations/0102_api_token.py +++ b/judge/migrations/0102_api_token.py @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='profile', name='api_token', - field=models.CharField(help_text='64 character hex-encoded API access token', max_length=64, null=True, validators=[django.core.validators.RegexValidator('^[a-f0-9]{64}$', 'API token must be None or hexadecimal')], verbose_name='API token'), + field=models.CharField(help_text='64-character hex-encoded API access token.', max_length=64, null=True, validators=[django.core.validators.RegexValidator('^[a-f0-9]{64}$', 'API token must be None or hexadecimal')], verbose_name='API token'), ), ] diff --git a/judge/migrations/0105_webauthn.py b/judge/migrations/0105_webauthn.py index 4bf228918..deef041a0 100644 --- a/judge/migrations/0105_webauthn.py +++ b/judge/migrations/0105_webauthn.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='profile', name='is_webauthn_enabled', - field=models.BooleanField(default=False, help_text='check to enable WebAuthn-based two-factor authentication', verbose_name='WebAuthn 2FA enabled'), + field=models.BooleanField(default=False, help_text='Check to enable WebAuthn-based two-factor authentication.', verbose_name='WebAuthn 2FA enabled'), ), migrations.CreateModel( name='WebAuthnCredential', diff --git a/judge/migrations/0114_add_custom_python_checker.py b/judge/migrations/0114_add_custom_python_checker.py index aff05935e..81b5f3a36 100644 --- a/judge/migrations/0114_add_custom_python_checker.py +++ b/judge/migrations/0114_add_custom_python_checker.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='judge', name='auth_key', - field=models.CharField(help_text='A key to authenticate this judge', max_length=100, verbose_name='authentication key'), + field=models.CharField(help_text='A key to authenticate this judge.', max_length=100, verbose_name='authentication key'), ), migrations.AlterField( model_name='language', @@ -159,12 +159,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='profile', name='is_totp_enabled', - field=models.BooleanField(default=False, help_text='check to enable TOTP-based two-factor authentication', verbose_name='TOTP 2FA enabled'), + field=models.BooleanField(default=False, help_text='Check to enable TOTP-based two-factor authentication.', verbose_name='TOTP 2FA enabled'), ), migrations.AlterField( model_name='profile', name='scratch_codes', - field=judge.models.profile.EncryptedNullCharField(blank=True, help_text='JSON array of 16 character base32-encoded codes for scratch codes', max_length=255, null=True, validators=[django.core.validators.RegexValidator('^(\\[\\])?$|^\\[("[A-Z0-9]{16}", *)*"[A-Z0-9]{16}"\\]$', 'Scratch codes must be empty or a JSON array of 16-character base32 codes')], verbose_name='scratch codes'), + field=judge.models.profile.EncryptedNullCharField(blank=True, help_text='JSON array of 16-character Base32-encoded codes for scratch codes.', max_length=255, null=True, validators=[django.core.validators.RegexValidator('^(\\[\\])?$|^\\[("[A-Z0-9]{16}", *)*"[A-Z0-9]{16}"\\]$', 'Scratch codes must be empty or a JSON array of 16-character Base32 codes.')], verbose_name='scratch codes'), ), migrations.AlterField( model_name='profile', diff --git a/judge/migrations/0148_add_output_only.py b/judge/migrations/0148_add_output_only.py index 6133197e9..df1c1a45b 100644 --- a/judge/migrations/0148_add_output_only.py +++ b/judge/migrations/0148_add_output_only.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='language', name='common_name', - field=models.CharField(help_text='Common name for the language. For example, the common name for C++03, C++11, and C++14 would be "C++"', max_length=20, verbose_name='common name'), + field=models.CharField(help_text='Common name for the language. For example, the common name for C++03, C++11, and C++14 would be "C++".', max_length=20, verbose_name='common name'), ), migrations.AlterField( model_name='problemdata', diff --git a/judge/migrations/0159_create_org.py b/judge/migrations/0159_create_org.py index d3c85ad6d..09ccab003 100644 --- a/judge/migrations/0159_create_org.py +++ b/judge/migrations/0159_create_org.py @@ -17,6 +17,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='organization', name='is_open', - field=models.BooleanField(default=False, help_text='Allow joining organization', verbose_name='is open organization?'), + field=models.BooleanField(default=False, help_text='Allow joining organization.', verbose_name='is open organization?'), ), ] diff --git a/judge/migrations/0167_profile_username_display_override.py b/judge/migrations/0167_profile_username_display_override.py index 095fd7288..109183687 100644 --- a/judge/migrations/0167_profile_username_display_override.py +++ b/judge/migrations/0167_profile_username_display_override.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='profile', name='username_display_override', - field=models.CharField(blank=True, help_text='Name displayed in place of username', max_length=100, verbose_name='display name override'), + field=models.CharField(blank=True, help_text='Name displayed in place of username.', max_length=100, verbose_name='display name override'), ), ] diff --git a/judge/migrations/0178_add_java_checker.py b/judge/migrations/0178_add_java_checker.py new file mode 100644 index 000000000..d4e8fa414 --- /dev/null +++ b/judge/migrations/0178_add_java_checker.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.26 on 2022-10-21 18:03 + +import django.core.validators +from django.db import migrations, models + +import judge.models.problem_data +import judge.utils.problem_data + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0177_testcase_result_visibility'), + ] + + operations = [ + migrations.AlterField( + model_name='problemdata', + name='custom_checker', + field=models.FileField(blank=True, null=True, storage=judge.utils.problem_data.ProblemDataStorage(), upload_to=judge.models.problem_data.problem_directory_file, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['cpp', 'py', 'pas', 'java'])], verbose_name='custom checker file'), + ), + ] diff --git a/judge/migrations/0179_add_ranking_access_code.py b/judge/migrations/0179_add_ranking_access_code.py new file mode 100644 index 000000000..4d7f3c765 --- /dev/null +++ b/judge/migrations/0179_add_ranking_access_code.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2022-11-15 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0178_add_java_checker'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='ranking_access_code', + field=models.CharField(blank=True, default='', help_text='An optional code to view the contest ranking. Leave it blank to disable.', max_length=255, verbose_name='ranking access code'), + ), + ] diff --git a/judge/migrations/0180_add_problem_data_hints.py b/judge/migrations/0180_add_problem_data_hints.py new file mode 100644 index 000000000..dc4305f7b --- /dev/null +++ b/judge/migrations/0180_add_problem_data_hints.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.16 on 2023-01-19 10:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0179_add_ranking_access_code'), + ] + + operations = [ + migrations.AddField( + model_name='problemdata', + name='nobigmath', + field=models.BooleanField(blank=True, null=True, verbose_name='disable bigInteger / bigDecimal'), + ), + migrations.AddField( + model_name='problemdata', + name='unicode', + field=models.BooleanField(blank=True, null=True, verbose_name='enable unicode'), + ), + migrations.AlterField( + model_name='blogpost', + name='og_image', + field=models.CharField(blank=True, default='', max_length=150, verbose_name='OpenGraph image'), + ), + migrations.AlterField( + model_name='comment', + name='hidden', + field=models.BooleanField(default=0, verbose_name='hidden'), + ), + migrations.AlterField( + model_name='submission', + name='result', + field=models.CharField(blank=True, choices=[('AC', 'Accepted'), ('WA', 'Wrong Answer'), ('TLE', 'Time Limit Exceeded'), ('MLE', 'Memory Limit Exceeded'), ('OLE', 'Output Limit Exceeded'), ('IR', 'Invalid Return'), ('RTE', 'Runtime Error'), ('CE', 'Compile Error'), ('IE', 'Internal Error'), ('SC', 'Short Circuited'), ('AB', 'Aborted')], db_index=True, default=None, max_length=3, null=True, verbose_name='result'), + ), + migrations.AlterField( + model_name='submissiontestcase', + name='status', + field=models.CharField(choices=[('AC', 'Accepted'), ('WA', 'Wrong Answer'), ('TLE', 'Time Limit Exceeded'), ('MLE', 'Memory Limit Exceeded'), ('OLE', 'Output Limit Exceeded'), ('IR', 'Invalid Return'), ('RTE', 'Runtime Error'), ('CE', 'Compile Error'), ('IE', 'Internal Error'), ('SC', 'Short Circuited'), ('AB', 'Aborted')], max_length=3, verbose_name='status flag'), + ), + migrations.AlterField( + model_name='ticketmessage', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_messages', to='judge.profile', verbose_name='user'), + ), + ] diff --git a/judge/migrations/0181_disable_judge.py b/judge/migrations/0181_disable_judge.py new file mode 100644 index 000000000..b6ffdca7d --- /dev/null +++ b/judge/migrations/0181_disable_judge.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2022-10-05 09:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0180_add_problem_data_hints'), + ] + + operations = [ + migrations.AddField( + model_name='judge', + name='is_disabled', + field=models.BooleanField(default=False, help_text='Whether this judge should be removed from judging queue.', verbose_name='disable judge'), + ), + ] diff --git a/judge/migrations/0182_remove_zombie_editorials.py b/judge/migrations/0182_remove_zombie_editorials.py new file mode 100644 index 000000000..6fa308ebb --- /dev/null +++ b/judge/migrations/0182_remove_zombie_editorials.py @@ -0,0 +1,23 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def delete_null_solutions(apps, scheme_editor): + model = apps.get_model('judge', 'Solution') + model.objects.filter(problem=None).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0181_disable_judge'), + ] + + operations = [ + migrations.RunPython(delete_null_solutions), + migrations.AlterField( + model_name='solution', + name='problem', + field=models.OneToOneField(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='solution', to='judge.Problem', verbose_name='associated problem'), + ), + ] diff --git a/judge/migrations/0183_fix_problem_data.py b/judge/migrations/0183_fix_problem_data.py new file mode 100644 index 000000000..d333291cf --- /dev/null +++ b/judge/migrations/0183_fix_problem_data.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.16 on 2023-01-31 15:53 + +import django.core.validators +from django.db import migrations, models + +import judge.models.problem_data +import judge.utils.problem_data + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0182_remove_zombie_editorials'), + ] + + operations = [ + migrations.AlterField( + model_name='problemdata', + name='custom_checker', + field=models.FileField(blank=True, null=True, storage=judge.utils.problem_data.ProblemDataStorage(), upload_to=judge.models.problem_data.problem_directory_file, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['cpp', 'pas', 'java'])], verbose_name='custom checker file'), + ), + migrations.AlterField( + model_name='problemdata', + name='custom_grader', + field=models.FileField(blank=True, null=True, storage=judge.utils.problem_data.ProblemDataStorage(), upload_to=judge.models.problem_data.problem_directory_file, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['cpp'])], verbose_name='custom grader file'), + ), + migrations.AlterField( + model_name='problemdata', + name='grader', + field=models.CharField(choices=[('standard', 'Standard'), ('interactive', 'Interactive'), ('signature', 'Function Signature Grading (IOI-style)'), ('output_only', 'Output Only')], default='standard', max_length=30, verbose_name='Grader'), + ), + ] diff --git a/judge/migrations/0184_profile_site_theme.py b/judge/migrations/0184_profile_site_theme.py new file mode 100644 index 000000000..ad79791b4 --- /dev/null +++ b/judge/migrations/0184_profile_site_theme.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.16 on 2023-02-05 02:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0183_fix_problem_data'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='site_theme', + field=models.CharField(choices=[('auto', 'Follow system default'), ('light', 'Light'), ('dark', 'Dark')], default='auto', max_length=10, verbose_name='site theme'), + ), + migrations.AlterField( + model_name='profile', + name='ace_theme', + field=models.CharField(choices=[('ambiance', 'Ambiance'), ('chaos', 'Chaos'), ('chrome', 'Chrome'), ('clouds', 'Clouds'), ('clouds_midnight', 'Clouds Midnight'), ('cobalt', 'Cobalt'), ('crimson_editor', 'Crimson Editor'), ('dawn', 'Dawn'), ('dreamweaver', 'Dreamweaver'), ('eclipse', 'Eclipse'), ('github', 'Github'), ('idle_fingers', 'Idle Fingers'), ('katzenmilch', 'Katzenmilch'), ('kr_theme', 'KR Theme'), ('kuroir', 'Kuroir'), ('merbivore', 'Merbivore'), ('merbivore_soft', 'Merbivore Soft'), ('mono_industrial', 'Mono Industrial'), ('monokai', 'Monokai'), ('pastel_on_dark', 'Pastel on Dark'), ('solarized_dark', 'Solarized Dark'), ('solarized_light', 'Solarized Light'), ('terminal', 'Terminal'), ('textmate', 'Textmate'), ('tomorrow', 'Tomorrow'), ('tomorrow_night', 'Tomorrow Night'), ('tomorrow_night_blue', 'Tomorrow Night Blue'), ('tomorrow_night_bright', 'Tomorrow Night Bright'), ('tomorrow_night_eighties', 'Tomorrow Night Eighties'), ('twilight', 'Twilight'), ('vibrant_ink', 'Vibrant Ink'), ('xcode', 'XCode')], default='github', max_length=30, verbose_name='Ace theme'), + ), + migrations.AlterField( + model_name='profile', + name='math_engine', + field=models.CharField(choices=[('tex', 'Leave as LaTeX'), ('svg', 'SVG only'), ('mml', 'MathML only'), ('jax', 'MathJax with SVG fallback'), ('auto', 'Detect best quality')], default='auto', help_text='The rendering engine used to render math.', max_length=4, verbose_name='math engine'), + ), + ] diff --git a/judge/migrations/0185_dark_ace_theme.py b/judge/migrations/0185_dark_ace_theme.py new file mode 100644 index 000000000..fd4f36185 --- /dev/null +++ b/judge/migrations/0185_dark_ace_theme.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.16 on 2023-02-08 01:11 + +from django.db import migrations, models + + +def github_to_auto(apps, schema_editor): + Profile = apps.get_model('judge', 'Profile') + Profile.objects.filter(ace_theme='github').update(ace_theme='auto') + + +def auto_to_github(apps, schema_editor): + Profile = apps.get_model('judge', 'Profile') + Profile.objects.filter(ace_theme='auto').update(ace_theme='github') + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0184_profile_site_theme'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='ace_theme', + field=models.CharField(choices=[('auto', 'Follow site theme'), ('ambiance', 'Ambiance'), ('chaos', 'Chaos'), ('chrome', 'Chrome'), ('clouds', 'Clouds'), ('clouds_midnight', 'Clouds Midnight'), ('cobalt', 'Cobalt'), ('crimson_editor', 'Crimson Editor'), ('dawn', 'Dawn'), ('dreamweaver', 'Dreamweaver'), ('eclipse', 'Eclipse'), ('github', 'Github'), ('idle_fingers', 'Idle Fingers'), ('katzenmilch', 'Katzenmilch'), ('kr_theme', 'KR Theme'), ('kuroir', 'Kuroir'), ('merbivore', 'Merbivore'), ('merbivore_soft', 'Merbivore Soft'), ('mono_industrial', 'Mono Industrial'), ('monokai', 'Monokai'), ('pastel_on_dark', 'Pastel on Dark'), ('solarized_dark', 'Solarized Dark'), ('solarized_light', 'Solarized Light'), ('terminal', 'Terminal'), ('textmate', 'Textmate'), ('tomorrow', 'Tomorrow'), ('tomorrow_night', 'Tomorrow Night'), ('tomorrow_night_blue', 'Tomorrow Night Blue'), ('tomorrow_night_bright', 'Tomorrow Night Bright'), ('tomorrow_night_eighties', 'Tomorrow Night Eighties'), ('twilight', 'Twilight'), ('vibrant_ink', 'Vibrant Ink'), ('xcode', 'XCode')], default='auto', max_length=30, verbose_name='Ace theme'), + ), + migrations.RunPython(github_to_auto, auto_to_github, atomic=True), + ] diff --git a/judge/migrations/0186_user_index_refactor.py b/judge/migrations/0186_user_index_refactor.py new file mode 100644 index 000000000..45e296811 --- /dev/null +++ b/judge/migrations/0186_user_index_refactor.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.18 on 2023-02-18 01:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0185_dark_ace_theme'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='contribution_points', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='profile', + name='performance_points', + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name='profile', + name='points', + field=models.FloatField(default=0), + ), + migrations.AlterField( + model_name='profile', + name='problem_count', + field=models.IntegerField(default=0), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['is_unlisted', '-performance_points'], name='judge_profi_is_unli_1410d8_idx'), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['is_unlisted', '-contribution_points'], name='judge_profi_is_unli_d31e5b_idx'), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['is_unlisted', '-rating'], name='judge_profi_is_unli_bcf16a_idx'), + ), + migrations.AddIndex( + model_name='profile', + index=models.Index(fields=['is_unlisted', '-problem_count'], name='judge_profi_is_unli_171bf3_idx'), + ), + ] diff --git a/judge/migrations/0187_submission_index_refactor.py b/judge/migrations/0187_submission_index_refactor.py new file mode 100644 index 000000000..a37cef681 --- /dev/null +++ b/judge/migrations/0187_submission_index_refactor.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.16 on 2023-02-12 01:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0186_user_index_refactor'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='points', + field=models.FloatField(null=True, verbose_name='points granted'), + ), + migrations.AlterField( + model_name='submission', + name='result', + field=models.CharField(blank=True, choices=[('AC', 'Accepted'), ('WA', 'Wrong Answer'), ('TLE', 'Time Limit Exceeded'), ('MLE', 'Memory Limit Exceeded'), ('OLE', 'Output Limit Exceeded'), ('IR', 'Invalid Return'), ('RTE', 'Runtime Error'), ('CE', 'Compile Error'), ('IE', 'Internal Error'), ('SC', 'Short Circuited'), ('AB', 'Aborted')], default=None, max_length=3, null=True, verbose_name='result'), + ), + migrations.AlterField( + model_name='submission', + name='time', + field=models.FloatField(null=True, verbose_name='execution time'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['problem', 'user', '-points', '-time'], name='judge_submi_problem_8d5e0a_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['result', '-id'], name='judge_submi_result_7a005c_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['result', 'language', '-id'], name='judge_submi_result_ba9a62_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['language', '-id'], name='judge_submi_languag_dfe850_idx'), + ), + ] diff --git a/judge/migrations/0188_submission_extra_index.py b/judge/migrations/0188_submission_extra_index.py new file mode 100644 index 000000000..ad97bdc03 --- /dev/null +++ b/judge/migrations/0188_submission_extra_index.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.16 on 2023-02-19 20:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0187_submission_index_refactor'), + ] + + operations = [ + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['result', 'problem'], name='judge_submi_result_a77e42_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['language', 'problem', 'result'], name='judge_submi_languag_380ab4_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['problem', 'result'], name='judge_submi_problem_49f8ec_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['user', 'problem', 'result'], name='judge_submi_user_id_650db3_idx'), + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['user', 'result'], name='judge_submi_user_id_ec9a4b_idx'), + ), + ] diff --git a/judge/migrations/0189_comment_revision_count.py b/judge/migrations/0189_comment_revision_count.py new file mode 100644 index 000000000..158599ab0 --- /dev/null +++ b/judge/migrations/0189_comment_revision_count.py @@ -0,0 +1,39 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db import migrations, models + + +def populate_revisions(apps, schema_editor): + db_alias = schema_editor.connection.alias + ContentType = apps.get_model('contenttypes', 'ContentType') + try: + content_type = ContentType.objects.get(app_label='judge', model='Comment') + except ObjectDoesNotExist: + # If you don't have content types, then you obviously haven't had any edited comments. + # Therefore, it's safe to all revision counts as zero. + pass + else: + schema_editor.execute("""\ +UPDATE `judge_comment` INNER JOIN ( + SELECT CAST(`reversion_version`.`object_id` AS INT) AS `id`, COUNT(*) AS `count` + FROM `reversion_version` + WHERE `reversion_version`.`content_type_id` = %s AND + `reversion_version`.`db` = %s + GROUP BY 1 +) `versions` ON (`judge_comment`.`id` = `versions`.`id`) +SET `judge_comment`.`revisions` = `versions`.`count`; +""", (content_type.id, db_alias)) + + +class Migration(migrations.Migration): + dependencies = [ + ('judge', '0188_submission_extra_index'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='revisions', + field=models.IntegerField(default=0, verbose_name='revisions'), + ), + migrations.RunPython(populate_revisions, migrations.RunPython.noop, atomic=False, elidable=True), + ] diff --git a/judge/migrations/0190_contest_problem_rank_index.py b/judge/migrations/0190_contest_problem_rank_index.py new file mode 100644 index 000000000..982cbfffb --- /dev/null +++ b/judge/migrations/0190_contest_problem_rank_index.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.16 on 2023-02-20 06:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0189_comment_revision_count'), + ] + + operations = [ + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['contest_object', 'problem', 'user', '-points', '-time'], name='judge_submi_contest_59fbe3_idx'), + ), + ] diff --git a/judge/migrations/0191_submission_index_cleanup.py b/judge/migrations/0191_submission_index_cleanup.py new file mode 100644 index 000000000..091951fe1 --- /dev/null +++ b/judge/migrations/0191_submission_index_cleanup.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.17 on 2023-02-20 14:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0190_contest_problem_rank_index'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='contest_object', + field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='judge.contest', verbose_name='contest'), + ), + migrations.AlterField( + model_name='submission', + name='language', + field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, to='judge.language', verbose_name='submission language'), + ), + migrations.AlterField( + model_name='submission', + name='problem', + field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, to='judge.problem'), + ), + migrations.AlterField( + model_name='submission', + name='user', + field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, to='judge.profile'), + ), + migrations.AlterField( + model_name='submissiontestcase', + name='submission', + field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='test_cases', to='judge.submission', verbose_name='associated submission'), + ), + ] diff --git a/judge/migrations/0192_add_registration_fields.py b/judge/migrations/0192_add_registration_fields.py new file mode 100644 index 000000000..b3e9f80ef --- /dev/null +++ b/judge/migrations/0192_add_registration_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.17 on 2023-05-01 08:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0191_submission_index_cleanup'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='registration_end', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='registration end time'), + ), + migrations.AddField( + model_name='contest', + name='registration_start', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='registration start time'), + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 9ea3e0b5a..8b2828236 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -6,8 +6,7 @@ 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, TranslatedProblemForeignKeyQuerySet, \ - TranslatedProblemQuerySet + 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, OrganizationRequest, Profile, WebAuthnCredential diff --git a/judge/models/choices.py b/judge/models/choices.py index 16c57decb..0797d59ef 100644 --- a/judge/models/choices.py +++ b/judge/models/choices.py @@ -21,6 +21,7 @@ def make_timezones(): del make_timezones ACE_THEMES = ( + ('auto', _('Follow site theme')), ('ambiance', 'Ambiance'), ('chaos', 'Chaos'), ('chrome', 'Chrome'), @@ -57,10 +58,16 @@ def make_timezones(): MATH_ENGINES_CHOICES = ( ('tex', _('Leave as LaTeX')), - ('svg', _('SVG with PNG fallback')), + ('svg', _('SVG only')), ('mml', _('MathML only')), - ('jax', _('MathJax with SVG/PNG fallback')), + ('jax', _('MathJax with SVG fallback')), ('auto', _('Detect best quality')), ) EFFECTIVE_MATH_ENGINES = ('svg', 'mml', 'tex', 'jax') + +SITE_THEMES = ( + ('auto', _('Follow system default')), + ('light', _('Light')), + ('dark', _('Dark')), +) diff --git a/judge/models/comment.py b/judge/models/comment.py index addae5a53..392cc1972 100644 --- a/judge/models/comment.py +++ b/judge/models/comment.py @@ -1,7 +1,6 @@ import itertools from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.core.validators import RegexValidator @@ -12,7 +11,6 @@ from django.utils.translation import gettext_lazy as _ from mptt.fields import TreeForeignKey from mptt.models import MPTTModel -from reversion.models import Version from judge.models.contest import Contest from judge.models.interface import BlogPost @@ -27,18 +25,6 @@ _(r'Page code must be ^\w+:[a-z0-9A-Z_]+$')) -class VersionRelation(GenericRelation): - def __init__(self): - super(VersionRelation, self).__init__(Version, object_id_field='object_id') - - def get_extra_restriction(self, where_class, alias, remote_alias): - cond = super(VersionRelation, self).get_extra_restriction(where_class, alias, remote_alias) - field = self.remote_field.model._meta.get_field('db') - lookup = field.get_lookup('exact')(field.get_col(remote_alias), 'default') - cond.add(lookup, 'AND') - return cond - - class Comment(MPTTModel): author = models.ForeignKey(Profile, verbose_name=_('commenter'), on_delete=CASCADE) time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True) @@ -46,10 +32,10 @@ class Comment(MPTTModel): validators=[comment_validator]) score = models.IntegerField(verbose_name=_('votes'), default=0) body = models.TextField(verbose_name=_('body of comment'), max_length=8192) - hidden = models.BooleanField(verbose_name=_('hide the comment'), default=0) + hidden = models.BooleanField(verbose_name=_('hidden'), default=0) parent = TreeForeignKey('self', verbose_name=_('parent'), null=True, blank=True, related_name='replies', on_delete=CASCADE) - versions = VersionRelation() + revisions = models.IntegerField(verbose_name=_('revisions'), default=0) class Meta: verbose_name = _('comment') @@ -64,9 +50,14 @@ def vote(self, delta): self.author.update_contribution_points(delta * settings.VNOJ_CP_COMMENT) @classmethod - def most_recent(cls, user, n, batch=None): - queryset = cls.objects.filter(hidden=False).select_related('author__user', 'author__display_badge') \ - .defer('author__about', 'body').order_by('-id') + def get_newest_visible_comments(cls, viewer, author=None, n=None, batch=None): + if author is not None: + queryset = cls.objects.filter(hidden=False, author=author) + else: + queryset = cls.objects.filter(hidden=False) + + queryset = (queryset.prefetch_related('author__user', 'author__display_badge') + .defer('author__about', 'body').order_by('-id')) problem_cache = CacheDict(lambda code: Problem.objects.defer('description', 'summary').get(code=code)) solution_cache = CacheDict(lambda code: Solution.objects.defer('content').get(problem__code=code)) @@ -74,13 +65,14 @@ def most_recent(cls, user, n, batch=None): blog_cache = CacheDict(lambda id: BlogPost.objects.defer('summary', 'content').get(id=id)) problemtag_cache = CacheDict(lambda code: TagProblem.objects.get(code=code)) - problem_access = CacheDict(lambda code: problem_cache[code].is_accessible_by(user)) - solution_access = CacheDict(lambda code: problem_access[code] and solution_cache[code].is_accessible_by(user)) - contest_access = CacheDict(lambda key: contest_cache[key].is_accessible_by(user)) - blog_access = CacheDict(lambda id: blog_cache[id].can_see(user)) + problem_access = CacheDict(lambda code: problem_cache[code].is_accessible_by(viewer)) + solution_access = CacheDict(lambda code: problem_access[code] and solution_cache[code].is_accessible_by(viewer)) + contest_access = CacheDict(lambda key: contest_cache[key].is_accessible_by(viewer)) + blog_access = CacheDict(lambda id: blog_cache[id].can_see(viewer)) if batch is None: batch = 2 * n + output = [] for i in itertools.count(0): slice = queryset[i * batch:i * batch + batch] @@ -111,10 +103,14 @@ def most_recent(cls, user, n, batch=None): else: if has_access: output.append(comment) - if len(output) >= n: + if n is not None and len(output) >= n: return output return output + @classmethod + def most_recent(cls, user, n, batch=None): + return cls.get_newest_visible_comments(viewer=user, n=n, batch=batch) + @cached_property def link(self): try: diff --git a/judge/models/contest.py b/judge/models/contest.py index 825cd1cec..2b33c055a 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -1,6 +1,6 @@ import hashlib import hmac -from datetime import timedelta +from datetime import date, timedelta from django.conf import settings from django.core.exceptions import ValidationError @@ -86,6 +86,10 @@ class Contest(models.Model): problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem') start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True) end_time = models.DateTimeField(verbose_name=_('end time'), db_index=True) + registration_start = models.DateTimeField(verbose_name=_('registration start time'), + blank=True, null=True, default=None) + registration_end = models.DateTimeField(verbose_name=_('registration end time'), + blank=True, null=True, default=None) time_limit = models.DurationField(verbose_name=_('time limit'), blank=True, null=True) frozen_last_minutes = models.IntegerField(verbose_name=_('frozen last minutes'), default=0, help_text=_('If set, the scoreboard will be frozen for the last X ' @@ -100,8 +104,8 @@ class Contest(models.Model): related_name='view_contest_scoreboard', help_text=_('These users will be able to view the scoreboard.')) scoreboard_visibility = models.CharField(verbose_name=_('scoreboard visibility'), default=SCOREBOARD_VISIBLE, - max_length=1, help_text=_('Scoreboard visibility through the duration ' - 'of the contest'), choices=SCOREBOARD_VISIBILITY) + help_text=_('Scoreboard visibility through the duration of the contest'), + max_length=1, choices=SCOREBOARD_VISIBILITY) scoreboard_cache_timeout = models.PositiveIntegerField(verbose_name=('scoreboard cache timeout'), default=0, help_text=_('How long should the scoreboard be cached. ' 'Set to 0 to disable caching.')) @@ -114,10 +118,10 @@ class Contest(models.Model): push_announcements = models.BooleanField(verbose_name=_('push announcements'), help_text=_('Notify users when there are new announcements.'), default=False) - rating_floor = models.IntegerField(verbose_name=('rating floor'), help_text=_('Rating floor for contest'), - null=True, blank=True) - rating_ceiling = models.IntegerField(verbose_name=('rating ceiling'), help_text=_('Rating ceiling for contest'), - null=True, blank=True) + rating_floor = models.IntegerField(verbose_name=_('rating floor'), null=True, blank=True, + help_text=_('Do not rate users who have a lower rating.')) + rating_ceiling = models.IntegerField(verbose_name=_('rating ceiling'), null=True, blank=True, + help_text=_('Do not rate users who have a higher rating.')) rate_all = models.BooleanField(verbose_name=_('rate all'), help_text=_('Rate all users who joined.'), default=False) rate_exclude = models.ManyToManyField(Profile, verbose_name=_('exclude from ratings'), blank=True, related_name='rate_exclude+') @@ -144,7 +148,7 @@ class Contest(models.Model): organizations = models.ManyToManyField(Organization, blank=True, verbose_name=_('organizations'), help_text=_('If private, only these organizations may see the contest')) og_image = models.CharField(verbose_name=_('OpenGraph image'), default='', max_length=150, blank=True) - logo_override_image = models.CharField(verbose_name=_('Logo override image'), default='', max_length=150, + logo_override_image = models.CharField(verbose_name=_('logo override image'), default='', max_length=150, blank=True, help_text=_('This image will replace the default site logo for users ' 'inside the contest.')) @@ -183,6 +187,11 @@ class Contest(models.Model): help_text=_('Disallow virtual joining after contest has ended.'), default=False) + ranking_access_code = models.CharField(verbose_name=_('ranking access code'), + help_text=_('An optional code to view the contest ranking. ' + 'Leave it blank to disable.'), + blank=True, default='', max_length=255) + @cached_property def format_class(self): return contest_format.formats[self.format_name] @@ -205,6 +214,16 @@ def clean(self): # Django will complain if you didn't fill in start_time or end_time, so we don't have to. if self.start_time and self.end_time and self.start_time >= self.end_time: raise ValidationError('What is this? A contest that ended before it starts?') + + if self.registration_start and self.registration_end and self.registration_start >= self.registration_end: + raise ValidationError('Registration window must start before it ends.') + + if self.registration_start and self.start_time and self.registration_start >= self.start_time: + raise ValidationError('Registration window must start before the contest starts.') + + if self.registration_end and self.end_time and self.registration_end >= self.end_time: + raise ValidationError('Registration window must end before the contest ends.') + self.format_class.validate(self.format_config) try: @@ -220,7 +239,8 @@ def clean(self): def is_in_contest(self, user): if user.is_authenticated: profile = user.profile - return profile and profile.current_contest is not None and profile.current_contest.contest == self + return profile and profile.current_contest is not None and profile.current_contest.contest == self \ + and profile.current_contest.contest.can_join return False def can_see_own_scoreboard(self, user): @@ -301,6 +321,20 @@ def _now(self): # This ensures that all methods talk about the same now. return timezone.now() + @cached_property + def require_registration(self): + return self.registration_start is not None or self.registration_end is not None + + @cached_property + def can_register(self): + if not self.require_registration: + return False + if self.registration_start and self._now < self.registration_start: + return False + if self.registration_end and self._now > self.registration_end: + return False + return True + @cached_property def can_join(self): return self.start_time <= self._now @@ -310,6 +344,13 @@ def frozen_time(self): # Don't need to check self.frozen_last_minutes != 0 return self.end_time - timedelta(minutes=self.frozen_last_minutes) + @property + def time_before_register(self): + if self.registration_start and self._now <= self.registration_start: + return self.registration_start - self._now + else: + return None + @property def time_before_start(self): if self.start_time >= self._now: @@ -358,7 +399,7 @@ def get_absolute_url(self): def update_user_count(self): self.user_count = self.users.filter(virtual=0).count() - self.virtual_count = self.users.filter(virtual__gt=ContestParticipation.SPECTATE).count() + self.virtual_count = self.users.filter(virtual__gt=0).count() self.save() update_user_count.alters_data = True @@ -569,6 +610,10 @@ def live(self): def spectate(self): return self.virtual == self.SPECTATE + @cached_property + def pre_registered(self): + return self.real_start.astimezone(timezone.utc).date() == date(1970, 1, 1) + @cached_property def start(self): contest = self.contest @@ -584,6 +629,8 @@ def end_time(self): return self.real_start + contest.time_limit else: return self.real_start + (contest.end_time - contest.start_time) + if self.pre_registered: + return contest.end_time return contest.end_time if contest.time_limit is None else \ min(self.real_start + contest.time_limit, contest.end_time) diff --git a/judge/models/interface.py b/judge/models/interface.py index 47bf40286..1e5e0db51 100644 --- a/judge/models/interface.py +++ b/judge/models/interface.py @@ -16,8 +16,8 @@ class MiscConfig(models.Model): - key = models.CharField(max_length=30, db_index=True) - value = models.TextField(blank=True) + key = models.CharField(max_length=30, verbose_name=_('key'), db_index=True) + value = models.TextField(verbose_name=_('value'), blank=True) def __str__(self): return self.key @@ -73,7 +73,7 @@ class BlogPost(models.Model): publish_on = models.DateTimeField(verbose_name=_('publish after')) content = models.TextField(verbose_name=_('post content')) summary = models.TextField(verbose_name=_('post summary'), blank=True) - og_image = models.CharField(verbose_name=_('openGraph image'), default='', max_length=150, blank=True) + og_image = models.CharField(verbose_name=_('OpenGraph image'), default='', max_length=150, blank=True) score = models.IntegerField(verbose_name=_('votes'), default=0) global_post = models.BooleanField(verbose_name=_('global post'), default=False, help_text=_('Display this blog post at the homepage.')) diff --git a/judge/models/problem.py b/judge/models/problem.py index 414863311..7f8b3a210 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -8,8 +8,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models, transaction -from django.db.models import CASCADE, F, Q, QuerySet, SET_NULL -from django.db.models.expressions import RawSQL +from django.db.models import CASCADE, Exists, F, FilteredRelation, OuterRef, Q, SET_NULL from django.db.models.functions import Coalesce from django.urls import reverse from django.utils import timezone @@ -21,10 +20,9 @@ from judge.models.profile import Organization, Profile from judge.models.runtime import Language from judge.user_translations import gettext as user_gettext -from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join __all__ = ['ProblemGroup', 'ProblemType', 'Problem', 'ProblemTranslation', 'ProblemClarification', 'License', - 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet', 'TranslatedProblemForeignKeyQuerySet'] + 'Solution', 'SubmissionSourceAccess', 'TranslatedProblemQuerySet'] def disallowed_characters_validator(text): @@ -66,8 +64,8 @@ class License(models.Model): link = models.CharField(max_length=256, verbose_name=_('link')) name = models.CharField(max_length=256, verbose_name=_('full name')) display = models.CharField(max_length=256, blank=True, verbose_name=_('short name'), - help_text=_('Displayed on pages under this license')) - icon = models.CharField(max_length=256, blank=True, verbose_name=_('icon'), help_text=_('URL to the icon')) + help_text=_('Displayed on pages under this license.')) + icon = models.CharField(max_length=256, blank=True, verbose_name=_('icon'), help_text=_('URL to the icon.')) text = models.TextField(verbose_name=_('license text')) def __str__(self): @@ -86,22 +84,17 @@ def __init__(self, **kwargs): super(TranslatedProblemQuerySet, self).__init__(('code', 'name', 'description'), **kwargs) def add_i18n_name(self, language): - queryset = self._clone() - alias = unique_together_left_join(queryset, ProblemTranslation, 'problem', 'language', language) - return queryset.annotate(i18n_name=Coalesce(RawSQL('%s.name' % alias, ()), F('name'), - output_field=models.CharField())) - - -class TranslatedProblemForeignKeyQuerySet(QuerySet): - def add_problem_i18n_name(self, key, language, name_field=None): - queryset = self._clone() if name_field is None else self.annotate(_name=F(name_field)) - alias = unique_together_left_join(queryset, ProblemTranslation, 'problem', 'language', language, - parent_model=Problem) - # You must specify name_field if Problem is not yet joined into the QuerySet. - kwargs = {key: Coalesce(RawSQL('%s.name' % alias, ()), - F(name_field) if name_field else RawSQLColumn(Problem, 'name'), - output_field=models.CharField())} - return queryset.annotate(**kwargs) + return self.annotate(i18n_translation=FilteredRelation( + 'translations', condition=Q(translations__language=language), + )).annotate(i18n_name=Coalesce(F('i18n_translation__name'), F('name'), output_field=models.CharField())) + + def add_i18n_description(self, language): + return self.annotate(i18n_translation=FilteredRelation( + 'translations', condition=Q(translations__language=language), + )).annotate(i18n_description=Coalesce( + F('i18n_translation__description'), F('description'), + output_field=models.TextField()), + ) class SubmissionSourceAccess: @@ -145,11 +138,10 @@ class Problem(models.Model): code = models.CharField(max_length=32, verbose_name=_('problem code'), unique=True, validators=[RegexValidator('^[a-z0-9_]+$', _('Problem code must be ^[a-z0-9_]+$'))], - help_text=_('A short, unique code for the problem, ' - 'used in the url after /problem/')) + help_text=_('A short, unique code for the problem, used in the url after /problem/')) name = models.CharField(max_length=100, verbose_name=_('problem name'), db_index=True, - help_text=_('The full name of the problem, ' - 'as shown in the problem list.')) + help_text=_('The full name of the problem, as shown in the problem list.'), + validators=[disallowed_characters_validator]) pdf_url = models.CharField(max_length=200, verbose_name=_('PDF statement URL'), blank=True, help_text=_('URL to PDF statement. The PDF file must be embeddable (Mobile web browsers' 'may not support embedding). Fallback included.')) @@ -168,8 +160,7 @@ class Problem(models.Model): help_text=_( 'These users will be able to view the private problem, but not edit it.')) types = models.ManyToManyField(ProblemType, verbose_name=_('problem types'), - help_text=_('The type of problem, ' - "as shown on the problem's page.")) + help_text=_("The type of problem, as shown on the problem's page.")) group = models.ForeignKey(ProblemGroup, verbose_name=_('problem group'), on_delete=CASCADE, help_text=_('The group of problem, shown under Category in the problem list.')) time_limit = models.FloatField(verbose_name=_('time limit'), @@ -194,7 +185,8 @@ class Problem(models.Model): 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.')) date = models.DateTimeField(verbose_name=_('date of publishing'), null=True, blank=True, db_index=True, - help_text=_("Doesn't have magic ability to auto-publish due to backward compatibility")) + help_text=_( + "Doesn't have the magic ability to auto-publish due to backward compatibility.")) banned_users = models.ManyToManyField(Profile, verbose_name=_('personae non gratae'), blank=True, help_text=_('Bans the selected users from submitting to this problem.')) license = models.ForeignKey(License, null=True, blank=True, on_delete=SET_NULL, @@ -275,10 +267,14 @@ def is_accessible_by(self, user, skip_contest_problem_check=False): # If we don't want to check if the user is in a contest containing that problem. if not skip_contest_problem_check and user.is_authenticated: # If user is currently in a contest containing that problem. - current = user.profile.current_contest_id + current = user.profile.current_contest if current is not None: + # If contest has not started (for joining contest in advance). + if not current.contest.can_join: + return False + from judge.models import ContestProblem - if ContestProblem.objects.filter(problem_id=self.id, contest__users__id=current).exists(): + if ContestProblem.objects.filter(problem_id=self.id, contest__users__id=current.id).exists(): return True # Problem is public. @@ -367,23 +363,36 @@ def get_visible_problems(cls, user): q = Q(is_public=True) if not (user.has_perm('judge.see_organization_problem') or edit_public_problem): # Either not organization private or in the organization. - q &= ( - Q(is_organization_private=False) | - Q(is_organization_private=True, organizations__in=user.profile.organizations.all()) + q &= Q(is_organization_private=False) | cls.organization_filter_q( + # Avoids needlessly joining Organization + Profile.organizations.through.objects.filter(profile=user.profile).values('organization_id'), ) # Suggesters should be able to view suggesting problems if edit_suggesting_problem: q |= Q(suggester__isnull=False, is_public=False) - # Authors, curators, and testers should always have access, so OR at the very end. - q |= Q(authors=user.profile) - q |= Q(curators=user.profile) - q |= Q(testers=user.profile) + # Authors, curators, and testers should always have access. + q = cls.q_add_author_curator_tester(q, user.profile) queryset = queryset.filter(q) return queryset + @classmethod + def q_add_author_curator_tester(cls, q, profile): + # This is way faster than the obvious |= Q(authors=profile) et al. because we are not doing + # joins and forcing the user to clean it up with .distinct(). + q |= Exists(Problem.authors.through.objects.filter(problem=OuterRef('pk'), profile=profile)) + q |= Exists(Problem.curators.through.objects.filter(problem=OuterRef('pk'), profile=profile)) + q |= Exists(Problem.testers.through.objects.filter(problem=OuterRef('pk'), profile=profile)) + return q + + @classmethod + def organization_filter_q(cls, queryset): + q = Q(is_organization_private=True) + q &= Exists(Problem.organizations.through.objects.filter(problem=OuterRef('pk'), organization__in=queryset)) + return q + @classmethod def get_public_problems(cls): return cls.objects.filter(is_public=True, is_organization_private=False).defer('description') @@ -650,8 +659,8 @@ class Meta: class Solution(models.Model): - problem = models.OneToOneField(Problem, on_delete=SET_NULL, verbose_name=_('associated problem'), - null=True, blank=True, related_name='solution') + problem = models.OneToOneField(Problem, on_delete=CASCADE, verbose_name=_('associated problem'), + blank=True, related_name='solution') is_public = models.BooleanField(verbose_name=_('public visibility'), default=False) publish_on = models.DateTimeField(verbose_name=_('publish date')) authors = models.ManyToManyField(Profile, verbose_name=_('authors'), blank=True) diff --git a/judge/models/problem_data.py b/judge/models/problem_data.py index 7805f0511..e325e5d8c 100644 --- a/judge/models/problem_data.py +++ b/judge/models/problem_data.py @@ -35,7 +35,6 @@ def problem_directory_file(data, filename): ('interactive', _('Interactive')), ('signature', _('Function Signature Grading (IOI-style)')), ('output_only', _('Output Only')), - ('custom_judge', _('Custom Grader')), ) IO_METHODS = ( @@ -49,6 +48,7 @@ def problem_directory_file(data, filename): ('cms', _('CMS checker')), ('coci', _('COCI checker')), ('peg', _('PEG checker')), + ('default', _('DMOJ checker')), ) @@ -64,20 +64,24 @@ class ProblemData(models.Model): feedback = models.TextField(verbose_name=_('init.yml generation feedback'), blank=True) checker = models.CharField(max_length=10, verbose_name=_('checker'), choices=CHECKERS, default='standard') grader = models.CharField(max_length=30, verbose_name=_('Grader'), choices=GRADERS, default='standard') + unicode = models.BooleanField(verbose_name=_('enable unicode'), null=True, blank=True) + nobigmath = models.BooleanField(verbose_name=_('disable bigInteger / bigDecimal'), null=True, blank=True) checker_args = models.TextField(verbose_name=_('checker arguments'), blank=True, - help_text=_('checker arguments as a JSON object')) + help_text=_('Checker arguments as a JSON object.')) custom_checker = models.FileField(verbose_name=_('custom checker file'), storage=problem_data_storage, null=True, blank=True, upload_to=problem_directory_file, - validators=[FileExtensionValidator(allowed_extensions=['cpp', 'py', 'pas'])]) + validators=[FileExtensionValidator( + allowed_extensions=['cpp', 'pas', 'java'], + )]) custom_grader = models.FileField(verbose_name=_('custom grader file'), storage=problem_data_storage, null=True, blank=True, upload_to=problem_directory_file, - validators=[FileExtensionValidator(allowed_extensions=['cpp', 'py'])]) + validators=[FileExtensionValidator(allowed_extensions=['cpp'])]) custom_header = models.FileField(verbose_name=_('custom header file'), storage=problem_data_storage, null=True, @@ -140,4 +144,4 @@ class ProblemTestCase(models.Model): output_limit = models.IntegerField(verbose_name=_('output limit length'), blank=True, null=True) checker = models.CharField(max_length=10, verbose_name=_('checker'), choices=CHECKERS, blank=True) checker_args = models.TextField(verbose_name=_('checker arguments'), blank=True, - help_text=_('checker arguments as a JSON object')) + help_text=_('Checker arguments as a JSON object.')) diff --git a/judge/models/profile.py b/judge/models/profile.py index 0561b5c82..30dee1b7f 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -21,7 +21,7 @@ from pyotp.utils import strings_equal from sortedm2m.fields import SortedManyToManyField -from judge.models.choices import ACE_THEMES, MATH_ENGINES_CHOICES, TIMEZONE +from judge.models.choices import ACE_THEMES, MATH_ENGINES_CHOICES, SITE_THEMES, TIMEZONE from judge.models.runtime import Language from judge.ratings import rating_class from judge.utils.float_compare import float_compare_equal @@ -40,23 +40,23 @@ def get_prep_value(self, value): class Organization(models.Model): name = models.CharField(max_length=128, verbose_name=_('organization title')) slug = models.SlugField(max_length=128, verbose_name=_('organization slug'), - help_text=_('Organization name shown in URL')) + help_text=_('Organization name shown in URLs.')) short_name = models.CharField(max_length=20, verbose_name=_('short name'), - help_text=_('Displayed beside user name during contests')) + help_text=_('Displayed beside user name during contests.')) about = models.TextField(verbose_name=_('organization description')) admins = models.ManyToManyField('Profile', verbose_name=_('administrators'), related_name='admin_of', - help_text=_('Those who can edit this organization')) + help_text=_('Those who can edit this organization.')) creation_date = models.DateTimeField(verbose_name=_('creation date'), auto_now_add=True) is_open = models.BooleanField(verbose_name=_('is open organization?'), - help_text=_('Allow joining organization'), default=False) + help_text=_('Allow joining organization.'), default=False) is_unlisted = models.BooleanField(verbose_name=_('is unlisted organization?'), help_text=_('Organization will not be listed'), default=True) slots = models.IntegerField(verbose_name=_('maximum size'), null=True, blank=True, help_text=_('Maximum amount of users in this organization, ' - 'only applicable to private organizations')) - access_code = models.CharField(max_length=7, help_text=_('Student access code'), + 'only applicable to private organizations.')) + access_code = models.CharField(max_length=7, help_text=_('Student access code.'), verbose_name=_('access code'), null=True, blank=True) - logo_override_image = models.CharField(verbose_name=_('Logo override image'), default='', max_length=150, + logo_override_image = models.CharField(verbose_name=_('logo override image'), default='', max_length=150, blank=True, help_text=_('This image will replace the default site logo for users ' 'viewing the organization.')) @@ -96,7 +96,7 @@ def __contains__(self, item): elif isinstance(item, Profile): return self.members.filter(id=item.id).exists() else: - raise TypeError('Organization membership test must be Profile or primany key') + raise TypeError('Organization membership test must be Profile or primary key.') def __str__(self): return self.name @@ -131,15 +131,16 @@ def __str__(self): class Profile(models.Model): user = models.OneToOneField(User, verbose_name=_('user associated'), on_delete=models.CASCADE) about = models.TextField(verbose_name=_('self-description'), null=True, blank=True) - timezone = models.CharField(max_length=50, verbose_name=_('location'), choices=TIMEZONE, + timezone = models.CharField(max_length=50, verbose_name=_('time zone'), choices=TIMEZONE, default=settings.DEFAULT_USER_TIME_ZONE) language = models.ForeignKey('Language', verbose_name=_('preferred language'), on_delete=models.SET_DEFAULT, default=Language.get_default_language_pk) - points = models.FloatField(default=0, db_index=True) - performance_points = models.FloatField(default=0, db_index=True) - contribution_points = models.IntegerField(default=0, db_index=True) - problem_count = models.IntegerField(default=0, db_index=True) - ace_theme = models.CharField(max_length=30, choices=ACE_THEMES, default='github') + points = models.FloatField(default=0) + performance_points = models.FloatField(default=0) + contribution_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='auto') last_access = models.DateTimeField(verbose_name=_('last access time'), default=now) ip = models.GenericIPAddressField(verbose_name=_('last IP'), blank=True, null=True) badges = models.ManyToManyField(Badge, verbose_name=_('badges'), blank=True, related_name='users') @@ -164,32 +165,32 @@ class Profile(models.Model): null=True, blank=True, related_name='+', on_delete=models.SET_NULL) math_engine = models.CharField(verbose_name=_('math engine'), choices=MATH_ENGINES_CHOICES, max_length=4, default=settings.MATHOID_DEFAULT_TYPE, - help_text=_('the rendering engine used to render math')) + help_text=_('The rendering engine used to render math.')) is_totp_enabled = models.BooleanField(verbose_name=_('TOTP 2FA enabled'), default=False, - help_text=_('check to enable TOTP-based two-factor authentication')) + help_text=_('Check to enable TOTP-based two-factor authentication.')) is_webauthn_enabled = models.BooleanField(verbose_name=_('WebAuthn 2FA enabled'), default=False, - help_text=_('check to enable WebAuthn-based two-factor authentication')) + help_text=_('Check to enable WebAuthn-based two-factor authentication.')) totp_key = EncryptedNullCharField(max_length=32, null=True, blank=True, verbose_name=_('TOTP key'), - help_text=_('32 character base32-encoded key for TOTP'), + help_text=_('32-character Base32-encoded key for TOTP.'), validators=[RegexValidator('^$|^[A-Z2-7]{32}$', - _('TOTP key must be empty or base32'))]) + _('TOTP key must be empty or Base32.'))]) scratch_codes = EncryptedNullCharField(max_length=255, null=True, blank=True, verbose_name=_('scratch codes'), - help_text=_('JSON array of 16 character base32-encoded codes ' - 'for scratch codes'), + help_text=_('JSON array of 16-character Base32-encoded codes ' + 'for scratch codes.'), validators=[ RegexValidator(r'^(\[\])?$|^\[("[A-Z0-9]{16}", *)*"[A-Z0-9]{16}"\]$', _('Scratch codes must be empty or a JSON array of ' - '16-character base32 codes'))]) + '16-character Base32 codes.'))]) last_totp_timecode = models.IntegerField(verbose_name=_('last TOTP timecode'), default=0) api_token = models.CharField(max_length=64, null=True, verbose_name=_('API token'), - help_text=_('64 character hex-encoded API access token'), + help_text=_('64-character hex-encoded API access token.'), validators=[RegexValidator('^[a-f0-9]{64}$', _('API token must be None or hexadecimal'))]) notes = models.TextField(verbose_name=_('internal notes'), null=True, blank=True, help_text=_('Notes for administrators regarding this user.')) data_last_downloaded = models.DateTimeField(verbose_name=_('last data download time'), null=True, blank=True) username_display_override = models.CharField(max_length=100, blank=True, verbose_name=_('display name override'), - help_text=_('Name displayed in place of username')) + help_text=_('Name displayed in place of username.')) @cached_property def organization(self): @@ -208,8 +209,12 @@ def display_name(self): return self.username_display_override or self.username @cached_property - def has_any_solves(self): - return self.submission_set.filter(points=F('problem__points')).exists() + def has_enough_solves(self): + return self.problem_count >= settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT + + @cached_property + def is_new_user(self): + return not self.user.is_staff and not self.has_enough_solves @cached_property def can_tag_problems(self): @@ -220,6 +225,21 @@ def can_tag_problems(self): return True return False + @cached_property + def resolved_ace_theme(self): + if self.ace_theme != 'auto': + return self.ace_theme + if not self.user.has_perm('judge.test_site'): + return settings.DMOJ_THEME_DEFAULT_ACE_THEME.get('light') + if self.site_theme != 'auto': + return settings.DMOJ_THEME_DEFAULT_ACE_THEME.get(self.site_theme) + # This must be resolved client-side using prefers-color-scheme. + return None + + @cached_property + def registered_contest_ids(self): + return set(self.contest_history.filter(virtual=0).values_list('contest_id', flat=True)) + _pp_table = [pow(settings.DMOJ_PP_STEP, i) for i in range(settings.DMOJ_PP_ENTRIES)] def calculate_points(self, table=_pp_table): @@ -230,13 +250,14 @@ def calculate_points(self, table=_pp_table): .annotate(max_points=Max('submission__points')).order_by('-max_points') .values_list('max_points', flat=True).filter(max_points__gt=0) ) - extradata = ( - public_problems.filter(submission__user=self, submission__result='AC').values('id').distinct().count() - ) bonus_function = settings.DMOJ_PP_BONUS_FUNCTION points = sum(data) - problems = len(data) - pp = sum(x * y for x, y in zip(table, data)) + bonus_function(extradata) + problems = ( + public_problems.filter(submission__user=self, submission__result='AC', + submission__case_points__gte=F('submission__case_total')) + .values('id').distinct().count() + ) + pp = sum(x * y for x, y in zip(table, data)) + bonus_function(problems) if not float_compare_equal(self.points, points) or \ problems != self.problem_count or \ not float_compare_equal(self.performance_points, pp): @@ -256,7 +277,7 @@ def calculate_contribution_points(self): # Because the aggregate function can return None # So we use `X or 0` to get 0 if X is None # Please note that `0 or X` will return None if X is None - total_comment_scores = Comment.objects.filter(author=self.id) \ + total_comment_scores = Comment.objects.filter(author=self.id, hidden=False) \ .aggregate(sum=Sum('score'))['sum'] or 0 total_blog_scores = BlogPost.objects.filter(authors=self.id, visible=True, organization=None) \ .aggregate(sum=Sum('score'))['sum'] or 0 @@ -370,6 +391,13 @@ class Meta: verbose_name = _('user profile') verbose_name_plural = _('user profiles') + indexes = [ + models.Index(fields=('is_unlisted', '-performance_points')), + models.Index(fields=('is_unlisted', '-contribution_points')), + models.Index(fields=('is_unlisted', '-rating')), + models.Index(fields=('is_unlisted', '-problem_count')), + ] + class WebAuthnCredential(models.Model): user = models.ForeignKey(Profile, verbose_name=_('user'), related_name='webauthn_credentials', diff --git a/judge/models/runtime.py b/judge/models/runtime.py index 2878bd533..26a94edfb 100644 --- a/judge/models/runtime.py +++ b/judge/models/runtime.py @@ -10,7 +10,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from judge.judgeapi import disconnect_judge +from judge.judgeapi import disconnect_judge, update_disable_judge __all__ = ['Language', 'RuntimeVersion', 'Judge'] @@ -28,7 +28,7 @@ class Language(models.Model): null=True, blank=True) common_name = models.CharField(max_length=20, verbose_name=_('common name'), help_text=_('Common name for the language. For example, the common name for C++03, ' - 'C++11, and C++14 would be "C++"')) + 'C++11, and C++14 would be "C++".')) ace = models.CharField(max_length=20, verbose_name=_('ace mode name'), help_text=_('Language ID for Ace.js editor highlighting, appended to "mode-" to determine ' 'the Ace JavaScript file to use, e.g., "python".')) @@ -130,13 +130,16 @@ class RuntimeVersion(models.Model): class Judge(models.Model): - name = models.CharField(max_length=50, help_text=_('Server name, hostname-style'), unique=True) + name = models.CharField(max_length=50, verbose_name=_('judge name'), help_text=_('Server name, hostname-style.'), + unique=True) created = models.DateTimeField(auto_now_add=True, verbose_name=_('time of creation')) - auth_key = models.CharField(max_length=100, help_text=_('A key to authenticate this judge'), + auth_key = models.CharField(max_length=100, help_text=_('A key to authenticate this judge.'), verbose_name=_('authentication key')) is_blocked = models.BooleanField(verbose_name=_('block judge'), default=False, help_text=_('Whether this judge should be blocked from connecting, ' 'even if its key is correct.')) + is_disabled = models.BooleanField(verbose_name=_('disable judge'), default=False, + help_text=_('Whether this judge should be removed from judging queue.')) online = models.BooleanField(verbose_name=_('judge online status'), default=False) start_time = models.DateTimeField(verbose_name=_('judge start time'), null=True) ping = models.FloatField(verbose_name=_('response time'), null=True) @@ -155,6 +158,13 @@ def disconnect(self, force=False): disconnect.alters_data = True + def toggle_disabled(self): + self.is_disabled = not self.is_disabled + update_disable_judge(self) + self.save(update_fields=['is_disabled']) + + toggle_disabled.alters_data = True + @classmethod def runtime_versions(cls): qs = (RuntimeVersion.objects.filter(judge__online=True) diff --git a/judge/models/submission.py b/judge/models/submission.py index 412cbac26..957e405c4 100644 --- a/judge/models/submission.py +++ b/judge/models/submission.py @@ -11,7 +11,7 @@ from reversion import revisions from judge.judgeapi import abort_submission, judge_submission -from judge.models.problem import Problem, SubmissionSourceAccess, TranslatedProblemForeignKeyQuerySet +from judge.models.problem import Problem, SubmissionSourceAccess from judge.models.profile import Profile from judge.models.runtime import Language from judge.utils.unicode import utf8bytes @@ -28,7 +28,7 @@ ('RTE', _('Runtime Error')), ('CE', _('Compile Error')), ('IE', _('Internal Error')), - ('SC', _('Short circuit')), + ('SC', _('Short Circuited')), ('AB', _('Aborted')), ) @@ -70,16 +70,17 @@ class Submission(models.Model): 'AB': _('Aborted'), } - user = models.ForeignKey(Profile, on_delete=models.CASCADE) - problem = models.ForeignKey(Problem, on_delete=models.CASCADE) + user = models.ForeignKey(Profile, on_delete=models.CASCADE, db_index=False) + problem = models.ForeignKey(Problem, on_delete=models.CASCADE, db_index=False) date = models.DateTimeField(verbose_name=_('submission time'), auto_now_add=True, db_index=True) - time = models.FloatField(verbose_name=_('execution time'), null=True, db_index=True) + time = models.FloatField(verbose_name=_('execution time'), null=True) memory = models.FloatField(verbose_name=_('memory usage'), null=True) - points = models.FloatField(verbose_name=_('points granted'), null=True, db_index=True) - language = models.ForeignKey(Language, verbose_name=_('submission language'), on_delete=models.CASCADE) + 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) result = models.CharField(verbose_name=_('result'), max_length=3, choices=SUBMISSION_RESULT, - default=None, null=True, blank=True, db_index=True) + default=None, null=True, blank=True) error = models.TextField(verbose_name=_('compile errors'), null=True, blank=True) current_testcase = models.IntegerField(default=0) batch = models.BooleanField(verbose_name=_('batched cases'), default=False) @@ -91,11 +92,9 @@ class Submission(models.Model): rejudged_date = models.DateTimeField(verbose_name=_('last rejudge date by admin'), null=True, blank=True) is_pretested = models.BooleanField(verbose_name=_('was ran on pretests only'), default=False) contest_object = models.ForeignKey('Contest', verbose_name=_('contest'), null=True, blank=True, - on_delete=models.SET_NULL, related_name='+') + on_delete=models.SET_NULL, related_name='+', db_index=False) locked_after = models.DateTimeField(verbose_name=_('submission lock'), null=True, blank=True) - objects = TranslatedProblemForeignKeyQuerySet.as_manager() - @classmethod def result_class_from_code(cls, result, case_points, case_total): if result == 'AC': @@ -106,7 +105,7 @@ def result_class_from_code(cls, result, case_points, case_total): @property def result_class(self): - # This exists to save all these conditionals from being executed (slowly) in each row.jade template + # This exists to save all these conditionals from being executed (slowly) in each row.html template if self.status in ('IE', 'CE'): return self.status return Submission.result_class_from_code(self.result, self.case_points, self.case_total) @@ -239,6 +238,32 @@ class Meta: verbose_name = _('submission') verbose_name_plural = _('submissions') + indexes = [ + # For problem submission rankings + models.Index(fields=['problem', 'user', '-points', '-time']), + + # For contest problem submission rankings + models.Index(fields=['contest_object', 'problem', 'user', '-points', '-time']), + + # For main submission list filtering by some combination of result and language + models.Index(fields=['result', '-id']), + models.Index(fields=['result', 'language', '-id']), + models.Index(fields=['language', '-id']), + + # For filtered main submission list result charts + models.Index(fields=['result', 'problem']), + models.Index(fields=['language', 'problem', 'result']), + + # For problem submissions result chart + models.Index(fields=['problem', 'result']), + + # For user_attempted_ids and own problem submissions result chart + models.Index(fields=['user', 'problem', 'result']), + + # For user_completed_ids + models.Index(fields=['user', 'result']), + ] + class SubmissionSource(models.Model): submission = models.OneToOneField(Submission, on_delete=models.CASCADE, verbose_name=_('associated submission'), @@ -253,7 +278,7 @@ def __str__(self): class SubmissionTestCase(models.Model): RESULT = SUBMISSION_RESULT - submission = models.ForeignKey(Submission, verbose_name=_('associated submission'), + submission = models.ForeignKey(Submission, verbose_name=_('associated submission'), db_index=False, related_name='test_cases', on_delete=models.CASCADE) case = models.IntegerField(verbose_name=_('test case ID')) status = models.CharField(max_length=3, verbose_name=_('status flag'), choices=SUBMISSION_RESULT) @@ -270,6 +295,12 @@ class SubmissionTestCase(models.Model): def long_status(self): return Submission.USER_DISPLAY_CODES.get(self.status, '') + @property + def result_class(self): + if self.status in ('IE', 'CE'): + return self.status + return Submission.result_class_from_code(self.status, self.points, self.total) + class Meta: unique_together = ('submission', 'case') verbose_name = _('submission test case') diff --git a/judge/models/ticket.py b/judge/models/ticket.py index 2a377d369..73a1bee32 100644 --- a/judge/models/ticket.py +++ b/judge/models/ticket.py @@ -36,7 +36,7 @@ class Ticket(models.Model): class TicketMessage(models.Model): ticket = models.ForeignKey(Ticket, verbose_name=_('ticket'), related_name='messages', related_query_name='message', on_delete=models.CASCADE) - user = models.ForeignKey(Profile, verbose_name=_('poster'), related_name='ticket_messages', + user = models.ForeignKey(Profile, verbose_name=_('user'), related_name='ticket_messages', on_delete=models.CASCADE) body = models.TextField(verbose_name=_('message body')) time = models.DateTimeField(verbose_name=_('message time'), auto_now_add=True) diff --git a/judge/pdf_problems.py b/judge/pdf_problems.py deleted file mode 100644 index ead8232a7..000000000 --- a/judge/pdf_problems.py +++ /dev/null @@ -1,337 +0,0 @@ -import base64 -import errno -import io -import json -import logging -import os -import shutil -import subprocess -import uuid - -from django.conf import settings -from django.utils.translation import gettext - -logger = logging.getLogger('judge.problem.pdf') - -HAS_SELENIUM = False -if settings.USE_SELENIUM: - try: - from selenium import webdriver - from selenium.common.exceptions import TimeoutException - from selenium.webdriver.common.by import By - from selenium.webdriver.support import expected_conditions as EC - from selenium.webdriver.support.ui import WebDriverWait - HAS_SELENIUM = True - except ImportError: - logger.warning('Failed to import Selenium', exc_info=True) - -HAS_PHANTOMJS = os.access(settings.PHANTOMJS, os.X_OK) -HAS_SLIMERJS = os.access(settings.SLIMERJS, os.X_OK) - -NODE_PATH = settings.NODEJS -PUPPETEER_MODULE = settings.PUPPETEER_MODULE -HAS_PUPPETEER = os.access(NODE_PATH, os.X_OK) and os.path.isdir(PUPPETEER_MODULE) - -HAS_PDF = (os.path.isdir(settings.DMOJ_PDF_PROBLEM_CACHE) and - (HAS_PHANTOMJS or HAS_SLIMERJS or HAS_PUPPETEER or HAS_SELENIUM)) - -EXIFTOOL = settings.EXIFTOOL -HAS_EXIFTOOL = os.access(EXIFTOOL, os.X_OK) - - -class BasePdfMaker(object): - math_engine = 'jax' - title = None - - def __init__(self, dir=None, clean_up=True, footer=True): - self.dir = dir or os.path.join(settings.DMOJ_PDF_PROBLEM_TEMP_DIR, str(uuid.uuid1())) - self.proc = None - self.log = None - self.htmlfile = os.path.join(self.dir, 'input.html') - self.pdffile = os.path.join(self.dir, 'output.pdf') - self.clean_up = clean_up - self.footer = footer - - def load(self, file, source): - with open(os.path.join(self.dir, file), 'w') as target, open(source) as source: - target.write(source.read()) - - def make(self, debug=False): - self._make(debug) - - if self.title and HAS_EXIFTOOL: - try: - subprocess.check_output([EXIFTOOL, '-Title=%s' % (self.title,), self.pdffile]) - except subprocess.CalledProcessError as e: - logger.error('Failed to run exiftool to set title for: %s\n%s', self.title, e.output) - - def _make(self, debug): - raise NotImplementedError() - - @property - def html(self): - with io.open(self.htmlfile, encoding='utf-8') as f: - return f.read() - - @html.setter - def html(self, data): - with io.open(self.htmlfile, 'w', encoding='utf-8') as f: - f.write(data) - - @property - def success(self): - return self.proc.returncode == 0 - - @property - def created(self): - return os.path.exists(self.pdffile) - - def __enter__(self): - try: - os.makedirs(self.dir) - except OSError as e: - if e.errno != errno.EEXIST: - raise - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.clean_up: - shutil.rmtree(self.dir, ignore_errors=True) - - -class PhantomJSPdfMaker(BasePdfMaker): - template = """\ -"use strict"; -var page = require('webpage').create(); -var param = {params}; - -page.paperSize = { - format: param.paper, orientation: 'portrait', margin: '1cm', - footer: param.footer ? { - height: '1cm', - contents: phantom.callback(function(num, pages) { - return ('
' - + param.footer.replace('[page]', num).replace('[topage]', pages) + '
'); - }) - } : {} -}; - -page.onCallback = function (data) { - if (data.action === 'snapshot') { - page.render(param.output); - phantom.exit(); - } -} - -page.open(param.input, function (status) { - if (status !== 'success') { - console.log('Unable to load the address!'); - phantom.exit(1); - } else { - page.evaluate(function (zoom) { - document.documentElement.style.zoom = zoom; - }, param.zoom); - window.setTimeout(function () { - page.render(param.output); - phantom.exit(); - }, param.timeout); - } -}); -""" - - def get_render_script(self): - return self.template.replace('{params}', json.dumps({ - 'zoom': settings.PHANTOMJS_PDF_ZOOM, - 'timeout': int(settings.PHANTOMJS_PDF_TIMEOUT * 1000), - 'input': 'input.html', 'output': 'output.pdf', - 'paper': settings.PHANTOMJS_PAPER_SIZE, - 'footer': gettext('Page [page] of [topage]') if self.footer else '', - })) - - def _make(self, debug): - with io.open(os.path.join(self.dir, '_render.js'), 'w', encoding='utf-8') as f: - f.write(self.get_render_script()) - cmdline = [settings.PHANTOMJS, '_render.js'] - env = {'OPENSSL_CONF': '/etc/ssl'} - self.proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.dir, env=env) - self.log = self.proc.communicate()[0] - - -class SlimerJSPdfMaker(BasePdfMaker): - math_engine = 'mml' - - template = """\ -"use strict"; -try { - var param = {params}; - - var {Cc, Ci} = require('chrome'); - var prefs = Cc['@mozilla.org/preferences-service;1'].getService(Ci.nsIPrefService); - // Changing the serif font so that printed footers show up as Segoe UI. - var branch = prefs.getBranch('font.name.serif.'); - branch.setCharPref('x-western', 'Segoe UI'); - - var page = require('webpage').create(); - - page.paperSize = { - format: param.paper, orientation: 'portrait', margin: '1cm', edge: '0.5cm', - }; - if (param.footer) - page.paperSize.footerStr = { left: '', right: '', center: param.footer }; - - page.open(param.input, function (status) { - if (status !== 'success') { - console.log('Unable to load the address!'); - slimer.exit(1); - } else { - page.render(param.output, { ratio: param.zoom }); - slimer.exit(); - } - }); -} catch (e) { - console.error(e); - slimer.exit(1); -} -""" - - def get_render_script(self): - if self.footer: - footer = gettext('Page [page] of [topage]').replace('[page]', '&P').replace('[topage]', '&L') - else: - footer = '' - return self.template.replace('{params}', json.dumps({ - 'zoom': settings.SLIMERJS_PDF_ZOOM, - 'input': 'input.html', 'output': 'output.pdf', - 'paper': settings.SLIMERJS_PAPER_SIZE, - 'footer': footer, - })) - - def _make(self, debug): - with io.open(os.path.join(self.dir, '_render.js'), 'w', encoding='utf-8') as f: - f.write(self.get_render_script()) - - env = None - firefox = settings.SLIMERJS_FIREFOX_PATH - if firefox: - env = os.environ.copy() - env['SLIMERJSLAUNCHER'] = firefox - - cmdline = [settings.SLIMERJS, '--headless', '_render.js'] - self.proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.dir, env=env) - self.log = self.proc.communicate()[0] - - -class PuppeteerPDFRender(BasePdfMaker): - template = """\ -"use strict"; -const param = {params}; -const puppeteer = require('puppeteer'); - -puppeteer.launch().then(browser => Promise.resolve() - .then(async () => { - const page = await browser.newPage(); - await page.goto(param.input, { waitUntil: 'networkidle0' }); - await page.waitForSelector('.math-loaded', { timeout: 15000 }); - await page.pdf({ - path: param.output, - format: param.paper, - margin: { - top: '1cm', - bottom: '1cm', - left: '1cm', - right: '1cm', - }, - printBackground: true, - displayHeaderFooter: true, - headerTemplate: '
', - footerTemplate: param.footer ? '
' + - param.footer.replace('[page]', '') - .replace('[topage]', '') - + '
' : '
', - }); - await browser.close(); - }) - .catch(e => browser.close().then(() => {throw e})) -).catch(e => { - console.error(e); - process.exit(1); -}); -""" - - def get_render_script(self): - return self.template.replace('{params}', json.dumps({ - 'input': 'file://%s' % self.htmlfile, - 'output': self.pdffile, - 'paper': settings.PUPPETEER_PAPER_SIZE, - 'footer': gettext('Page [page] of [topage]') if self.footer else '', - })) - - def _make(self, debug): - with io.open(os.path.join(self.dir, '_render.js'), 'w', encoding='utf-8') as f: - f.write(self.get_render_script()) - - env = os.environ.copy() - env['NODE_PATH'] = os.path.dirname(PUPPETEER_MODULE) - - cmdline = [NODE_PATH, '_render.js'] - self.proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.dir, env=env) - self.log = self.proc.communicate()[0] - - -class SeleniumPDFRender(BasePdfMaker): - success = False - template = { - 'printBackground': True, - 'displayHeaderFooter': True, - 'headerTemplate': '
', - 'footerTemplate': '
' + - gettext('Page %(current)s of %(total)s') % ({ - 'current': '', - 'total': '', - }) + '
', - } - - def get_log(self, driver): - return '\n'.join(map(str, driver.get_log('driver') + driver.get_log('browser'))) - - def _make(self, debug): - options = webdriver.ChromeOptions() - options.add_argument('--headless') - options.binary_location = settings.SELENIUM_CUSTOM_CHROME_PATH - - browser = webdriver.Chrome(settings.SELENIUM_CHROMEDRIVER_PATH, options=options) - browser.get('file://%s' % self.htmlfile) - self.log = self.get_log(browser) - - try: - WebDriverWait(browser, 15).until(EC.presence_of_element_located((By.CLASS_NAME, 'math-loaded'))) - except TimeoutException: - logger.error('PDF math rendering timed out') - self.log = self.get_log(browser) + '\nPDF math rendering timed out' - return - - template = self.template - if not self.footer: - template = template.copy() - template['footerTemplate'] = '
' - response = browser.execute_cdp_cmd('Page.printToPDF', template) - self.log = self.get_log(browser) - if not response: - return - - with open(self.pdffile, 'wb') as f: - f.write(base64.b64decode(response['data'])) - - self.success = True - - -if HAS_PUPPETEER: - DefaultPdfMaker = PuppeteerPDFRender -elif HAS_SELENIUM: - DefaultPdfMaker = SeleniumPDFRender -elif HAS_SLIMERJS: - DefaultPdfMaker = SlimerJSPdfMaker -elif HAS_PHANTOMJS: - DefaultPdfMaker = PhantomJSPdfMaker -else: - DefaultPdfMaker = None diff --git a/judge/performance_points.py b/judge/performance_points.py index 250d89bcb..30de63e19 100644 --- a/judge/performance_points.py +++ b/judge/performance_points.py @@ -14,8 +14,10 @@ def get_pp_breakdown(user, start=0, end=settings.DMOJ_PP_ENTRIES): + join_type = 'STRAIGHT_JOIN' if connection.vendor == 'mysql' else 'INNER JOIN' + with connection.cursor() as cursor: - cursor.execute(""" + cursor.execute(f""" SELECT max_points_table.problem_code, max_points_table.problem_name, max_points_table.max_points, @@ -26,24 +28,26 @@ def get_pp_breakdown(user, start=0, end=settings.DMOJ_PP_ENTRIES): judge_submission.result, judge_language.short_name, judge_language.key - FROM judge_submission - JOIN (SELECT judge_problem.id problem_id, - judge_problem.name problem_name, - judge_problem.code problem_code, - MAX(judge_submission.points) AS max_points + FROM ( + SELECT judge_problem.id problem_id, + judge_problem.name problem_name, + judge_problem.code problem_code, + MAX(judge_submission.points) AS max_points FROM judge_problem INNER JOIN judge_submission ON (judge_problem.id = judge_submission.problem_id) - WHERE (judge_problem.is_public = True AND - judge_problem.is_organization_private = False AND + WHERE (judge_problem.is_public AND + NOT judge_problem.is_organization_private AND judge_submission.points IS NOT NULL AND judge_submission.user_id = %s) GROUP BY judge_problem.id - HAVING MAX(judge_submission.points) > 0.0) AS max_points_table - ON (judge_submission.problem_id = max_points_table.problem_id AND + HAVING MAX(judge_submission.points) > 0.0 + ) AS max_points_table + {join_type} judge_submission ON ( + judge_submission.problem_id = max_points_table.problem_id AND judge_submission.points = max_points_table.max_points AND - judge_submission.user_id = %s) - JOIN judge_language - ON judge_submission.language_id = judge_language.id + judge_submission.user_id = %s + ) + {join_type} judge_language ON (judge_submission.language_id = judge_language.id) GROUP BY max_points_table.problem_id ORDER BY max_points DESC, judge_submission.date DESC LIMIT %s OFFSET %s diff --git a/judge/ratings.py b/judge/ratings.py index 998f7e19a..2101e204f 100644 --- a/judge/ratings.py +++ b/judge/ratings.py @@ -151,8 +151,7 @@ def rate_contest(contest): times=Coalesce(Subquery(rating_subquery.order_by().values('user_id') .annotate(count=Count('id')).values('count')), 0)) \ .exclude(user_id__in=contest.rate_exclude.all()) \ - .filter(virtual=0, is_disqualified=False).values('id', 'user_id', 'score', 'cumtime', 'tiebreaker', - 'last_rating', 'last_mean', 'times') + .filter(virtual=0).values('id', 'user_id', 'score', 'cumtime', 'tiebreaker', 'last_mean', 'times') if not contest.rate_all: users = users.filter(submissions__gt=0) if contest.rating_floor is not None: diff --git a/judge/signals.py b/judge/signals.py index 1316429f4..87c2f8386 100644 --- a/judge/signals.py +++ b/judge/signals.py @@ -1,13 +1,13 @@ import errno import os +from typing import Optional from django.conf import settings from django.contrib.flatpages.models import FlatPage -from django.contrib.sites.models import Site from django.core.cache import cache from django.core.cache.utils import make_template_fragment_key from django.db import transaction -from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save +from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver from registration.models import RegistrationProfile from registration.signals import user_registered @@ -19,7 +19,10 @@ from judge.views.register import RegistrationView -def get_pdf_path(basename): +def get_pdf_path(basename: str) -> Optional[str]: + if not settings.DMOJ_PDF_PROBLEM_CACHE: + return None + return os.path.join(settings.DMOJ_PDF_PROBLEM_CACHE, basename) @@ -48,7 +51,9 @@ def problem_update(sender, instance, **kwargs): cache.delete_many(['generated-meta-problem:%s:%d' % (lang, instance.id) for lang, _ in settings.LANGUAGES]) for lang, _ in settings.LANGUAGES: - unlink_if_exists(get_pdf_path('%s.%s.pdf' % (instance.code, lang))) + cached_pdf_filename = get_pdf_path('%s.%s.pdf' % (instance.code, lang)) + if cached_pdf_filename is not None: + unlink_if_exists(cached_pdf_filename) @receiver(post_save, sender=Profile) @@ -143,35 +148,14 @@ def organization_admin_update(sender, instance, action, **kwargs): profile.organizations.add(instance) -_misc_config_i18n = [code for code, _ in settings.LANGUAGES] -_misc_config_i18n.append('') - - -def misc_config_cache_delete(key): - cache.delete_many(['misc_config:%s:%s:%s' % (domain, lang, key.split('.')[0]) - for lang in _misc_config_i18n - for domain in Site.objects.values_list('domain', flat=True)]) - - -@receiver(pre_save, sender=MiscConfig) -def misc_config_pre_save(sender, instance, **kwargs): - try: - old_key = MiscConfig.objects.filter(id=instance.id).values_list('key').get()[0] - except MiscConfig.DoesNotExist: - old_key = None - instance._old_key = old_key - - @receiver(post_save, sender=MiscConfig) def misc_config_update(sender, instance, **kwargs): - misc_config_cache_delete(instance.key) - if instance._old_key is not None and instance._old_key != instance.key: - misc_config_cache_delete(instance._old_key) + cache.delete('misc_config') @receiver(post_delete, sender=MiscConfig) def misc_config_delete(sender, instance, **kwargs): - misc_config_cache_delete(instance.key) + cache.delete('misc_config') @receiver(post_save, sender=ContestSubmission) diff --git a/judge/sitemap.py b/judge/sitemap.py index 39024ceda..5c3426ad2 100644 --- a/judge/sitemap.py +++ b/judge/sitemap.py @@ -7,7 +7,7 @@ class ProblemSitemap(Sitemap): - changefreq = 'daily' + changefreq = 'weekly' priority = 0.8 def items(self): @@ -18,7 +18,7 @@ def location(self, obj): class UserSitemap(Sitemap): - changefreq = 'hourly' + changefreq = 'weekly' priority = 0.5 def items(self): @@ -30,7 +30,7 @@ def location(self, obj): class ContestSitemap(Sitemap): changefreq = 'hourly' - priority = 0.5 + priority = 0.7 def items(self): return Contest.objects.filter(is_visible=True, is_private=False, @@ -41,7 +41,7 @@ def location(self, obj): class OrganizationSitemap(Sitemap): - changefreq = 'hourly' + changefreq = 'weekly' priority = 0.5 def items(self): @@ -63,12 +63,12 @@ def location(self, obj): class SolutionSitemap(Sitemap): - changefreq = 'hourly' + changefreq = 'weekly' priority = 0.8 def items(self): - return (Solution.objects.filter(is_public=True, publish_on__lte=timezone.now(), problem__isnull=False) - .values_list('problem__code')) + return (Solution.objects.filter(is_public=True, publish_on__lte=timezone.now(), + problem__in=Problem.get_public_problems()).values_list('problem__code')) def location(self, obj): return reverse('problem_editorial', args=obj) @@ -76,7 +76,7 @@ def location(self, obj): class HomePageSitemap(Sitemap): priority = 1.0 - changefreq = 'daily' + changefreq = 'hourly' def items(self): return ['home'] @@ -100,3 +100,17 @@ def priority(self, obj): def changefreq(self, obj): return obj.get('changefreq', 'daily') if isinstance(obj, dict) else 'daily' + + +sitemaps = { + 'home': HomePageSitemap, + 'pages': UrlSitemap([ + {'location': '/about/', 'priority': 0.9}, + ]), + 'problem': ProblemSitemap, + 'solutions': SolutionSitemap, + 'blog': BlogPostSitemap, + 'contest': ContestSitemap, + 'organization': OrganizationSitemap, + 'user': UserSitemap, +} diff --git a/judge/social_auth.py b/judge/social_auth.py index 5ae9290a2..f8fcc2453 100644 --- a/judge/social_auth.py +++ b/judge/social_auth.py @@ -54,7 +54,7 @@ def verify_email(backend, details, *args, **kwargs): 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'}) + error_messages={'invalid': 'A username must contain letters, numbers, or underscores.'}) def clean_username(self): if User.objects.filter(username=self.cleaned_data['username']).exists(): diff --git a/judge/template_context.py b/judge/template_context.py index 8203f62a5..e055cbdc7 100644 --- a/judge/template_context.py +++ b/judge/template_context.py @@ -3,12 +3,11 @@ from django.conf import settings from django.contrib.auth.context_processors import PermWrapper from django.contrib.sites.shortcuts import get_current_site -from django.core.cache import cache from django.utils.functional import SimpleLazyObject, new_method_proxy from judge import event_poster as event from judge.utils.caniuse import CanIUse, SUPPORT -from .models import MiscConfig, NavigationBar, Profile +from .models import NavigationBar, Profile class FixedSimpleLazyObject(SimpleLazyObject): @@ -25,13 +24,13 @@ def get_resource(request): else: scheme = 'http' return { - 'PYGMENT_THEME': settings.PYGMENT_THEME, 'INLINE_JQUERY': settings.INLINE_JQUERY, 'INLINE_FONTAWESOME': settings.INLINE_FONTAWESOME, 'JQUERY_JS': settings.JQUERY_JS, 'FONTAWESOME_CSS': settings.FONTAWESOME_CSS, 'DMOJ_SCHEME': scheme, 'DMOJ_CANONICAL': settings.DMOJ_CANONICAL, + 'DMOJ_SELECT2_THEME': settings.DMOJ_SELECT2_THEME, } @@ -73,37 +72,8 @@ def site(request): return {'site': get_current_site(request)} -class MiscConfigDict(dict): - __slots__ = ('language', 'site') - - def __init__(self, language='', domain=None): - self.language = language - self.site = domain - super(MiscConfigDict, self).__init__() - - def __missing__(self, key): - cache_key = 'misc_config:%s:%s:%s' % (self.site, self.language, key) - value = cache.get(cache_key) - if value is None: - keys = ['%s.%s' % (key, self.language), key] if self.language else [key] - if self.site is not None: - keys = ['%s:%s' % (self.site, key) for key in keys] + keys - map = dict(MiscConfig.objects.values_list('key', 'value').filter(key__in=keys)) - for item in keys: - if item in map: - value = map[item] - break - else: - value = '' - cache.set(cache_key, value, 86400) - self[key] = value - return value - - def misc_config(request): - domain = get_current_site(request).domain - return {'misc_config': MiscConfigDict(domain=domain), - 'i18n_config': MiscConfigDict(language=request.LANGUAGE_CODE, domain=domain)} + return {'misc_config': request.misc_config} def site_name(request): @@ -112,10 +82,24 @@ def site_name(request): 'SITE_ADMIN_EMAIL': settings.SITE_ADMIN_EMAIL} +def site_theme(request): + # Middleware populating `profile` may not have loaded at this point if we're called from an error context. + if hasattr(request.user, 'profile'): + preferred_css = settings.DMOJ_THEME_CSS.get(request.profile.site_theme) + else: + preferred_css = None + return { + 'DARK_STYLE_CSS': settings.DMOJ_THEME_CSS['dark'], + 'LIGHT_STYLE_CSS': settings.DMOJ_THEME_CSS['light'], + 'PREFERRED_STYLE_CSS': preferred_css, + } + + def math_setting(request): caniuse = CanIUse(request.META.get('HTTP_USER_AGENT', '')) - if request.user.is_authenticated: + # Middleware populating `profile` may not have loaded at this point if we're called from an error context. + if hasattr(request.user, 'profile'): engine = request.profile.math_engine else: engine = settings.MATHOID_DEFAULT_TYPE diff --git a/judge/utils/diggpaginator.py b/judge/utils/diggpaginator.py index af125daf5..ba71aa572 100644 --- a/judge/utils/diggpaginator.py +++ b/judge/utils/diggpaginator.py @@ -189,6 +189,9 @@ def __init__(self, *args, **kwargs): # validate padding value max_padding = int(math.ceil(self.body / 2.0) - 1) self.padding = kwargs.pop('padding', min(4, max_padding)) + count_override = kwargs.pop('count', None) + if count_override is not None: + self.__dict__['count'] = count_override if self.padding > max_padding: raise ValueError('padding too large for body (max %d)' % max_padding) super(DiggPaginator, self).__init__(*args, **kwargs) diff --git a/judge/utils/infinite_paginator.py b/judge/utils/infinite_paginator.py index 545ba9dd0..06e4f2198 100644 --- a/judge/utils/infinite_paginator.py +++ b/judge/utils/infinite_paginator.py @@ -1,4 +1,4 @@ -import collections +import collections.abc import inspect from math import ceil diff --git a/judge/utils/lazy.py b/judge/utils/lazy.py new file mode 100644 index 000000000..6da0686f5 --- /dev/null +++ b/judge/utils/lazy.py @@ -0,0 +1,18 @@ +from django.utils.functional import lazy + + +class LazyMemoizedCallable: + sentinel = object() + + def __init__(self, func): + self.value = self.sentinel + self.func = func + + def __call__(self): + if self.value is self.sentinel: + self.value = self.func() + return self.value + + +def memo_lazy(func, result_type): + return lazy(LazyMemoizedCallable(func), result_type)() diff --git a/judge/utils/mathoid.py b/judge/utils/mathoid.py index cb7f128fb..ae4550056 100644 --- a/judge/utils/mathoid.py +++ b/judge/utils/mathoid.py @@ -6,7 +6,6 @@ from django.conf import settings from django.core.cache import caches from django.utils.html import format_html -from django.utils.safestring import mark_safe from mistune import escape from judge.utils.file_cache import HashFileCache @@ -81,15 +80,15 @@ def query_mathoid(self, formula, hash): logger.error('Mathoid failure for: %s\n%s', formula, data) return - if any(i not in data for i in ('mml', 'png', 'svg', 'mathoidStyle')): - logger.error('Mathoid did not return required information (mml, png, svg, mathoidStyle needed):\n%s', data) + if any(i not in data for i in ('mml', 'svg', 'mathoidStyle')): + logger.error('Mathoid did not return required information (mml, svg, mathoidStyle needed):\n%s', data) return css = data['mathoidStyle'] mml = data['mml'] result = { - 'css': css, 'mml': mml, - 'png': self.cache.cache_data(hash, 'png', bytearray(data['png']['data'])), + 'css': css, + 'mml': mml, 'svg': self.cache.cache_data(hash, 'svg', data['svg'].encode('utf-8')), } self.cache.cache_data(hash, 'mml', mml.encode('utf-8'), url=False, gzip=False) @@ -97,10 +96,7 @@ def query_mathoid(self, formula, hash): return result def query_cache(self, hash): - result = { - 'svg': self.cache.get_url(hash, 'svg'), - 'png': self.cache.get_url(hash, 'png'), - } + result = {'svg': self.cache.get_url(hash, 'svg')} key = 'mathoid:css:' + hash css = result['css'] = self.css_cache.get(key) @@ -135,45 +131,25 @@ def get_result(self, formula): result['display'] = formula.startswith(r'\displaystyle') return { 'mml': self.output_mml, - 'msp': self.output_msp, - 'svg': self.output_svg, 'jax': self.output_jax, - 'png': self.output_png, + 'svg': self.output_svg, 'raw': lambda x: x, }[self.type](result) def output_mml(self, result): return result['mml'] - def output_msp(self, result): - # 100% MediaWiki compatibility. - return format_html('' - '' - '', - mark_safe(result['mml']), result['svg'], result['png'], result['css'], result['tex'], - ['inline', 'display'][result['display']]) - def output_jax(self, result): - return format_html('' - '''{3}""" - """""" + return format_html('' + '{2}' + '' '', - result['svg'], result['png'], result['css'], result['tex'], + result['svg'], result['css'], result['tex'], ['inline-math', 'display-math'][result['display']], ['~', '$$'][result['display']]) def output_svg(self, result): - return format_html('{3}""", - result['svg'], result['png'], result['css'], result['tex'], - ['inline-math', 'display-math'][result['display']]) - - def output_png(self, result): return format_html('{2}', - result['png'], result['css'], result['tex'], + result['svg'], result['css'], result['tex'], ['inline-math', 'display-math'][result['display']]) def display_math(self, math): diff --git a/judge/utils/opengraph.py b/judge/utils/opengraph.py index df016c297..7d409b7be 100644 --- a/judge/utils/opengraph.py +++ b/judge/utils/opengraph.py @@ -10,7 +10,7 @@ def generate_opengraph(cache_key, data, style): if metadata is None: description = None tree = reference(markdown(data, style)).tree - for p in tree.iterfind('.//p'): + for p in tree.iterfind('..//p'): text = p.text_content().strip() if text: description = text diff --git a/judge/utils/pdfoid.py b/judge/utils/pdfoid.py new file mode 100644 index 000000000..9fd6fc3c0 --- /dev/null +++ b/judge/utils/pdfoid.py @@ -0,0 +1,44 @@ +import base64 +import logging + +import requests +from django.conf import settings +from django.utils.translation import gettext + +logger = logging.getLogger('judge.problem.pdf') + + +PDFOID_URL = settings.DMOJ_PDF_PDFOID_URL +PDF_RENDERING_ENABLED = PDFOID_URL is not None + + +def render_pdf(*, title: str, html: str, footer: bool = False) -> bytes: + if not PDF_RENDERING_ENABLED: + raise RuntimeError("pdfoid is not configured, can't render PDFs") + + if footer: + footer_template = ( + '
' + + gettext('Page {page_number} of {total_pages}') + + '
') + else: + footer_template = None + + response = requests.post( + PDFOID_URL, + data={ + 'html': html, + 'title': title, + 'footer-template': footer_template, + 'wait-for-class': 'math-loaded', + 'wait-for-duration-secs': 15, + }, + ) + + response.raise_for_status() + data = response.json() + + if not data['success']: + raise RuntimeError(data['error']) + + return base64.b64decode(data['pdf']) diff --git a/judge/utils/problem_data.py b/judge/utils/problem_data.py index f31e0dc4d..c01764ec6 100644 --- a/judge/utils/problem_data.py +++ b/judge/utils/problem_data.py @@ -143,15 +143,8 @@ def make_checker(case): except Exception as e: raise ProblemDataError(e) - # Python checker doesn't need to use bridged - # so we return the name directly - if checker_ext == 'py': - return custom_checker_path[1] - - if checker_ext != 'cpp' and checker_ext != 'pas': - raise ProblemDataError(_("Why don't you use a cpp/pas/py checker?")) - # the cpp checker will be handled - # right below here, outside of this scope + if checker_ext not in ['cpp', 'pas', 'java']: + raise ProblemDataError(_('Only C++, Pascal, or Java checkers are supported.')) if case.checker_args: return { @@ -225,13 +218,6 @@ def make_grader(init, case): init['signature_grader']['allow_main'] = True return - if case.grader == 'custom_judge': - file_name, file_ext = get_file_name_and_ext(case.custom_grader.name) - if file_ext != 'py': - raise ProblemDataError(_('Only accept `.py` custom judge')) - init['custom_judge'] = file_name - return - for i, case in enumerate(self.cases, 1): if case.type == 'C': data = {} @@ -294,7 +280,7 @@ def make_grader(init, case): case.save(update_fields=('checker_args', 'input_file', 'output_file')) elif case.type == 'E': if not batch: - raise ProblemDataError(_('Attempt to end batch outside of one in case #%d') % i) + raise ProblemDataError(_('Attempt to end batch outside of one in case #%d.') % i) case.is_pretest = batch['is_pretest'] case.input_file = '' case.output_file = '' @@ -321,23 +307,40 @@ def make_grader(init, case): raise ProblemDataError(_('How did you corrupt the generator path?')) init['generator'] = generator_path[1] - pretests = [case for case in cases if case['is_pretest']] + pretest_test_cases = [] + test_cases = [] + hints = [] + for case in cases: + if case['is_pretest']: + pretest_test_cases.append(case) + else: + test_cases.append(case) + del case['is_pretest'] - if pretests: - init['pretest_test_cases'] = pretests - if cases: - init['test_cases'] = cases + + if pretest_test_cases: + init['pretest_test_cases'] = pretest_test_cases + if test_cases: + init['test_cases'] = test_cases if self.data.output_limit is not None: init['output_limit_length'] = self.data.output_limit if self.data.output_prefix is not None: init['output_prefix_length'] = self.data.output_prefix + if self.data.unicode: + hints.append('unicode') + if self.data.nobigmath: + hints.append('nobigmath') if self.data.checker: init['checker'] = make_checker(self.data) else: self.data.checker_args = '' if self.data.grader: make_grader(init, self.data) + + if hints: + init['hints'] = hints + return init def compile(self): diff --git a/judge/utils/problems.py b/judge/utils/problems.py index 3e029ebdd..693be2a79 100644 --- a/judge/utils/problems.py +++ b/judge/utils/problems.py @@ -2,10 +2,10 @@ from math import e from django.core.cache import cache -from django.db.models import Case, Count, ExpressionWrapper, F, Max, When +from django.db.models import Case, Count, ExpressionWrapper, F, When from django.db.models.fields import FloatField from django.utils import timezone -from django.utils.translation import gettext as _, gettext_noop +from django.utils.translation import gettext_noop from judge.models import Problem, Submission @@ -44,11 +44,7 @@ def contest_attempted_ids(participation): key = 'contest_attempted:%s' % participation.id result = cache.get(key) if result is None: - result = {id: {'achieved_points': points, 'max_points': max_points} - for id, max_points, points in (participation.submissions - .values_list('problem__problem__id', 'problem__points') - .annotate(points=Max('points')) - .filter(points__lt=F('problem__points')))} + result = set(participation.submissions.values_list('problem__problem_id', flat=True).distinct()) cache.set(key, result, 86400) return result @@ -57,11 +53,7 @@ def user_attempted_ids(profile): key = 'user_attempted:%s' % profile.id result = cache.get(key) if result is None: - result = {id: {'achieved_points': points, 'max_points': max_points} - for id, max_points, points in (Submission.objects.filter(user=profile) - .values_list('problem__id', 'problem__points') - .annotate(points=Max('points')) - .filter(points__lt=F('problem__points')))} + result = set(profile.submission_set.values_list('problem_id', flat=True).distinct()) cache.set(key, result, 86400) return result @@ -86,7 +78,7 @@ def get_result_data(*args, **kwargs): if args: submissions = args[0] if kwargs: - raise ValueError(_("Can't pass both queryset and keyword filters")) + raise ValueError("Can't pass both queryset and keyword filters") else: submissions = Submission.objects.filter(**kwargs) if kwargs is not None else Submission.objects raw = submissions.values('result').annotate(count=Count('result')).values_list('result', 'count') diff --git a/judge/utils/pwned.py b/judge/utils/pwned.py index 98d1709d8..bab356223 100644 --- a/judge/utils/pwned.py +++ b/judge/utils/pwned.py @@ -73,7 +73,7 @@ def _get_pwned(prefix): results = {} for line in response.text.splitlines(): line_suffix, _, times = line.partition(':') - results[line_suffix] = int(times) + results[line_suffix] = int(times.replace(',', '')) return results diff --git a/judge/utils/raw_sql.py b/judge/utils/raw_sql.py index 6dbeb9c30..bbf7235bd 100644 --- a/judge/utils/raw_sql.py +++ b/judge/utils/raw_sql.py @@ -1,32 +1,10 @@ -from copy import copy - from django.db import connections -from django.db.models import Field -from django.db.models.expressions import RawSQL from django.db.models.sql.constants import INNER, LOUTER from django.db.models.sql.datastructures import Join from judge.utils.cachedict import CacheDict -def unique_together_left_join(queryset, model, link_field_name, filter_field_name, filter_value, parent_model=None): - link_field = copy(model._meta.get_field(link_field_name).remote_field) - filter_field = model._meta.get_field(filter_field_name) - - def restrictions(where_class, alias, related_alias): - cond = where_class() - cond.add(filter_field.get_lookup('exact')(filter_field.get_col(alias), filter_value), 'AND') - return cond - - link_field.get_extra_restriction = restrictions - - if parent_model is not None: - parent_alias = parent_model._meta.db_table - else: - parent_alias = queryset.query.get_initial_alias() - return queryset.query.join(Join(model._meta.db_table, parent_alias, None, LOUTER, link_field, True)) - - class RawSQLJoin(Join): def __init__(self, subquery, subquery_params, parent_alias, table_alias, join_type, join_field, nullable, filtered_relation=None): @@ -40,8 +18,9 @@ def as_sql(self, compiler, connection): class FakeJoinField: - def __init__(self, joining_columns): + def __init__(self, joining_columns, related_model): self.joining_columns = joining_columns + self.related_model = related_model def get_joining_columns(self): return self.joining_columns @@ -50,26 +29,22 @@ def get_extra_restriction(self, where_class, alias, remote_alias): pass -def join_sql_subquery(queryset, subquery, params, join_fields, alias, join_type=INNER, parent_model=None): +def join_sql_subquery( + queryset, subquery, params, join_fields, alias, related_model, join_type=INNER, parent_model=None): if parent_model is not None: parent_alias = parent_model._meta.db_table else: parent_alias = queryset.query.get_initial_alias() - queryset.query.external_aliases.add(alias) - join = RawSQLJoin(subquery, params, parent_alias, alias, join_type, FakeJoinField(join_fields), join_type == LOUTER) + if isinstance(queryset.query.external_aliases, dict): # Django 3.x + queryset.query.external_aliases[alias] = True + else: + queryset.query.external_aliases.add(alias) + join = RawSQLJoin(subquery, params, parent_alias, alias, join_type, FakeJoinField(join_fields, related_model), + join_type == LOUTER) queryset.query.join(join) join.table_alias = alias -def RawSQLColumn(model, field=None): - if isinstance(model, Field): - field = model - model = field.model - if isinstance(field, str): - field = model._meta.get_field(field) - return RawSQL('%s.%s' % (model._meta.db_table, field.get_attname_column()[1]), ()) - - def make_straight_join_query(QueryType): class Query(QueryType): def join(self, join, *args, **kwargs): diff --git a/judge/utils/strings.py b/judge/utils/strings.py index 22987307b..d64100a66 100644 --- a/judge/utils/strings.py +++ b/judge/utils/strings.py @@ -1,3 +1,6 @@ +from math import isfinite + + def safe_int_or_none(value): try: return int(value) @@ -5,8 +8,13 @@ def safe_int_or_none(value): return None -def safe_float_or_none(value): +def safe_float_or_none(value, force_finite=True): try: - return float(value) + num = float(value) except (ValueError, TypeError): return None + + if force_finite and not isfinite(num): + return None + + return num diff --git a/judge/utils/tests/test_lazy.py b/judge/utils/tests/test_lazy.py new file mode 100644 index 000000000..7436780d1 --- /dev/null +++ b/judge/utils/tests/test_lazy.py @@ -0,0 +1,18 @@ +import unittest + +from judge.utils.lazy import memo_lazy + + +class MemoLazyTestCase(unittest.TestCase): + def test_works(self): + called_times = 0 + + def work(): + nonlocal called_times + called_times += 1 + return 123 + + result = memo_lazy(work, int) + self.assertEqual(result, 123) + self.assertEqual(result + 123, 246) + self.assertEqual(called_times, 1) diff --git a/judge/utils/texoid.py b/judge/utils/texoid.py index d66806613..1f57dd019 100644 --- a/judge/utils/texoid.py +++ b/judge/utils/texoid.py @@ -32,7 +32,7 @@ def query_texoid(self, document, hash): }) response.raise_for_status() except requests.HTTPError as e: - if e.response.status == 400: + if e.response.status_code == 400: logger.error('Texoid failed to render: %s\n%s', document, e.response.text) else: logger.exception('Failed to connect to texoid for: %s', document) diff --git a/judge/utils/views.py b/judge/utils/views.py index 1aef0509a..6abe796c0 100644 --- a/judge/utils/views.py +++ b/judge/utils/views.py @@ -1,26 +1,10 @@ from django.shortcuts import render -from django.utils.decorators import method_decorator from django.views.generic import FormView from django.views.generic.detail import SingleObjectMixin from judge.utils.diggpaginator import DiggPaginator -def class_view_decorator(function_decorator): - """Convert a function based decorator into a class based decorator usable - on class based Views. - - Can't subclass the `View` as it breaks inheritance (super in particular), - so we monkey-patch instead. - """ - - def simple_decorator(View): - View.dispatch = method_decorator(function_decorator)(View.dispatch) - return View - - return simple_decorator - - def generic_message(request, title, message, status=None): return render(request, 'generic-message.html', { 'message': message, @@ -113,7 +97,7 @@ def get_sort_context(self): links[current] = sort_prefix + ('' if self.order.startswith('-') else '-') + current order = {key: '' for key in self.all_sorts} - order[current] = ' \u25BE' if self.order.startswith('-') else u' \u25B4' + order[current] = ' \u25BE' if self.order.startswith('-') else ' \u25B4' return {'sort_links': links, 'sort_order': order} def get_sort_paginate_context(self): diff --git a/judge/views/api/__init__.py b/judge/views/api/__init__.py index 12355ffb0..f25bab089 100644 --- a/judge/views/api/__init__.py +++ b/judge/views/api/__init__.py @@ -1,4 +1,3 @@ -from .api_v1 import * from .api_v2 import ( APIContestDetail, APIContestList, APIContestParticipationList, APIOrganizationList, APIProblemDetail, APIProblemList, APISubmissionDetail, APISubmissionList, APIUserDetail, APIUserList, diff --git a/judge/views/api/api_v1.py b/judge/views/api/api_v1.py deleted file mode 100644 index a10fd390a..000000000 --- a/judge/views/api/api_v1.py +++ /dev/null @@ -1,204 +0,0 @@ -from operator import attrgetter - -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import F, OuterRef, Prefetch, Subquery -from django.http import Http404, JsonResponse -from django.shortcuts import get_object_or_404 - -from dmoj import settings -from judge.models import Contest, ContestParticipation, ContestTag, Problem, Profile, Rating, Submission - - -def sane_time_repr(delta): - days = delta.days - hours = delta.seconds / 3600 - minutes = (delta.seconds % 3600) / 60 - return '%02d:%02d:%02d' % (days, hours, minutes) - - -def api_v1_contest_list(request): - queryset = Contest.get_visible_contests(request.user).prefetch_related( - Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list')) - - return JsonResponse({c.key: { - 'name': c.name, - 'start_time': c.start_time.isoformat(), - 'end_time': c.end_time.isoformat(), - 'time_limit': c.time_limit and sane_time_repr(c.time_limit), - 'labels': list(map(attrgetter('name'), c.tag_list)), - } for c in queryset}) - - -def api_v1_contest_detail(request, contest): - contest = get_object_or_404(Contest, key=contest) - - if not contest.is_accessible_by(request.user): - raise Http404() - - in_contest = contest.is_in_contest(request.user) - can_see_rankings = contest.can_see_full_scoreboard(request.user) - - problems = list(contest.contest_problems.select_related('problem') - .defer('problem__description').order_by('order')) - - new_ratings_subquery = Rating.objects.filter(participation=OuterRef('pk')) - old_ratings_subquery = (Rating.objects.filter(user=OuterRef('user__pk'), - contest__end_time__lt=OuterRef('contest__end_time')) - .order_by('-contest__end_time')) - participations = (contest.users.filter(virtual=0) - .annotate(new_rating=Subquery(new_ratings_subquery.values('rating')[:1])) - .annotate(old_rating=Subquery(old_ratings_subquery.values('rating')[:1])) - .prefetch_related('user__organizations') - .annotate(username=F('user__user__username')) - .order_by('-score', 'cumtime', 'tiebreaker') if can_see_rankings else []) - can_see_problems = (in_contest or contest.ended or contest.is_editable_by(request.user)) - - return JsonResponse({ - 'time_limit': contest.time_limit and contest.time_limit.total_seconds(), - 'start_time': contest.start_time.isoformat(), - 'end_time': contest.end_time.isoformat(), - 'tags': list(contest.tags.values_list('name', flat=True)), - 'is_rated': contest.is_rated, - 'rate_all': contest.is_rated and contest.rate_all, - 'has_rating': contest.ratings.exists(), - 'rating_floor': contest.rating_floor, - 'rating_ceiling': contest.rating_ceiling, - 'format': { - 'name': contest.format_name, - 'config': contest.format_config, - }, - 'problems': [ - { - 'points': int(problem.points), - 'partial': problem.partial, - 'name': problem.problem.name, - 'code': problem.problem.code, - } for problem in problems] if can_see_problems else [], - 'rankings': [ - { - 'user': participation.username, - 'points': participation.score, - 'cumtime': participation.cumtime, - 'tiebreaker': participation.tiebreaker, - 'old_rating': participation.old_rating, - 'new_rating': participation.new_rating, - 'is_disqualified': participation.is_disqualified, - 'solutions': contest.format.get_problem_breakdown(participation, problems), - } for participation in participations], - }) - - -def api_v1_problem_list(request): - queryset = Problem.get_visible_problems(request.user) - if settings.ENABLE_FTS and 'search' in request.GET: - query = ' '.join(request.GET.getlist('search')).strip() - if query: - queryset = queryset.search(query) - queryset = queryset.values_list('code', 'points', 'partial', 'name', 'group__full_name') - - return JsonResponse({code: { - 'points': points, - 'partial': partial, - 'name': name, - 'group': group, - } for code, points, partial, name, group in queryset}) - - -def api_v1_problem_info(request, problem): - p = get_object_or_404(Problem, code=problem) - if not p.is_accessible_by(request.user, skip_contest_problem_check=True): - raise Http404() - - return JsonResponse({ - 'name': p.name, - 'authors': list(p.authors.values_list('user__username', flat=True)), - 'types': list(p.types.values_list('full_name', flat=True)), - 'group': p.group.full_name, - 'time_limit': p.time_limit, - 'memory_limit': p.memory_limit, - 'points': p.points, - 'partial': p.partial, - 'languages': list(p.allowed_languages.values_list('key', flat=True)), - }) - - -def api_v1_user_list(request): - queryset = Profile.objects.filter(is_unlisted=False).values_list('user__username', 'points', 'performance_points', - 'display_rank') - return JsonResponse({username: { - 'points': points, - 'performance_points': performance_points, - 'rank': rank, - } for username, points, performance_points, rank in queryset}) - - -def api_v1_user_info(request, user): - profile = get_object_or_404(Profile, user__username=user) - submissions = list(Submission.objects.filter(case_points=F('case_total'), user=profile, problem__is_public=True, - problem__is_organization_private=False) - .values('problem').distinct().values_list('problem__code', flat=True)) - resp = { - 'points': profile.points, - 'performance_points': profile.performance_points, - 'rank': profile.display_rank, - 'solved_problems': submissions, - 'organizations': list(profile.organizations.values_list('id', flat=True)), - } - - last_rating = profile.ratings.last() - - contest_history = {} - participations = ContestParticipation.objects.filter(user=profile, virtual=0, contest__is_visible=True, - contest__is_private=False, - contest__is_organization_private=False) - for contest_key, rating, mean, performance in participations.values_list( - 'contest__key', 'rating__rating', 'rating__mean', 'rating__performance', - ): - contest_history[contest_key] = { - 'rating': rating, - 'raw_rating': mean, - 'performance': performance, - } - - resp['contests'] = { - 'current_rating': last_rating.rating if last_rating else None, - 'history': contest_history, - } - - return JsonResponse(resp) - - -def api_v1_user_submissions(request, user): - profile = get_object_or_404(Profile, user__username=user) - subs = Submission.objects.filter(user=profile, problem__is_public=True, problem__is_organization_private=False) - - return JsonResponse({sub['id']: { - 'problem': sub['problem__code'], - 'time': sub['time'], - 'memory': sub['memory'], - 'points': sub['points'], - 'language': sub['language__key'], - 'status': sub['status'], - 'result': sub['result'], - } for sub in subs.values('id', 'problem__code', 'time', 'memory', 'points', 'language__key', 'status', 'result')}) - - -def api_v1_user_ratings(request, page): - queryset = Profile.objects.filter(is_unlisted=False, user__is_active=True).values_list('user__username', 'rating') - paginator = Paginator(queryset, settings.DMOJ_API_PAGE_SIZE) - - try: - page = paginator.page(int(page)) - except (PageNotAnInteger, EmptyPage): - return JsonResponse({'error': 'page not found'}, status=422) - except (KeyError, ValueError): - return JsonResponse({'error': 'invalid page number'}, status=422) - - return JsonResponse({ - 'pages': paginator.num_pages, - 'users': { - username: { - 'rating': rating, - } for username, rating in page - }, - }) diff --git a/judge/views/api/api_v2.py b/judge/views/api/api_v2.py index 7d350442c..6b998bf87 100644 --- a/judge/views/api/api_v2.py +++ b/judge/views/api/api_v2.py @@ -572,6 +572,7 @@ def get_unfiltered_queryset(self): queryset, subquery=Problem.get_visible_problems(self.request.user).distinct().only('id').query, params=[], + related_model=Problem, join_fields=[('problem_id', 'id')], alias='visible_problems', ) diff --git a/judge/views/blog.py b/judge/views/blog.py index 3d627236c..4b25b6ec6 100644 --- a/judge/views/blog.py +++ b/judge/views/blog.py @@ -2,8 +2,8 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.db import IntegrityError -from django.db.models import Count, Max -from django.db.models.expressions import Value +from django.db.models import Count, FilteredRelation, Max, Q +from django.db.models.expressions import F, Value from django.db.models.functions import Coalesce from django.http import (Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, @@ -22,8 +22,7 @@ from judge.tasks import on_new_blogpost from judge.utils.cachedict import CacheDict from judge.utils.diggpaginator import DiggPaginator -from judge.utils.problems import user_completed_ids -from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join +from judge.utils.opengraph import generate_opengraph from judge.utils.tickets import filter_visible_tickets from judge.utils.views import TitleMixin, generic_message @@ -39,8 +38,9 @@ def vote_blog(request, delta): if 'id' not in request.POST or len(request.POST['id']) > 10: return HttpResponseBadRequest() - if not request.user.is_staff and not request.profile.has_any_solves: - return HttpResponseBadRequest(_('You must solve at least one problem before you can vote.'), + if request.profile.is_new_user: + return HttpResponseBadRequest(_('You must solve at least %d problems before you can vote.') + % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT, content_type='text/plain') if request.profile.mute: @@ -117,9 +117,10 @@ def get_queryset(self): queryset = (BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now()) .prefetch_related('authors__user', 'authors__display_badge')) if self.request.user.is_authenticated: - queryset = queryset.annotate(vote_score=Coalesce(RawSQLColumn(BlogVote, 'score'), Value(0))) profile = self.request.profile - unique_together_left_join(queryset, BlogVote, 'blog', 'voter', profile.id) + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) return queryset def get_context_data(self, **kwargs): @@ -180,16 +181,6 @@ def get_context_data(self, **kwargs): now = timezone.now() - # Dashboard stuff - if self.request.user.is_authenticated: - user = self.request.profile - context['recently_attempted_problems'] = (Submission.objects.filter(user=user) - .exclude(problem__in=user_completed_ids(user)) - .values_list('problem__code', 'problem__name', 'problem__points') - .annotate(points=Max('points'), latest=Max('date')) - .order_by('-latest') - [:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT]) - visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True) \ .order_by('start_time') @@ -252,14 +243,20 @@ def get_comment_page(self): def get_queryset(self): queryset = super().get_queryset() if self.request.user.is_authenticated: - queryset = queryset.annotate(vote_score=Coalesce(RawSQLColumn(BlogVote, 'score'), Value(0))) profile = self.request.profile - unique_together_left_join(queryset, BlogVote, 'blog', 'voter', profile.id) + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) return queryset def get_context_data(self, **kwargs): context = super(PostView, self).get_context_data(**kwargs) - context['og_image'] = self.object.og_image + + metadata = generate_opengraph('generated-meta-blog:%d' % self.object.id, + self.object.summary or self.object.content, 'blog') + context['meta_description'] = metadata[0] + context['og_image'] = self.object.og_image or metadata[1] + return context def get_object(self, queryset=None): diff --git a/judge/views/comment.py b/judge/views/comment.py index 1d0145a98..10b3565a0 100644 --- a/judge/views/comment.py +++ b/judge/views/comment.py @@ -1,9 +1,12 @@ +from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import PermissionDenied from django.db import IntegrityError +from django.db.models import F from django.forms.models import ModelForm -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, \ + HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from django.views.decorators.http import require_POST @@ -31,8 +34,9 @@ def vote_comment(request, delta): if 'id' not in request.POST or len(request.POST['id']) > 10: return HttpResponseBadRequest() - if not request.user.is_staff and not request.profile.has_any_solves: - return HttpResponseBadRequest(_('You must solve at least one problem before you can vote.'), + if request.profile.is_new_user: + return HttpResponseBadRequest(_('You must solve at least %d problems before you can vote.') + % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT, content_type='text/plain') if request.profile.mute: @@ -130,7 +134,11 @@ def form_valid(self, form): with revisions.create_revision(atomic=True): revisions.set_comment(_('Edited from site')) revisions.set_user(self.request.user) - return super(CommentEditAjax, self).form_valid(form) + + self.object = comment = form.save(commit=False) + comment.revisions = F('revisions') + 1 + comment.save() + return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): return self.object.get_absolute_url() @@ -178,4 +186,5 @@ def comment_hide(request): comment = get_object_or_404(Comment, id=comment_id) comment.get_descendants(include_self=True).update(hidden=True) + comment.author.calculate_contribution_points() return HttpResponse('ok') diff --git a/judge/views/contests.py b/judge/views/contests.py index cce75d806..20745356b 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -17,9 +17,9 @@ from django.db.models.expressions import CombinedExpression from django.db.models.query import Prefetch from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect -from django.shortcuts import get_object_or_404, render -from django.template import loader +from django.shortcuts import get_object_or_404, redirect, render from django.template.defaultfilters import date as date_filter, floatformat +from django.template.loader import get_template from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -28,7 +28,7 @@ from django.utils.timezone import make_aware from django.utils.translation import gettext as _, gettext_lazy from django.views.generic import FormView, ListView, TemplateView -from django.views.generic.detail import BaseDetailView, DetailView, SingleObjectMixin, View +from django.views.generic.detail import DetailView, SingleObjectMixin, View from django.views.generic.edit import CreateView, UpdateView from django.views.generic.list import BaseListView from icalendar import Calendar as ICalendar, Event @@ -116,6 +116,10 @@ def get_queryset(self): query_set = query_set.filter(Q(key__icontains=search_query) | Q(name__icontains=search_query)) return query_set + def get_paginator(self, queryset, per_page, orphans=0, allow_empty_first_page=True, **kwargs): + return super().get_paginator(queryset, per_page, orphans, allow_empty_first_page, + count=self.get_queryset().values('id').count(), **kwargs) + def get_context_data(self, **kwargs): context = super(ContestList, self).get_context_data(**kwargs) present, active, future = [], [], [] @@ -288,14 +292,14 @@ def get_context_data(self, **kwargs): .add_i18n_name(self.request.LANGUAGE_CODE) # convert to problem points in contest instead of actual points - points_list = self.object.contest_problems.values_list('points').order_by('order') + points_list = list(self.object.contest_problems.values_list('points').order_by('order')) for idx, p in enumerate(context['contest_problems']): p.points = points_list[idx][0] context['metadata'] = { 'has_public_editorials': any( problem.is_public and problem.has_public_editorial for problem in context['contest_problems'] - ), + ) if self.object.ended else False, } context['metadata'].update( **self.object.contest_problems @@ -328,6 +332,27 @@ def get_context_data(self, **kwargs): return context +class ContestAllProblems(ContestMixin, TitleMixin, DetailView): + template_name = 'contest/contest-all-problems.html' + + def get_title(self): + return self.object.name + + def get_context_data(self, **kwargs): + context = super(ContestAllProblems, self).get_context_data(**kwargs) + context['contest_problems'] = Problem.objects.filter(contests__contest=self.object) \ + .order_by('contests__order') \ + .add_i18n_name(self.request.LANGUAGE_CODE) \ + .add_i18n_description(self.request.LANGUAGE_CODE) + + # convert to problem points in contest instead of actual points + points_list = list(self.object.contest_problems.values_list('points').order_by('order')) + for idx, p in enumerate(context['contest_problems']): + p.points = points_list[idx][0] + + return context + + class ContestClone(ContestMixin, PermissionRequiredMixin, TitleMixin, SingleObjectFormView): title = gettext_lazy('Clone Contest') template_name = 'contest/clone.html' @@ -410,7 +435,88 @@ def __init__(self, *args, **kwargs): self.fields['access_code'].widget.attrs.update({'autocomplete': 'off'}) -class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView): +class ContestRegister(LoginRequiredMixin, ContestMixin, SingleObjectMixin, View): + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return self.ask_for_access_code() + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + try: + return self.register_contest(request) + except ContestAccessDenied: + if request.POST.get('access_code'): + return self.ask_for_access_code(ContestAccessCodeForm(request.POST)) + else: + return HttpResponseRedirect(request.path) + + def register_contest(self, request, access_code=None): + contest = self.object + profile = request.profile + + if self.is_editor or self.is_tester: + return generic_message(request, _('Cannot register'), + _('You cannot register for this contest.')) + + if not request.user.is_superuser and contest.banned_users.filter(id=profile.id).exists(): + return generic_message(request, _('Banned from joining'), + _('You have been declared persona non grata for this contest. ' + 'You are permanently barred from joining this contest.')) + + if not contest.require_registration: + return generic_message(request, _('Cannot register'), + _('Registration is not required for this contest.')) + + if not contest.can_register: + return generic_message(request, _('Cannot register'), + _('You cannot register for this contest now.')) + + requires_access_code = (not self.can_edit and contest.access_code and access_code != contest.access_code) + if contest.ended: + return generic_message(request, _('Contest has ended'), + _('"%s" has ended.') % contest.name) + else: + if self.is_editor or self.is_tester: + return generic_message(request, _('Cannot register'), + _('You cannot register for this contest.')) + + try: + ContestParticipation.objects.get( + contest=contest, user=profile, virtual=0, + ) + except ContestParticipation.DoesNotExist: + if requires_access_code: + raise ContestAccessDenied() + + ContestParticipation.objects.create( + contest=contest, user=profile, virtual=0, + real_start=datetime(1970, 1, 1, tzinfo=timezone.utc), + ) + else: + return generic_message(request, _('Already registered'), + _('You have already registered for this contest.')) + + contest._updating_stats_only = True + contest.update_user_count() + return HttpResponseRedirect(reverse('contest_view', args=(contest.key,))) + + def ask_for_access_code(self, form=None): + contest = self.object + wrong_code = False + if form: + if form.is_valid(): + if form.cleaned_data['access_code'] == contest.access_code: + return self.register_contest(self.request, form.cleaned_data['access_code']) + wrong_code = True + else: + form = ContestAccessCodeForm() + return render(self.request, 'contest/access_code.html', { + 'form': form, 'wrong_code': wrong_code, + 'title': _('Enter access code for "%s"') % contest.name, + }) + + +class ContestJoin(LoginRequiredMixin, ContestMixin, SingleObjectMixin, View): def get(self, request, *args, **kwargs): self.object = self.get_object() return self.ask_for_access_code() @@ -439,6 +545,16 @@ def join_contest(self, request, access_code=None): _('You have been declared persona non grata for this contest. ' 'You are permanently barred from joining this contest.')) + # Conditions for joining a contest: + # - If contest has ended, allow virtual joining iff: + # - contest.disallow_virtual is False + # - requires_access_code is False + # - If contest is ongoing, allow joining iff: + # - Not editor or tester + # - Registered if registration windows has ended + # - requires_access_code is False + # - Editors/Testers can only spectate live contests and only when requires_access_code is False. + requires_access_code = (not self.can_edit and contest.access_code and access_code != contest.access_code) if contest.ended: if contest.disallow_virtual: @@ -464,19 +580,29 @@ def join_contest(self, request, access_code=None): else: SPECTATE = ContestParticipation.SPECTATE LIVE = ContestParticipation.LIVE + can_only_spectate = self.is_editor or self.is_tester try: participation = ContestParticipation.objects.get( - contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE), + contest=contest, user=profile, virtual=(SPECTATE if can_only_spectate else LIVE), ) except ContestParticipation.DoesNotExist: + if contest.require_registration and not contest.can_register and not can_only_spectate: + return generic_message(request, _('Not registered'), + _('You are not registered for this contest.')) + if requires_access_code: raise ContestAccessDenied() participation = ContestParticipation.objects.create( - contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE), + contest=contest, user=profile, virtual=(SPECTATE if can_only_spectate else LIVE), real_start=timezone.now(), ) else: + if participation.pre_registered: + # Pre-registered. First time joining. + participation.real_start = timezone.now() + participation.save() + if participation.ended: participation = ContestParticipation.objects.get_or_create( contest=contest, user=profile, virtual=SPECTATE, @@ -505,7 +631,7 @@ def ask_for_access_code(self, form=None): }) -class ContestLeave(LoginRequiredMixin, ContestMixin, BaseDetailView): +class ContestLeave(LoginRequiredMixin, ContestMixin, SingleObjectMixin, View): def dispatch(self, request, *args, **kwargs): if request.method != 'POST': return HttpResponseForbidden() @@ -524,12 +650,11 @@ def post(self, request, *args, **kwargs): return HttpResponseRedirect(reverse('contest_view', args=(contest.key,))) -ContestDay = namedtuple('ContestDay', 'date weekday is_pad is_today starts ends oneday') +ContestDay = namedtuple('ContestDay', 'date is_pad is_today starts ends oneday') class ContestCalendar(TitleMixin, ContestListMixin, TemplateView): firstweekday = SUNDAY - weekday_classes = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] template_name = 'contest/calendar.html' def get(self, request, *args, **kwargs): @@ -537,7 +662,7 @@ def get(self, request, *args, **kwargs): self.year = int(kwargs['year']) self.month = int(kwargs['month']) except (KeyError, ValueError): - raise ImproperlyConfigured(_('ContestCalendar requires integer year and month')) + raise ImproperlyConfigured('ContestCalendar requires integer year and month') self.today = timezone.now().date() return self.render() @@ -565,9 +690,9 @@ def get_table(self): starts, ends, oneday = self.get_contest_data(make_aware(datetime.combine(calendar[0][0], time.min)), make_aware(datetime.combine(calendar[-1][-1], time.min))) return [[ContestDay( - date=date, weekday=self.weekday_classes[weekday], is_pad=date.month != self.month, + date=date, is_pad=date.month != self.month, is_today=date == self.today, starts=starts[date], ends=ends[date], oneday=oneday[date], - ) for weekday, date in enumerate(week)] for week in calendar] + ) for date in week] for week in calendar] def get_context_data(self, **kwargs): context = super(ContestCalendar, self).get_context_data(**kwargs) @@ -699,12 +824,12 @@ def get_context_data(self, **kwargs): BestSolutionData = namedtuple('BestSolutionData', 'code points time state is_pretested') -def make_contest_ranking_profile(contest, participation, contest_problems, frozen=False): +def make_contest_ranking_profile(contest, participation, contest_problems, first_solves, frozen=False): def display_user_problem(contest_problem): # When the contest format is changed, `format_data` might be invalid. # This will cause `display_user_problem` to error, so we display '???' instead. try: - return contest.format.display_user_problem(participation, contest_problem, frozen) + return contest.format.display_user_problem(participation, contest_problem, first_solves, frozen) except (KeyError, TypeError, ValueError): return mark_safe('???') @@ -728,8 +853,11 @@ def display_user_problem(contest_problem): def base_contest_ranking_list(contest, problems, queryset, frozen=False): - return [make_contest_ranking_profile(contest, participation, problems, frozen) for participation in - queryset.select_related('user__user', 'rating').defer('user__about', 'user__organizations__about')] + queryset = queryset.select_related('user__user', 'rating').defer('user__about', 'user__organizations__about') + first_solves, total_ac = contest.format.get_first_solves_and_total_ac(problems, queryset, frozen) + users = [make_contest_ranking_profile(contest, participation, problems, first_solves, frozen) for participation + in queryset] + return users, total_ac def base_contest_ranking_queryset(contest): @@ -754,14 +882,15 @@ def contest_ranking_list(contest, problems, frozen=False): def get_contest_ranking_list(request, contest, participation=None, ranking_list=contest_ranking_list, ranker=ranker): problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order')) - users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime', 'tiebreaker')) + users, total_ac = ranking_list(contest, problems) + users = ranker(users, key=attrgetter('points', 'cumtime', 'tiebreaker')) - return users, problems + return users, problems, total_ac class ContestRankingBase(ContestMixin, TitleMixin, DetailView): template_name = 'contest/ranking.html' - ranking_table_template_name = 'contest/ranking-table.html' + ranking_table_template = get_template('contest/ranking-table.html') tab = None def get_title(self): @@ -777,12 +906,18 @@ def get_ranking_list(self): def is_frozen(self): return False + def check_can_see_own_scoreboard(self): + if not self.object.can_see_own_scoreboard(self.request.user): + raise Http404() + def get_rendered_ranking_table(self): - users, problems = self.get_ranking_list() + users, problems, total_ac = self.get_ranking_list() - return loader.render_to_string(self.ranking_table_template_name, request=self.request, context={ + return self.ranking_table_template.render(request=self.request, context={ + 'table_id': 'ranking-table', 'users': users, 'problems': problems, + 'total_ac': total_ac, 'contest': self.object, 'has_rating': self.object.ratings.exists(), 'is_frozen': self.is_frozen, @@ -794,8 +929,7 @@ def get_rendered_ranking_table(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - if not self.object.can_see_own_scoreboard(self.request.user): - raise Http404() + self.check_can_see_own_scoreboard() context['rendered_ranking_table'] = self.get_rendered_ranking_table() context['tab'] = self.tab @@ -805,8 +939,7 @@ def get(self, request, *args, **kwargs): if 'raw' in request.GET: self.object = self.get_object() - if not self.object.can_see_own_scoreboard(self.request.user): - raise Http404() + self.check_can_see_own_scoreboard() return HttpResponse(self.get_rendered_ranking_table(), content_type='text/plain') @@ -832,7 +965,7 @@ def cache_key(self): @property def bypass_cache_ranking(self): return self.object.scoreboard_cache_timeout == 0 or self.can_edit or \ - (self.request.user.is_authenticated and not self.object.can_see_full_scoreboard(self.request.user.profile)) + (self.request.user.is_authenticated and not self.object.can_see_full_scoreboard(self.request.user)) def get_ranking_queryset(self): if self.is_frozen: @@ -843,15 +976,7 @@ def get_ranking_queryset(self): queryset = queryset.filter(virtual=ContestParticipation.LIVE) return queryset - def get_ranking_list(self): - if not self.object.can_see_full_scoreboard(self.request.user): - queryset = self.object.users.filter(user=self.request.profile, virtual=ContestParticipation.LIVE) - return get_contest_ranking_list( - self.request, self.object, - ranking_list=partial(base_contest_ranking_list, queryset=queryset), - ranker=lambda users, key: ((_('???'), user) for user in users), - ) - + def get_full_ranking_list(self): if 'show_virtual' in self.request.GET: self.show_virtual = self.request.session['show_virtual'] \ = self.request.GET.get('show_virtual').lower() == 'true' @@ -859,12 +984,22 @@ def get_ranking_list(self): self.show_virtual = self.request.session.get('show_virtual', False) queryset = self.get_ranking_queryset() - return get_contest_ranking_list( self.request, self.object, ranking_list=partial(base_contest_ranking_list, queryset=queryset, frozen=self.is_frozen), ) + def get_ranking_list(self): + if not self.object.can_see_full_scoreboard(self.request.user): + queryset = self.object.users.filter(user=self.request.profile, virtual=ContestParticipation.LIVE) + return get_contest_ranking_list( + self.request, self.object, + ranking_list=partial(base_contest_ranking_list, queryset=queryset), + ranker=lambda users, key: ((_('???'), user) for user in users), + ) + + return self.get_full_ranking_list() + def get_rendered_ranking_table(self): if self.bypass_cache_ranking: return super().get_rendered_ranking_table() @@ -885,9 +1020,28 @@ def get_context_data(self, **kwargs): return context +class ContestPublicRanking(ContestRanking): + def check_can_see_own_scoreboard(self): + # ignore this check, we want to show the scoreboard to everyone + pass + + def get_ranking_list(self): + # ignore the `can_see_full_scoreboard` check + return self.get_full_ranking_list() + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + ranking_access_code = self.object.ranking_access_code + if not ranking_access_code or ranking_access_code != request.GET.get('code'): + return generic_message(request, _('Ranking access code required'), + _('You need to provide a valid ranking access code to access this page.')) + + return super().get(request, *args, **kwargs) + + class ContestOfficialRanking(ContestRankingBase): template_name = 'contest/official-ranking.html' - ranking_table_template_name = 'contest/official-ranking-table.html' + ranking_table_template = get_template('contest/official-ranking-table.html') tab = 'official_ranking' def get_title(self): @@ -896,7 +1050,7 @@ def get_title(self): def get_ranking_list(self): def display_points(points): return format_html( - u'{points}', + '{points}', points=floatformat(points), ) @@ -908,25 +1062,35 @@ def display_points(points): users = list(zip(range(1, len(users) + 1), users)) - return users, problems + return users, problems, {} def get_context_data(self, **kwargs): - if not self.object.csv_ranking: - raise Http404() - context = super().get_context_data(**kwargs) context['has_rating'] = False return context + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.csv_ranking: + raise Http404() + + # If the csv_ranking is an url, redirect to it + # (the check is not perfect, but it's good enough) + if self.object.csv_ranking.startswith('http'): + return redirect(self.object.csv_ranking) + + return super().get(request, *args, **kwargs) + class ContestParticipationList(LoginRequiredMixin, ContestRankingBase): tab = 'participation' def get_title(self): if self.profile == self.request.profile: - return _('Your participation in %s') % self.object.name - return _("%(user)s's participation in %(contest)s") % \ - ({'user': self.profile.username, 'contest': self.object.name}) + return _('Your participation in %(contest)s') % {'contest': self.object.name} + return _("%(user)s's participation in %(contest)s") % { + 'user': self.profile.username, 'contest': self.object.name, + } def get_ranking_list(self): if not self.object.can_see_full_scoreboard(self.request.user) and self.profile != self.request.profile: diff --git a/judge/views/organization.py b/judge/views/organization.py index bcc760b88..68201945d 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -2,9 +2,10 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.models import Group from django.core.exceptions import ImproperlyConfigured, PermissionDenied -from django.db.models import Count, Q -from django.db.models.expressions import Value +from django.db.models import Count, FilteredRelation, Q +from django.db.models.expressions import F, Value from django.db.models.functions import Coalesce from django.forms import Form, modelformset_factory from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect @@ -18,12 +19,11 @@ from reversion import revisions from judge.forms import OrganizationForm -from judge.models import BlogPost, BlogVote, Comment, Contest, Language, Organization, OrganizationRequest, \ +from judge.models import BlogPost, Comment, Contest, Language, Organization, OrganizationRequest, \ Problem, Profile from judge.tasks import on_new_problem from judge.utils.ranker import ranker -from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join -from judge.utils.views import QueryStringSortMixin, TitleMixin, generic_message +from judge.utils.views import DiggPaginatorMixin, QueryStringSortMixin, TitleMixin, generic_message from judge.views.blog import BlogPostCreate, PostListBase from judge.views.contests import ContestList, CreateContest from judge.views.problem import ProblemCreate, ProblemList @@ -65,6 +65,22 @@ def can_edit_organization(self, org=None): return org.is_admin(self.request.profile) +class BaseOrganizationListView(OrganizationMixin, ListView): + model = None + context_object_name = None + slug_url_kwarg = 'slug' + + def get_object(self): + return get_object_or_404(Organization, id=self.kwargs.get('pk')) + + def get_context_data(self, **kwargs): + return super().get_context_data(organization=self.object, **kwargs) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + class OrganizationDetailView(OrganizationMixin, DetailView): def get(self, request, *args, **kwargs): self.object = self.get_object() @@ -85,22 +101,28 @@ def get_queryset(self): return Organization.objects.filter(is_unlisted=False) -class OrganizationUsers(QueryStringSortMixin, OrganizationDetailView): +class OrganizationUsers(QueryStringSortMixin, DiggPaginatorMixin, BaseOrganizationListView): template_name = 'organization/users.html' all_sorts = frozenset(('points', 'problem_count', 'rating', 'performance_points')) default_desc = all_sorts default_sort = '-performance_points' + paginate_by = 100 + context_object_name = 'users' + + def get_queryset(self): + return self.object.members.filter(is_unlisted=False).order_by(self.order) \ + .select_related('user', 'display_badge').defer('about', 'user_script', 'notes') def get_context_data(self, **kwargs): context = super(OrganizationUsers, self).get_context_data(**kwargs) context['title'] = self.object.name - context['users'] = \ - ranker(self.object.members.filter(is_unlisted=False).order_by(self.order) - .select_related('user', 'display_badge').defer('about', 'user_script', 'notes')) + context['users'] = ranker(context['users']) context['partial'] = True context['is_admin'] = self.can_edit_organization() context['kick_url'] = reverse('organization_user_kick', args=[self.object.id, self.object.slug]) + context['first_page_href'] = '.' context.update(self.get_sort_context()) + context.update(self.get_sort_paginate_context()) return context @@ -128,7 +150,9 @@ def handle(self, request, org, profile): if profile.organizations.filter(is_open=True).count() >= max_orgs: return generic_message( request, _('Joining organization'), - _('You may not be part of more than {count} public organizations.').format(count=max_orgs), + ngettext('You may not be part of more than {count} public organization.', + 'You may not be part of more than {count} public organizations.', + max_orgs).format(count=max_orgs), ) profile.organizations.add(org) @@ -139,6 +163,8 @@ class LeaveOrganization(OrganizationMembershipChange): def handle(self, request, org, profile): if not profile.organizations.filter(id=org.id).exists(): return generic_message(request, _('Leaving organization'), _('You are not in "%s".') % org.short_name) + if org.is_admin(profile): + return generic_message(request, _('Leaving organization'), _('You cannot leave an organization you own.')) profile.organizations.remove(org) @@ -208,6 +234,12 @@ def get_object(self, queryset=None): raise PermissionDenied() return organization + def get_requests(self): + queryset = self.object.requests.select_related('user__user').defer( + 'user__about', 'user__notes', 'user__user_script', + ) + return queryset + def get_context_data(self, **kwargs): context = super(OrganizationRequestBaseView, self).get_context_data(**kwargs) context['title'] = _('Managing join requests for %s') % self.object.name @@ -229,24 +261,27 @@ def get_context_data(self, **kwargs): def get(self, request, *args, **kwargs): self.object = self.get_object() - self.formset = OrganizationRequestFormSet( - queryset=OrganizationRequest.objects.filter(state='P', organization=self.object), - ) + self.formset = OrganizationRequestFormSet(queryset=self.get_requests()) context = self.get_context_data(object=self.object) return self.render_to_response(context) + def get_requests(self): + return super().get_requests().filter(state='P') + def post(self, request, *args, **kwargs): self.object = organization = self.get_object() - self.formset = formset = OrganizationRequestFormSet(request.POST, request.FILES) + self.formset = formset = OrganizationRequestFormSet(request.POST, request.FILES, queryset=self.get_requests()) if formset.is_valid(): if organization.slots is not None: deleted_set = set(formset.deleted_forms) to_approve = sum(form.cleaned_data['state'] == 'A' for form in formset.forms if form not in deleted_set) can_add = organization.slots - organization.members.count() if to_approve > can_add: - messages.error(request, _('Your organization can only receive %(can_add)d more members. ' - 'You cannot approve %(to_approve)d users.') % - ({'can_add': can_add, 'to_approve': to_approve})) + msg1 = ngettext('Your organization can only receive %d more member.', + 'Your organization can only receive %d more members.', can_add) % can_add + msg2 = ngettext('You cannot approve %d user.', + 'You cannot approve %d users.', to_approve) % to_approve + messages.error(request, msg1 + '\n' + msg2) return self.render_to_response(self.get_context_data(object=organization)) approved, rejected = 0, 0 @@ -277,7 +312,7 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super(OrganizationRequestLog, self).get_context_data(**kwargs) - context['requests'] = self.object.requests.filter(state__in=self.states) + context['requests'] = self.get_requests().filter(state__in=self.states) return context @@ -299,8 +334,11 @@ def form_valid(self, form): # slug is show in url # short_name is show in ranking org.short_name = org.slug[:20] - org.admins.add(self.request.user.profile) org.save() + all_admins = org.admins.all() + g = Group.objects.get(name=settings.GROUP_PERMISSION_FOR_ORG_ADMIN) + for admin in all_admins: + admin.user.groups.add(g) return HttpResponseRedirect(self.get_success_url()) @@ -362,7 +400,12 @@ def post(self, request, *args, **kwargs): if not organization.members.filter(id=user.id).exists(): return generic_message(request, _("Can't kick user"), - _('The user you are trying to kick is not in organization: %s.') % + _('The user you are trying to kick is not in organization: %s') % + organization.name, status=400) + + if organization.admins.filter(id=user.id).exists(): + return generic_message(request, _("Can't kick user"), + _('The user you are trying to kick is an admin of organization: %s.') % organization.name, status=400) organization.members.remove(user) @@ -451,9 +494,10 @@ def get_queryset(self): queryset = queryset.filter(Q(visible=True) | Q(authors=self.request.profile)) if self.request.user.is_authenticated: - queryset = queryset.annotate(vote_score=Coalesce(RawSQLColumn(BlogVote, 'score'), Value(0))) profile = self.request.profile - unique_together_left_join(queryset, BlogVote, 'blog', 'voter', profile.id) + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) return queryset.order_by('-sticky', '-publish_on').prefetch_related('authors__user') diff --git a/judge/views/problem.py b/judge/views/problem.py index 44ecabf33..ed82047e0 100755 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -1,7 +1,6 @@ import logging import os import re -import shutil import zipfile from datetime import timedelta from operator import itemgetter @@ -32,11 +31,11 @@ ProposeProblemSolutionFormSet from judge.models import ContestSubmission, Judge, Language, Problem, ProblemGroup, \ ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource -from judge.pdf_problems import DefaultPdfMaker, HAS_PDF from judge.tasks import on_new_problem from judge.template_context import misc_config from judge.utils.diggpaginator import DiggPaginator from judge.utils.opengraph import generate_opengraph +from judge.utils.pdfoid import PDF_RENDERING_ENABLED, render_pdf from judge.utils.problems import hot_problems, user_attempted_ids, \ user_completed_ids from judge.utils.strings import safe_float_or_none, safe_int_or_none @@ -208,7 +207,7 @@ def get_context_data(self, **kwargs): context['available_judges'] = Judge.objects.filter(online=True, problems=self.object) context['show_languages'] = self.object.allowed_languages.count() != Language.objects.count() - context['has_pdf_render'] = HAS_PDF + context['has_pdf_render'] = PDF_RENDERING_ENABLED context['completed_problem_ids'] = self.get_completed_problems() context['attempted_problems'] = self.get_attempted_problems() @@ -255,7 +254,7 @@ class ProblemPdfView(ProblemMixin, SingleObjectMixin, View): languages = set(map(itemgetter(0), settings.LANGUAGES)) def get(self, request, *args, **kwargs): - if not HAS_PDF: + if not PDF_RENDERING_ENABLED: raise Http404() language = kwargs.get('language', self.request.LANGUAGE_CODE) @@ -263,48 +262,47 @@ def get(self, request, *args, **kwargs): raise Http404() problem = self.get_object() - try: - trans = problem.translations.get(language=language) - except ProblemTranslation.DoesNotExist: - trans = None - - cache = os.path.join(settings.DMOJ_PDF_PROBLEM_CACHE, '%s.%s.pdf' % (problem.code, language)) - - if not os.path.exists(cache): - self.logger.info('Rendering: %s.%s.pdf', problem.code, language) - with DefaultPdfMaker() as maker, translation.override(language): - problem_name = problem.name if trans is None else trans.name - maker.html = get_template('problem/raw.html').render({ - 'problem': problem, - 'problem_name': problem_name, - 'description': problem.description if trans is None else trans.description, - 'url': request.build_absolute_uri(), - 'math_engine': maker.math_engine, - }).replace('"//', '"https://').replace("'//", "'https://") - maker.title = problem_name - - assets = ['style.css', 'pygment-github.css'] - if maker.math_engine == 'jax': - assets.append('mathjax_config.js') - for file in assets: - maker.load(file, os.path.join(settings.DMOJ_RESOURCES, file)) - maker.make() - if not maker.success: - self.logger.error('Failed to render PDF for %s', problem.code) - return HttpResponse(maker.log, status=500, content_type='text/plain') - shutil.move(maker.pdffile, cache) + pdf_basename = '%s.%s.pdf' % (problem.code, language) + + def render_problem_pdf(): + self.logger.info('Rendering PDF in %s: %s', language, problem.code) + + with translation.override(language): + try: + trans = problem.translations.get(language=language) + except ProblemTranslation.DoesNotExist: + trans = None + + problem_name = trans.problem_name if trans else problem.name + return render_pdf( + html=get_template('problem/raw.html').render({ + 'problem': problem, + 'problem_name': problem_name, + 'description': trans.description if trans else problem.description, + 'url': request.build_absolute_uri(), + }).replace('"//', '"https://').replace("'//", "'https://"), + title=problem_name, + ) response = HttpResponse() + response['Content-Type'] = 'application/pdf' + response['Content-Disposition'] = f'inline; filename={pdf_basename}' - if hasattr(settings, 'DMOJ_PDF_PROBLEM_INTERNAL'): - url_path = '%s/%s.%s.pdf' % (settings.DMOJ_PDF_PROBLEM_INTERNAL, problem.code, language) - else: - url_path = None + if settings.DMOJ_PDF_PROBLEM_CACHE: + pdf_filename = os.path.join(settings.DMOJ_PDF_PROBLEM_CACHE, pdf_basename) + if not os.path.exists(pdf_filename): + with open(pdf_filename, 'wb') as f: + f.write(render_problem_pdf()) - add_file_response(request, response, url_path, cache) + if settings.DMOJ_PDF_PROBLEM_INTERNAL: + url_path = f'{settings.DMOJ_PDF_PROBLEM_INTERNAL}/{pdf_basename}' + else: + url_path = None + + add_file_response(request, response, url_path, pdf_filename) + else: + response.content = render_problem_pdf() - response['Content-Type'] = 'application/pdf' - response['Content-Disposition'] = 'inline; filename=%s.%s.pdf' % (problem.code, language) return response @@ -324,11 +322,8 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView def get_paginator(self, queryset, per_page, orphans=0, allow_empty_first_page=True, **kwargs): paginator = DiggPaginator(queryset, per_page, body=6, padding=2, orphans=orphans, + count=queryset.values('pk').count(), allow_empty_first_page=allow_empty_first_page, **kwargs) - # Get the number of pages and then add in this magic. - # noinspection PyStatementEffect - paginator.num_pages - queryset = queryset.add_i18n_name(self.request.LANGUAGE_CODE) sort_key = self.order.lstrip('-') if sort_key in self.sql_sort: @@ -381,9 +376,7 @@ def apply_full_text(queryset, query): def get_filter(self): filter = Q(is_public=True) & Q(is_organization_private=False) if self.profile is not None: - filter |= Q(authors=self.profile) - filter |= Q(curators=self.profile) - filter |= Q(testers=self.profile) + filter = Problem.q_add_author_curator_tester(filter, self.profile) return filter def get_normal_queryset(self): @@ -453,8 +446,9 @@ def get_noui_slider_points(self): if not points: return 0, 0, {} if len(points) == 1: - return points[0], points[0] + 1, { - 'min': points[0], + return points[0] - 1, points[0] + 1, { + 'min': points[0] - 1, + '50%': points[0], 'max': points[0] + 1, } @@ -506,7 +500,7 @@ def get(self, request, *args, **kwargs): return generic_message(request, 'FTS syntax error', e.args[1], status=400) def post(self, request, *args, **kwargs): - to_update = ('hide_solved', 'show_types', 'full_text') + to_update = ('hide_solved', 'show_types', 'has_public_editorial', 'full_text') for key in to_update: if key in request.GET: val = request.GET.get(key) == '1' @@ -631,7 +625,7 @@ def get_form(self, form_class=None): form_data = getattr(form, 'cleaned_data', form.initial) if 'language' in form_data: form.fields['source'].widget.mode = form_data['language'].ace - form.fields['source'].widget.theme = self.request.profile.ace_theme + form.fields['source'].widget.theme = self.request.profile.resolved_ace_theme return form @@ -644,13 +638,13 @@ def form_valid(self, form): Submission.objects.filter(user=self.request.profile, rejudged_date__isnull=True) .exclude(status__in=['D', 'IE', 'CE', 'AB']).count() >= settings.DMOJ_SUBMISSION_LIMIT ): - return HttpResponse('

You submitted too many submissions.

', status=429) + return HttpResponse(format_html('

{0}

', _('You submitted too many submissions.')), status=429) if not self.object.allowed_languages.filter(id=form.cleaned_data['language'].id).exists(): raise PermissionDenied() if not self.request.user.is_superuser and self.object.banned_users.filter(id=self.request.profile.id).exists(): return generic_message(self.request, _('Banned from submitting'), _('You have been declared persona non grata for this problem. ' - 'You are permanently barred from submitting this problem.')) + 'You are permanently barred from submitting to this problem.')) # Must check for zero and not None. None means infinite submissions remaining. if self.remaining_submission_count == 0: return generic_message(self.request, _('Too many submissions'), @@ -734,7 +728,9 @@ def post(self, request, *args, **kwargs): request.user.username, kwargs.get(self.slug_url_kwarg), ) - return HttpResponseForbidden('

You are not allowed to submit to this problem.

') + return HttpResponseForbidden( + format_html('

{0}

', _('You are not allowed to submit to this problem.')), + ) def dispatch(self, request, *args, **kwargs): submission_id = kwargs.get('submission') diff --git a/judge/views/problem_data.py b/judge/views/problem_data.py index b001e1b71..f66f4f8dc 100644 --- a/judge/views/problem_data.py +++ b/judge/views/problem_data.py @@ -37,9 +37,9 @@ def checker_args_cleaner(self): return '' try: if not isinstance(json.loads(data), dict): - raise ValidationError(_('Checker arguments must be a JSON object')) + raise ValidationError(_('Checker arguments must be a JSON object.')) except ValueError: - raise ValidationError(_('Checker arguments is invalid JSON')) + raise ValidationError(_('Checker arguments is invalid JSON.')) return data @@ -162,6 +162,7 @@ def get_context_data(self, **kwargs): if not subs: raise Submission.DoesNotExist() + subs = subs.order_by('id') context['submissions'] = subs.filter(language__file_only=False) # If we have associated data we can do better than just guess diff --git a/judge/views/ranked_submission.py b/judge/views/ranked_submission.py index 8a03955de..d2f9473fb 100644 --- a/judge/views/ranked_submission.py +++ b/judge/views/ranked_submission.py @@ -3,7 +3,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from judge.models import Language +from judge.models import Language, Submission from judge.utils.problems import get_result_data from judge.utils.raw_sql import join_sql_subquery from judge.views.submission import ForceContestMixin, ProblemSubmissions @@ -18,10 +18,9 @@ class RankedSubmissions(ProblemSubmissions): def get_queryset(self): params = [self.problem.id] if self.in_contest: - contest_join = """INNER JOIN judge_contestsubmission AS cs ON (sub.id = cs.submission_id) - INNER JOIN judge_contestparticipation AS cp ON (cs.participation_id = cp.id)""" + contest_join = 'INNER JOIN judge_contestsubmission AS cs ON (sub.id = cs.submission_id)' points = 'cs.points' - constraint = ' AND cp.contest_id = %s' + constraint = ' AND sub.contest_object_id = %s' params.append(self.contest.id) else: contest_join = '' @@ -52,11 +51,11 @@ def get_queryset(self): GROUP BY sub.user_id, {points} ) AS fastest ON (highscore.uid = fastest.uid AND highscore.points = fastest.points) STRAIGHT_JOIN judge_submission AS sub - ON (sub.user_id = fastest.uid AND sub.time = fastest.time) {contest_join} - WHERE sub.problem_id = %s AND {points} > 0 {constraint} + ON (sub.user_id = fastest.uid AND sub.time = fastest.time) + WHERE sub.problem_id = %s {constraint} GROUP BY sub.user_id """.format(points=points, contest_join=contest_join, constraint=constraint), - params=params * 3, alias='best_subs', join_fields=[('id', 'id')], + params=params * 3, alias='best_subs', join_fields=[('id', 'id')], related_model=Submission, ) if self.in_contest: diff --git a/judge/views/register.py b/judge/views/register.py index 894846a87..ee64d4bfc 100644 --- a/judge/views/register.py +++ b/judge/views/register.py @@ -8,7 +8,7 @@ from django.db import transaction from django.forms import ChoiceField, ModelChoiceField from django.shortcuts import render -from django.utils.translation import gettext, gettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _, ngettext from registration.backends.default.views import (ActivationView as OldActivationView, RegistrationView as OldRegistrationView) from registration.forms import RegistrationForm @@ -25,7 +25,7 @@ class CustomRegistrationForm(RegistrationForm): 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')}) + 'numbers, or underscores.')}) full_name = forms.CharField(max_length=30, label=_('Full name'), required=False) timezone = ChoiceField(label=_('Timezone'), choices=TIMEZONE, widget=Select2Widget(attrs={'style': 'width:100%'})) @@ -53,6 +53,15 @@ def clean_email(self): 'Please use a reputable email provider.')) return self.cleaned_data['email'] + def clean_organizations(self): + organizations = self.cleaned_data.get('organizations') or [] + max_orgs = settings.DMOJ_USER_MAX_ORGANIZATION_COUNT + if len(organizations) > max_orgs: + raise forms.ValidationError(ngettext('You may not be part of more than {count} public organization.', + 'You may not be part of more than {count} public organizations.', + max_orgs).format(count=max_orgs)) + return self.cleaned_data['organizations'] + class RegistrationView(OldRegistrationView): title = _('Register') @@ -62,9 +71,7 @@ class RegistrationView(OldRegistrationView): def get_context_data(self, **kwargs): if 'title' not in kwargs: kwargs['title'] = self.title - tzmap = settings.TIMEZONE_MAP - kwargs['TIMEZONE_MAP'] = tzmap or 'http://momentjs.com/static/img/world.png' - kwargs['TIMEZONE_BG'] = settings.TIMEZONE_BG if tzmap else '#4E7CAD' + kwargs['TIMEZONE_MAP'] = settings.TIMEZONE_MAP kwargs['password_validators'] = get_default_password_validators() kwargs['tos_url'] = settings.TERMS_OF_SERVICE_URL return super(RegistrationView, self).get_context_data(**kwargs) diff --git a/judge/views/select2.py b/judge/views/select2.py index 708ae461e..432308796 100644 --- a/judge/views/select2.py +++ b/judge/views/select2.py @@ -2,7 +2,7 @@ from django.db.models import F, Q from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404 -from django.utils.encoding import smart_text +from django.utils.encoding import smart_str from django.views.generic.list import BaseListView from judge.jinja2.gravatar import gravatar @@ -36,7 +36,7 @@ def get(self, request, *args, **kwargs): return JsonResponse({ 'results': [ { - 'text': smart_text(self.get_name(obj)), + 'text': smart_str(self.get_name(obj)), 'id': obj.pk, } for obj in context['object_list']], 'more': context['page_obj'].has_next(), @@ -57,7 +57,7 @@ def get(self, request, *args, **kwargs): return JsonResponse({ 'results': [ { - 'text': smart_text(self.get_name(obj)), + 'text': smart_str(self.get_name(obj)), 'id': obj.pk, } for obj in qs], }) @@ -83,7 +83,7 @@ def get(self, request, *args, **kwargs): return JsonResponse({ 'results': [ { - 'text': smart_text(self.get_name(obj)), + 'text': smart_str(self.get_name(obj)), 'id': obj.pk, } for obj in qs], }) @@ -123,7 +123,7 @@ def get_queryset(self): class ProblemSelect2View(Select2View): def get_queryset(self): return Problem.get_visible_problems(self.request.user) \ - .filter(Q(code__icontains=self.term) | Q(name__icontains=self.term)).distinct() + .filter(Q(code__icontains=self.term) | Q(name__icontains=self.term)) class ContestSelect2View(Select2View): @@ -141,7 +141,7 @@ class UserSearchSelect2View(BaseListView): paginate_by = 20 def get_queryset(self): - return _get_user_queryset(self.term) + return _get_user_queryset(self.term).filter(is_unlisted=False) def get(self, request, *args, **kwargs): self.request = request diff --git a/judge/views/stats.py b/judge/views/stats.py index 71b925528..c39a06b8b 100644 --- a/judge/views/stats.py +++ b/judge/views/stats.py @@ -4,7 +4,6 @@ from django.conf import settings from django.core.cache import cache from django.db.models import Count, DateField, F, FloatField, Q -from django.db.models.expressions import ExpressionWrapper from django.db.models.functions import Cast from django.http import HttpResponseForbidden, JsonResponse from django.http.response import HttpResponseBadRequest @@ -47,7 +46,7 @@ def submission_data(start_date, end_date, utc_offset): queue_time = ( # Divide by 1000000 to convert microseconds to seconds queryset.filter(judged_date__isnull=False, rejudged_date__isnull=True) - .annotate(queue_time=ExpressionWrapper((F('judged_date') - F('date')) / 1000000, output_field=FloatField())) + .annotate(queue_time=Cast(F('judged_date') - F('date'), FloatField()) / 1000000.0) .order_by('queue_time').values_list('queue_time', flat=True) ) diff --git a/judge/views/submission.py b/judge/views/submission.py index a9dcedde1..8de7796b0 100644 --- a/judge/views/submission.py +++ b/judge/views/submission.py @@ -27,6 +27,7 @@ from judge.models import Contest, Language, Organization, Problem, ProblemTranslation, Profile, Submission from judge.models.problem import ProblemTestcaseResultAccess, SubmissionSourceAccess from judge.utils.infinite_paginator import InfinitePaginationMixin +from judge.utils.lazy import memo_lazy from judge.utils.problem_data import get_problem_testcases_data from judge.utils.problems import get_result_data, user_completed_ids, user_editable_ids, user_tester_ids from judge.utils.raw_sql import join_sql_subquery, use_straight_join @@ -236,6 +237,9 @@ def group_test_cases(cases): class SubmissionStatus(SubmissionDetailBase): template_name = 'submission/status.html' + def get_queryset(self): + return super().get_queryset().select_related('contest', 'contest_object', 'contest__problem') + def get_context_data(self, **kwargs): context = super(SubmissionStatus, self).get_context_data(**kwargs) submission = self.object @@ -244,6 +248,10 @@ def get_context_data(self, **kwargs): = group_test_cases(submission.test_cases.all()) context['feedback_limit'] = min(3, test_case_count - 1) + # In case the submission is in an on-going contest, we don't want to show any feedback. + # However, this can be override by setting `submission.problem.allow_view_feedback`. + if submission.contest_object and not submission.contest_object.ended: + context['feedback_limit'] = 0 # copy from combine_statuses if not submission.is_graded and len(statuses) > 0 and statuses[-1].batch is not None: @@ -332,10 +340,11 @@ def abort_submission(request, submission): def filter_submissions_by_visible_problems(queryset, user): join_sql_subquery( queryset, - subquery=str(Problem.get_visible_problems(user).distinct().only('id').query), + subquery=str(Problem.get_visible_problems(user).only('id').query), params=[], join_fields=[('problem_id', 'id')], alias='visible_problems', + related_model=Problem, ) @@ -398,7 +407,12 @@ def _get_queryset(self): Q(contest_object__isnull=True)) if self.selected_languages: - queryset = queryset.filter(language__in=Language.objects.filter(key__in=self.selected_languages)) + # MariaDB can't optimize this subquery for some insane, unknown reason, + # so we are forcing an eager evaluation to get the IDs right here. + # Otherwise, with multiple language filters, MariaDB refuses to use an index + # (or runs the subquery for every submission, which is even more horrifying to think about). + queryset = queryset.filter(language__in=list( + Language.objects.filter(key__in=self.selected_languages).values_list('id', flat=True))) if self.selected_statuses: queryset = queryset.filter(Q(result__in=self.selected_statuses) | Q(status__in=self.selected_statuses)) if self.selected_organization: @@ -437,9 +451,11 @@ def get_context_data(self, **kwargs): context['dynamic_update'] = False context['dynamic_contest_id'] = self.in_contest and self.contest.id context['show_problem'] = self.show_problem - context['completed_problem_ids'] = user_completed_ids(self.request.profile) if authenticated else [] - context['editable_problem_ids'] = user_editable_ids(self.request.profile) if authenticated else [] - context['tester_problem_ids'] = user_tester_ids(self.request.profile) if authenticated else [] + + profile = self.request.profile + context['completed_problem_ids'] = memo_lazy(lambda: user_completed_ids(profile), set) if authenticated else [] + context['editable_problem_ids'] = memo_lazy(lambda: user_editable_ids(profile), set) if authenticated else [] + context['tester_problem_ids'] = memo_lazy(lambda: user_tester_ids(profile), set) if authenticated else [] context['all_languages'] = Language.objects.all().values_list('key', 'name') context['selected_languages'] = self.selected_languages @@ -577,7 +593,7 @@ def access_check(self, request): def get(self, request, *args, **kwargs): if 'problem' not in kwargs: - raise ImproperlyConfigured(_('Must pass a problem')) + raise ImproperlyConfigured('Must pass a problem') self.problem = get_object_or_404(Problem, code=kwargs['problem']) self.problem_name = self.problem.translated_name(self.request.LANGUAGE_CODE) return super(ProblemSubmissionsBase, self).get(request, *args, **kwargs) @@ -732,7 +748,7 @@ def get_problem_label(self, problem): def get(self, request, *args, **kwargs): if 'contest' not in kwargs: - raise ImproperlyConfigured(_('Must pass a contest')) + raise ImproperlyConfigured('Must pass a contest') self._contest = get_object_or_404(Contest, key=kwargs['contest']) return super(ForceContestMixin, self).get(request, *args, **kwargs) diff --git a/judge/views/two_factor.py b/judge/views/two_factor.py index a98021fdb..5c6235401 100644 --- a/judge/views/two_factor.py +++ b/judge/views/two_factor.py @@ -14,6 +14,7 @@ from django.utils.http import is_safe_url from django.utils.translation import gettext as _, gettext_lazy from django.views.generic import FormView, View +from django.views.generic.base import ContextMixin from django.views.generic.detail import SingleObjectMixin from judge.forms import TOTPEnableForm, TOTPForm, TwoFactorLoginForm @@ -225,10 +226,11 @@ def post(self, request, *args, **kwargs): return HttpResponse() -class TwoFactorLoginView(SuccessURLAllowedHostsMixin, TOTPView): +class TwoFactorLoginView(SuccessURLAllowedHostsMixin, TOTPView, ContextMixin): form_class = TwoFactorLoginForm title = gettext_lazy('Perform Two-factor Authentication') template_name = 'registration/two_factor_auth.html' + extra_context = {'tfa_in_progress': True} def get_form_kwargs(self): result = super().get_form_kwargs() diff --git a/judge/views/user.py b/judge/views/user.py index ff20ccb26..49e3a69ad 100644 --- a/judge/views/user.py +++ b/judge/views/user.py @@ -13,10 +13,9 @@ from django.contrib.auth.models import Permission, User from django.contrib.auth.views import LoginView, PasswordChangeView, PasswordResetView, redirect_to_login from django.contrib.contenttypes.models import ContentType -from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured, PermissionDenied, ValidationError -from django.db.models import Count, F, Max, Min, Prefetch +from django.db.models import Count, F, FilteredRelation, Max, Min, Prefetch, Q from django.db.models.expressions import Value from django.db.models.fields import DateField from django.db.models.functions import Cast, Coalesce, ExtractYear @@ -24,6 +23,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.safestring import mark_safe @@ -34,16 +34,16 @@ from judge.forms import CustomAuthenticationForm, ProfileForm, UserBanForm, UserDownloadDataForm, UserForm, \ newsletter_id -from judge.models import BlogPost, BlogVote, Organization, Profile, Rating, Submission +from judge.models import BlogPost, Organization, Profile, Submission +from judge.models import Comment from judge.performance_points import get_pp_breakdown from judge.ratings import rating_class, rating_progress from judge.tasks import prepare_user_data -from judge.template_context import MiscConfigDict from judge.utils.celery import task_status_by_id, task_status_url_by_id +from judge.utils.infinite_paginator import InfinitePaginationMixin from judge.utils.problems import contest_completed_ids, user_completed_ids from judge.utils.pwned import PwnedPasswordsValidator from judge.utils.ranker import ranker -from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join from judge.utils.subscription import Subscription from judge.utils.unicode import utf8text from judge.utils.views import DiggPaginatorMixin, QueryStringSortMixin, SingleObjectFormView, TitleMixin, \ @@ -51,7 +51,7 @@ from judge.views.blog import PostListBase from .contests import ContestRanking -__all__ = ['UserPage', 'UserAboutPage', 'UserProblemsPage', 'UserDownloadData', 'UserPrepareData', +__all__ = ['UserPage', 'UserAboutPage', 'UserProblemsPage', 'UserCommentPage', 'UserDownloadData', 'UserPrepareData', 'users', 'edit_profile'] @@ -130,7 +130,7 @@ def get_context_data(self, **kwargs): context['hide_solved'] = int(self.hide_solved) context['authored'] = self.object.authored_problems.filter(is_public=True, is_organization_private=False) \ - .order_by('code') + .select_related('group').order_by('code') rating = self.object.ratings.order_by('-contest__end_time')[:1] context['rating'] = rating[0] if rating else None @@ -199,16 +199,6 @@ def get_context_data(self, **kwargs): 'height': '%.3fem' % rating_progress(rating.rating), } for rating in ratings])) - if ratings: - user_data = self.object.ratings.aggregate(Min('rating'), Max('rating')) - global_data = Rating.objects.aggregate(Min('rating'), Max('rating')) - min_ever, max_ever = global_data['rating__min'], global_data['rating__max'] - min_user, max_user = user_data['rating__min'], user_data['rating__max'] - delta = max_user - min_user - ratio = (max_ever - max_user) / (max_ever - min_ever) if max_ever != min_ever else 1.0 - context['max_graph'] = max_user + ratio * delta - context['min_graph'] = min_user + ratio * delta - delta - user_timezone = settings.DEFAULT_USER_TIME_ZONE if self.request is not None and self.request.profile is not None: user_timezone = user_timezone or self.request.profile.timezone @@ -259,17 +249,64 @@ class UserBlogPage(CustomUserMixin, PostListBase): def get_queryset(self): queryset = BlogPost.objects.filter(authors=self.user, organization=None) - if self.request.user != self.user.user: + if self.request.user != self.user.user and not self.request.user.is_superuser: queryset = queryset.filter(visible=True, publish_on__lte=timezone.now()) if self.request.user.is_authenticated: - queryset = queryset.annotate(vote_score=Coalesce(RawSQLColumn(BlogVote, 'score'), Value(0))) profile = self.request.profile - unique_together_left_join(queryset, BlogVote, 'blog', 'voter', profile.id) + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) return queryset.order_by('-sticky', '-publish_on').prefetch_related('authors__user') +class UserCommentPage(CustomUserMixin, DiggPaginatorMixin, ListView): + template_name = 'user/comment.html' + model = Comment + paginate_by = 10 + context_object_name = 'comments' + title = None + + def get_queryset(self): + return Comment.get_newest_visible_comments(viewer=self.request.user, + author=self.user, + batch=2 * self.paginate_by) + + def get_context_data(self, **kwargs): + context = super(UserCommentPage, self).get_context_data(**kwargs) + context['first_page_href'] = None + context['title'] = self.title or _('Page %d of Comments') % context['page_obj'].number + context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD + + if self.request.user.is_authenticated: + context['is_new_user'] = self.request.profile.is_new_user + context['interact_min_problem_count_msg'] = \ + _('You need to have solved at least %d problems before your voice can be heard.') \ + % settings.VNOJ_INTERACT_MIN_PROBLEM_COUNT + + return context + + @method_decorator(require_POST) + def delete_comments(self, request, *args, **kwargs): + if not request.user.is_superuser: + raise PermissionDenied() + + user_id = User.objects.get(username=kwargs['user']).id + user = Profile.objects.get(user=user_id) + for comment in Comment.get_newest_visible_comments(viewer=request.user, author=user, + batch=2 * self.paginate_by): + comment.get_descendants(include_self=True).update(hidden=True) + return HttpResponseRedirect(reverse('user_comment', args=(user.user.username,))) + + def dispatch(self, request, *args, **kwargs): + if not self.request.user.is_superuser: + raise PermissionDenied() + if request.method == 'POST': + return self.delete_comments(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) + + class UserProblemsPage(UserPage): template_name = 'user/user-problems.html' @@ -423,7 +460,7 @@ def get(self, request, *args, **kwargs): @login_required def edit_profile(request): if request.profile.mute: - raise Http404() + return generic_message(request, _("Can't edit profile"), _('Your part is silent, little toad.'), status=403) if request.method == 'POST': form = ProfileForm(request.POST, instance=request.profile, user=request.user) form_user = UserForm(request.POST, instance=request.user) @@ -463,15 +500,13 @@ def edit_profile(request): form.fields['newsletter'].initial = subscription.subscribed form.fields['test_site'].initial = request.user.has_perm('judge.test_site') - tzmap = settings.TIMEZONE_MAP return render(request, 'user/edit-profile.html', { 'require_staff_2fa': settings.DMOJ_REQUIRE_STAFF_2FA, 'form_user': form_user, 'form': form, 'title': _('Edit profile'), 'profile': request.profile, 'can_download_data': bool(settings.DMOJ_USER_DATA_DOWNLOAD), 'has_math_config': bool(settings.MATHOID_URL), 'ignore_user_script': True, - 'TIMEZONE_MAP': tzmap or 'http://momentjs.com/static/img/world.png', - 'TIMEZONE_BG': settings.TIMEZONE_BG if tzmap else '#4E7CAD', + 'TIMEZONE_MAP': settings.TIMEZONE_MAP, }) @@ -507,7 +542,7 @@ def generate_scratch_codes(request): return JsonResponse({'data': {'codes': profile.generate_scratch_codes()}}) -class UserList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ListView): +class UserList(QueryStringSortMixin, InfinitePaginationMixin, DiggPaginatorMixin, TitleMixin, ListView): model = Profile title = gettext_lazy('Leaderboard') context_object_name = 'users' @@ -515,10 +550,10 @@ class UserList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ListView): paginate_by = 100 all_sorts = frozenset(('points', 'problem_count', 'rating', 'performance_points')) default_desc = all_sorts - default_sort = '-performance_points' + default_sort = '-rating' def get_queryset(self): - return (Profile.objects.filter(is_unlisted=False).order_by(self.order) + return (Profile.objects.filter(is_unlisted=False).order_by(self.order, 'id') .prefetch_related(Prefetch('user', queryset=User.objects.only('username', 'first_name'))) .prefetch_related(Prefetch('organizations', queryset=Organization.objects.filter(is_unlisted=False).only('name', 'id', 'slug'))) @@ -553,7 +588,7 @@ class ContribList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ListView default_sort = '-contribution_points' def get_queryset(self): - return (Profile.objects.filter(is_unlisted=False).order_by(self.order) + return (Profile.objects.filter(is_unlisted=False).order_by(self.order, 'id') .prefetch_related(Prefetch('user', queryset=User.objects.only('username', 'first_name'))) .prefetch_related(Prefetch('organizations', queryset=Organization.objects.filter(is_unlisted=False).only('name', 'id', 'slug'))) @@ -594,9 +629,13 @@ def user_ranking_redirect(request): except KeyError: raise Http404() user = get_object_or_404(Profile, user__username=username) - rank = Profile.objects.filter(is_unlisted=False, performance_points__gt=user.performance_points).count() + # Assume using MySQL. NULL is considered smaller than any non-NULL value. + if user.rating is None: + rank = Profile.objects.filter(is_unlisted=False, rating__isnull=False).count() + else: + rank = Profile.objects.filter(is_unlisted=False, rating__gt=user.rating).count() rank += Profile.objects.filter( - is_unlisted=False, performance_points__exact=user.performance_points, id__lt=user.id, + is_unlisted=False, rating__exact=user.rating, id__lt=user.id, ).count() page = rank // UserList.paginate_by return HttpResponseRedirect('%s%s#!%s' % (reverse('user_list'), '?page=%d' % (page + 1) if page else '', username)) @@ -627,18 +666,21 @@ def post(self, request, *args, **kwargs): class CustomPasswordResetView(PasswordResetView): + title = gettext_lazy('Password reset') from_email = settings.SERVER_EMAIL + template_name = 'registration/password_reset.html' + html_email_template_name = 'registration/password_reset_email.html' + email_template_name = 'registration/password_reset_email.txt' def post(self, request, *args, **kwargs): key = f'pwreset!{request.META["REMOTE_ADDR"]}' cache.add(key, 0, timeout=settings.DMOJ_PASSWORD_RESET_LIMIT_WINDOW) if cache.incr(key) > settings.DMOJ_PASSWORD_RESET_LIMIT_COUNT: - return HttpResponse('You sent in too many password reset requests. Please try again later.', + return HttpResponse(_('You have sent too many password reset requests. Please try again later.'), content_type='text/plain', status=429) - domain = get_current_site(request).domain self.extra_email_context = { - 'misc_config': MiscConfigDict(domain=domain), + 'misc_config': request.misc_config, } return super().post(request, *args, **kwargs) diff --git a/judge/views/widgets.py b/judge/views/widgets.py index 992e094c8..bcaa2f75e 100755 --- a/judge/views/widgets.py +++ b/judge/views/widgets.py @@ -3,21 +3,17 @@ import uuid from urllib.parse import urljoin -import requests from django.conf import settings from django.contrib.auth.decorators import login_required -from django.core.exceptions import ImproperlyConfigured from django.core.files.storage import default_storage -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect -from django.utils.translation import gettext as _ +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \ + HttpResponseRedirect from django.views.decorators.http import require_POST -from django.views.generic import View from martor.api import imgur_uploader from judge.models import Submission -from judge.utils.views import generic_message -__all__ = ['rejudge_submission', 'DetectTimezone'] +__all__ = ['rejudge_submission'] @login_required @@ -41,42 +37,6 @@ def rejudge_submission(request): return HttpResponseRedirect(redirect) if redirect else HttpResponse('success', content_type='text/plain') -class DetectTimezone(View): - def askgeo(self, lat, long): - if not hasattr(settings, 'ASKGEO_ACCOUNT_ID') or not hasattr(settings, 'ASKGEO_ACCOUNT_API_KEY'): - raise ImproperlyConfigured() - data = requests.get('http://api.askgeo.com/v1/%s/%s/query.json?databases=TimeZone&points=%f,%f' % - (settings.ASKGEO_ACCOUNT_ID, settings.ASKGEO_ACCOUNT_API_KEY, lat, long)).json() - try: - return HttpResponse(data['data'][0]['TimeZone']['TimeZoneId'], content_type='text/plain') - except (IndexError, KeyError): - return HttpResponse(_('Invalid upstream data: %s') % data, content_type='text/plain', status=500) - - def geonames(self, lat, long): - if not hasattr(settings, 'GEONAMES_USERNAME'): - raise ImproperlyConfigured() - data = requests.get('http://api.geonames.org/timezoneJSON?lat=%f&lng=%f&username=%s' % - (lat, long, settings.GEONAMES_USERNAME)).json() - try: - return HttpResponse(data['timezoneId'], content_type='text/plain') - except KeyError: - return HttpResponse(_('Invalid upstream data: %s') % data, content_type='text/plain', status=500) - - def default(self, lat, long): - raise Http404() - - def get(self, request, *args, **kwargs): - backend = settings.TIMEZONE_DETECT_BACKEND - try: - lat, long = float(request.GET['lat']), float(request.GET['long']) - except (ValueError, KeyError): - return HttpResponse(_('Bad latitude or longitude'), content_type='text/plain', status=404) - return { - 'askgeo': self.askgeo, - 'geonames': self.geonames, - }.get(backend, self.default)(lat, long) - - def django_uploader(image): ext = os.path.splitext(image.name)[1] if ext not in settings.MARTOR_UPLOAD_SAFE_EXTS: @@ -128,11 +88,8 @@ def martor_image_uploader(request): return HttpResponse(data, content_type='application/json') -def csrf_failure(request, reason=''): - title = _('CSRF verification failed') - message = _('This error should not happend in normal operation. ' - 'Mostly this is because we are under a DDOS attack and we need to raise ' - 'our shield to protect the site from the attack.\n\n' - 'If you see this error, please return to the homepage and try again.' - 'DO NOT hit F5/reload/refresh page, it will cause this error again.') - return generic_message(request, title, message) +def csrf_failure(request: HttpRequest, reason=''): + # Redirect to the same page in case of CSRF failure + # So that we can turn on cloudflare DDOS protection without + # showing the CSRF failure page to user + return HttpResponseRedirect(request.path) diff --git a/judge/widgets/pagedown.py b/judge/widgets/pagedown.py index 01bb12a82..dc0735921 100644 --- a/judge/widgets/pagedown.py +++ b/judge/widgets/pagedown.py @@ -1,4 +1,3 @@ -from django.contrib.admin import widgets as admin_widgets from django.forms.utils import flatatt from django.template.loader import get_template from django.utils.encoding import force_str @@ -6,41 +5,27 @@ from judge.widgets.mixins import CompressorWidgetMixin -__all__ = ['PagedownWidget', 'AdminPagedownWidget', - 'MathJaxPagedownWidget', 'MathJaxAdminPagedownWidget', - 'HeavyPreviewPageDownWidget', 'HeavyPreviewAdminPageDownWidget'] +__all__ = ['PagedownWidget', 'MathJaxPagedownWidget', 'HeavyPreviewPageDownWidget'] try: from pagedown.widgets import PagedownWidget as OldPagedownWidget except ImportError: PagedownWidget = None - AdminPagedownWidget = None MathJaxPagedownWidget = None - MathJaxAdminPagedownWidget = None HeavyPreviewPageDownWidget = None - HeavyPreviewAdminPageDownWidget = None else: class PagedownWidget(CompressorWidgetMixin, OldPagedownWidget): # The goal here is to compress all the pagedown JS into one file. # We do not want any further compress down the chain, because - # 1. we'll creating multiple large JS files to download. + # 1. we'll create multiple large JS files to download. # 2. this is not a problem here because all the pagedown JS files will be used together. compress_js = True def __init__(self, *args, **kwargs): - kwargs.setdefault('css', ('pagedown_widget.css',)) + kwargs.setdefault('css', ()) super(PagedownWidget, self).__init__(*args, **kwargs) - class AdminPagedownWidget(PagedownWidget, admin_widgets.AdminTextareaWidget): - class Media: - css = {'all': [ - 'content-description.css', - 'admin/css/pagedown.css', - ]} - js = ['admin/js/pagedown.js'] - - class MathJaxPagedownWidget(PagedownWidget): class Media: js = [ @@ -50,10 +35,6 @@ class Media: ] - class MathJaxAdminPagedownWidget(AdminPagedownWidget, MathJaxPagedownWidget): - pass - - class HeavyPreviewPageDownWidget(PagedownWidget): def __init__(self, *args, **kwargs): kwargs.setdefault('template', 'pagedown.html') @@ -83,14 +64,4 @@ def get_template_context(self, attrs, value): } class Media: - css = {'all': ['dmmd-preview.css']} js = ['dmmd-preview.js'] - - - class HeavyPreviewAdminPageDownWidget(AdminPagedownWidget, HeavyPreviewPageDownWidget): - class Media: - css = {'all': [ - 'pygment-github.css', - 'table.css', - 'ranks.css', - ]} diff --git a/judge/widgets/select2.py b/judge/widgets/select2.py index 4b2e64f4b..8ae065985 100644 --- a/judge/widgets/select2.py +++ b/judge/widgets/select2.py @@ -46,9 +46,6 @@ from django.forms.models import ModelChoiceIterator from django.urls import reverse_lazy -DEFAULT_SELECT2_JS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js' -DEFAULT_SELECT2_CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css' - __all__ = ['Select2Widget', 'Select2MultipleWidget', 'Select2TagWidget', 'HeavySelect2Widget', 'HeavySelect2MultipleWidget', 'HeavySelect2TagWidget', 'AdminSelect2Widget', 'AdminSelect2MultipleWidget', 'AdminHeavySelect2Widget', @@ -67,6 +64,7 @@ class Select2Mixin(object): def build_attrs(self, base_attrs, extra_attrs=None): """Add select2 data attributes.""" attrs = super(Select2Mixin, self).build_attrs(base_attrs, extra_attrs) + attrs.setdefault('data-theme', settings.DMOJ_SELECT2_THEME) if self.is_required: attrs.setdefault('data-allow-clear', 'false') else: @@ -105,7 +103,7 @@ class AdminSelect2Mixin(Select2Mixin): def media(self): return forms.Media( js=['admin/js/jquery.init.js', settings.SELECT2_JS_URL, 'django_select2.js'], - css={'screen': [settings.SELECT2_CSS_URL]}, + css={'screen': [settings.SELECT2_CSS_URL, 'select2-dmoj.css']}, ) @@ -217,7 +215,8 @@ def format_value(self, value): chosen.queryset = chosen.queryset.filter(pk__in=[ int(i) for i in result if isinstance(i, int) or i.isdigit() ]) - self.choices = set(chosen) + # https://code.djangoproject.com/ticket/33155 + self.choices = {(value if isinstance(value, str) else value.value, label) for value, label in chosen} return result diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index a66b43ee3..b8c079d76 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -508,9 +508,10 @@ msgstr "" msgid "Your part is silent, little toad." msgstr "" -#: judge/comments.py:50 templates/comments/list.html:147 -msgid "" -"You need to have solved at least one problem before your voice can be heard." +#: judge/comments.py:50 judge/view/user.py:294 +#, python-format +msgid "You need to have solved at least %d problems " +"before your voice can be heard." msgstr "" #: judge/comments.py:94 @@ -743,7 +744,8 @@ msgid "Enable experimental features" msgstr "" #: judge/forms.py:85 -msgid "You must solve at least one problem before you can update your profile." +#, python-format +msgid "You must solve at least %d problems before you can update your profile." msgstr "" #: judge/forms.py:94 judge/views/organization.py:131 @@ -3336,7 +3338,8 @@ msgid "Messing around, are we?" msgstr "" #: judge/views/blog.py:43 judge/views/comment.py:35 -msgid "You must solve at least one problem before you can vote." +#, python-format +msgid "You must solve at least %d problems before you can vote." msgstr "" #: judge/views/blog.py:58 diff --git a/locale/vi/LC_MESSAGES/django.po b/locale/vi/LC_MESSAGES/django.po index 5055879a3..4c71239c9 100644 --- a/locale/vi/LC_MESSAGES/django.po +++ b/locale/vi/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: dmoj\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-06-26 15:34+0000\n" +"POT-Creation-Date: 2023-02-01 05:19+0000\n" "PO-Revision-Date: 2020-08-23 18:59\n" "Last-Translator: \n" "Language-Team: Vietnamese\n" @@ -37,11 +37,11 @@ msgstr "" msgid "Banned User" msgstr "" -#: dmoj/settings.py:74 templates/base.html:281 templates/comments/list.html:108 -#: templates/contest/contest-list-tabs.html:35 -#: templates/contest/ranking-table.html:54 +#: dmoj/settings.py:74 templates/base.html:273 templates/comments/list.html:102 +#: templates/contest/contest-list-tabs.html:34 +#: templates/contest/ranking-table.html:27 #: templates/problem/problem-list-tabs.html:15 -#: templates/submission/info-base.html:13 +#: templates/submission/info-base.html:15 #: templates/submission/submission-list-tabs.html:15 #: templates/tag/problem.html:83 templates/tag/tag-list-tabs.html:9 #: templates/ticket/message.html:18 @@ -52,185 +52,197 @@ msgstr "Quản trị" msgid "Teacher" msgstr "" -#: dmoj/settings.py:502 +#: dmoj/settings.py:509 msgid "English" msgstr "Tiếng Anh" -#: dmoj/settings.py:503 +#: dmoj/settings.py:510 msgid "Vietnamese" msgstr "Tiếng Việt" -#: dmoj/urls.py:38 +#: dmoj/urls.py:39 msgid "Activation Successful!" msgstr "Kích hoạt thành công" -#: dmoj/urls.py:46 +#: dmoj/urls.py:47 msgid "Registration Completed" msgstr "Đăng ký thành công" -#: dmoj/urls.py:50 +#: dmoj/urls.py:51 msgid "Registration Not Allowed" msgstr "Không được phép đăng ký" -#: dmoj/urls.py:57 +#: dmoj/urls.py:58 msgid "Password change successful" msgstr "Đổi mật khẩu thành công" -#: dmoj/urls.py:63 -msgid "Password reset" -msgstr "Đặt lại mật khẩu" - -#: dmoj/urls.py:68 +#: dmoj/urls.py:64 msgid "Enter new password" msgstr "Nhập mật khẩu mới" -#: dmoj/urls.py:72 +#: dmoj/urls.py:68 msgid "Password reset complete" msgstr "Đặt lại mật khẩu thành công" -#: dmoj/urls.py:76 +#: dmoj/urls.py:72 msgid "Password reset sent" msgstr "Đã gửi email đặt lại mật khẩu" -#: dmoj/urls.py:108 templates/base.html:236 templates/organization/tabs.html:14 +#: dmoj/urls.py:104 templates/base.html:228 templates/organization/tabs.html:14 msgid "Home" msgstr "Trang chủ" -#: judge/admin/comments.py:40 +#: judge/admin/comments.py:23 judge/admin/interface.py:76 +msgid "Content" +msgstr "Nội dung" + +#: judge/admin/comments.py:41 #, python-format msgid "%d comment successfully hidden." msgid_plural "%d comments successfully hidden." msgstr[0] "%d bình luận đã được ẩn." -#: judge/admin/comments.py:43 +#: judge/admin/comments.py:44 msgid "Hide comments" msgstr "Ẩn bình luận" -#: judge/admin/comments.py:47 +#: judge/admin/comments.py:49 #, python-format msgid "%d comment successfully unhidden." msgid_plural "%d comments successfully unhidden." msgstr[0] "%d bình luận đã được hiện lại." -#: judge/admin/comments.py:50 +#: judge/admin/comments.py:52 msgid "Unhide comments" msgstr "Bỏ ẩn bình luận" -#: judge/admin/comments.py:58 +#: judge/admin/comments.py:60 msgid "Associated page" msgstr "Trang liên kết" -#: judge/admin/contest.py:32 +#: judge/admin/contest.py:31 msgid "Included contests" msgstr "Các kỳ thi" -#: judge/admin/contest.py:66 judge/forms.py:574 -#: templates/contest/contest.html:201 templates/contest/contest.html:308 -#: templates/contest/moss.html:42 templates/problem/list.html:177 -#: templates/user/user-problems.html:58 templates/user/user-problems.html:100 +#: judge/admin/contest.py:65 judge/forms.py:582 +#: templates/contest/contest.html:201 templates/contest/contest.html:310 +#: templates/contest/moss.html:40 templates/problem/list.html:171 +#: templates/user/user-problems.html:58 templates/user/user-problems.html:102 msgid "Problem" msgstr "Bài" -#: judge/admin/contest.py:146 +#: judge/admin/contest.py:66 templates/contest/contest.html:190 +#: templates/contest/edit.html:108 templates/contest/edit.html:115 +msgid "Problems" +msgstr "Bài" + +#: judge/admin/contest.py:76 judge/admin/submission.py:249 +#: templates/admin/judge/submission/change_form.html:14 +#: templates/admin/judge/submission/change_form.html:17 +#: templates/submission/source.html:76 templates/submission/status.html:92 +msgid "Rejudge" +msgstr "Chấm lại" + +#: judge/admin/contest.py:145 msgid "Settings" msgstr "Cài đặt" -#: judge/admin/contest.py:151 +#: judge/admin/contest.py:150 msgid "Scheduling" msgstr "Lịch" -#: judge/admin/contest.py:152 +#: judge/admin/contest.py:151 msgid "Details" msgstr "Chi tiết" -#: judge/admin/contest.py:153 +#: judge/admin/contest.py:152 msgid "Format" msgstr "" -#: judge/admin/contest.py:154 templates/contest/ranking-table.html:8 +#: judge/admin/contest.py:153 templates/contest/ranking-table.html:69 msgid "Rating" msgstr "" -#: judge/admin/contest.py:155 +#: judge/admin/contest.py:154 msgid "Access" msgstr "Truy cập" -#: judge/admin/contest.py:157 judge/admin/problem.py:138 +#: judge/admin/contest.py:156 judge/admin/problem.py:138 msgid "Justice" msgstr "Công lý" -#: judge/admin/contest.py:158 +#: judge/admin/contest.py:157 msgid "Ranking" msgstr "Bảng xếp hạng" -#: judge/admin/contest.py:249 +#: judge/admin/contest.py:248 #, python-format msgid "%d contest successfully marked as visible." msgid_plural "%d contests successfully marked as visible." msgstr[0] "%d kỳ thi đã được đánh dấu là có thể thấy." -#: judge/admin/contest.py:252 +#: judge/admin/contest.py:251 msgid "Mark contests as visible" msgstr "Đánh dấu các kỳ thi là có thể thấy" -#: judge/admin/contest.py:258 +#: judge/admin/contest.py:257 #, python-format msgid "%d contest successfully marked as hidden." msgid_plural "%d contests successfully marked as hidden." msgstr[0] "%d kỳ thi đã được đánh dấu là ẩn." -#: judge/admin/contest.py:261 +#: judge/admin/contest.py:260 msgid "Mark contests as hidden" msgstr "Đánh dấu các kỳ thi là ẩn" -#: judge/admin/contest.py:267 +#: judge/admin/contest.py:266 #, python-format msgid "%d contest successfully locked." msgid_plural "%d contests successfully locked." msgstr[0] "" -#: judge/admin/contest.py:270 +#: judge/admin/contest.py:269 msgid "Lock contest submissions" msgstr "" -#: judge/admin/contest.py:276 +#: judge/admin/contest.py:275 #, python-format msgid "%d contest successfully unlocked." msgid_plural "%d contests successfully unlocked." msgstr[0] "" -#: judge/admin/contest.py:279 +#: judge/admin/contest.py:278 msgid "Unlock contest submissions" msgstr "" -#: judge/admin/contest.py:302 judge/admin/submission.py:175 +#: judge/admin/contest.py:301 judge/admin/submission.py:175 #, python-format msgid "%d submission was successfully scheduled for rejudging." msgid_plural "%d submissions were successfully scheduled for rejudging." msgstr[0] "%d bài nộp đã được lên lịch để chấm lại." -#: judge/admin/contest.py:312 judge/admin/submission.py:204 +#: judge/admin/contest.py:311 judge/admin/submission.py:204 #: judge/views/problem_manage.py:131 #, python-format msgid "%d submission was successfully rescored." msgid_plural "%d submissions were successfully rescored." msgstr[0] "%d bài đã được tính điểm lại." -#: judge/admin/contest.py:392 +#: judge/admin/contest.py:391 #, python-format msgid "%d participation recalculated." msgid_plural "%d participations recalculated." msgstr[0] "%d lượt tham gia đã được tính lại." -#: judge/admin/contest.py:395 +#: judge/admin/contest.py:394 msgid "Recalculate results" msgstr "Tính lại kết quả" -#: judge/admin/contest.py:399 judge/admin/organization.py:74 +#: judge/admin/contest.py:398 judge/admin/organization.py:74 msgid "username" msgstr "tên người dùng" -#: judge/admin/contest.py:404 templates/base.html:320 +#: judge/admin/contest.py:403 templates/base.html:312 msgid "virtual" msgstr "ảo" @@ -238,17 +250,13 @@ msgstr "ảo" msgid "link path" msgstr "đường dẫn liên kết" -#: judge/admin/interface.py:76 -msgid "Content" -msgstr "Nội dung" - #: judge/admin/interface.py:77 msgid "Summary" msgstr "Tóm tắt" -#: judge/admin/interface.py:117 judge/models/contest.py:524 -#: judge/models/contest.py:664 judge/models/profile.py:375 -#: judge/models/profile.py:406 +#: judge/admin/interface.py:117 judge/models/contest.py:531 +#: judge/models/contest.py:671 judge/models/profile.py:380 +#: judge/models/profile.py:411 judge/models/ticket.py:39 msgid "user" msgstr "thành viên" @@ -256,7 +264,7 @@ msgstr "thành viên" msgid "object" msgstr "đối tượng" -#: judge/admin/organization.py:33 judge/admin/problem.py:185 +#: judge/admin/organization.py:33 judge/admin/problem.py:182 #: judge/admin/profile.py:96 msgid "View on site" msgstr "Xem trên trang web" @@ -286,8 +294,8 @@ msgstr "Phân loại" #: judge/admin/problem.py:135 templates/blog/top-pp.html:9 #: templates/contest/contest.html:203 templates/contest/edit.html:116 #: templates/contest/official-ranking-table.html:11 -#: templates/organization/list.html:24 templates/problem/data.html:671 -#: templates/problem/list.html:188 templates/user/base-users-table.html:11 +#: templates/organization/list.html:24 templates/problem/data.html:674 +#: templates/problem/list.html:182 templates/user/base-users-table.html:10 #: templates/user/user-problems.html:60 msgid "Points" msgstr "Điểm" @@ -297,8 +305,8 @@ msgid "Limits" msgstr "Giới hạn" #: judge/admin/problem.py:137 judge/admin/submission.py:243 -#: templates/problem/editor.html:112 templates/submission/list.html:326 -#: templates/user/edit-profile.html:315 +#: templates/problem/editor.html:112 templates/problem/submission-diff.html:114 +#: templates/submission/list.html:324 msgid "Language" msgstr "Ngôn ngữ" @@ -306,37 +314,27 @@ msgstr "Ngôn ngữ" msgid "History" msgstr "Lịch sử" -#: judge/admin/problem.py:182 +#: judge/admin/problem.py:179 msgid "Authors" msgstr "Tác giả" #: judge/admin/problem.py:195 #, python-format -msgid "%d problem's publish date successfully updated." -msgid_plural "%d problems' publish date successfully updated." -msgstr[0] "" - -#: judge/admin/problem.py:199 -msgid "Set publish date to now" -msgstr "" - -#: judge/admin/problem.py:205 -#, python-format msgid "%d problem successfully marked as public." msgid_plural "%d problems successfully marked as public." msgstr[0] "%d bài tập đã được công bố." -#: judge/admin/problem.py:209 -msgid "Mark problems as public" -msgstr "Công bố bài" +#: judge/admin/problem.py:199 +msgid "Mark problems as public and set publish date to now" +msgstr "Công bố bài và đặt ngày công bố là bây giờ" -#: judge/admin/problem.py:215 +#: judge/admin/problem.py:205 #, python-format msgid "%d problem successfully marked as private." msgid_plural "%d problems successfully marked as private." msgstr[0] "Đã ẩn %d bài." -#: judge/admin/problem.py:219 +#: judge/admin/problem.py:209 msgid "Mark problems as private" msgstr "Ẩn bài" @@ -347,17 +345,16 @@ msgstr "múi giờ" #: judge/admin/profile.py:102 judge/admin/submission.py:222 #: templates/organization/requests/log.html:9 #: templates/organization/requests/pending.html:12 -#: templates/ticket/list.html:263 +#: templates/ticket/list.html:198 msgid "User" msgstr "Thành viên" -#: judge/admin/profile.py:107 templates/registration/registration_form.html:150 +#: judge/admin/profile.py:107 templates/registration/registration_form.html:156 msgid "Email" msgstr "Địa chỉ email" #: judge/admin/profile.py:112 judge/views/register.py:30 -#: templates/registration/registration_form.html:178 -#: templates/user/edit-profile.html:311 +#: templates/registration/registration_form.html:184 msgid "Timezone" msgstr "Múi giờ" @@ -367,8 +364,8 @@ msgstr "ngày tham gia" #: judge/admin/profile.py:124 #, python-format -msgid "%d user has scores recalculated." -msgid_plural "%d users have scores recalculated." +msgid "%d user had scores recalculated." +msgid_plural "%d users had scores recalculated." msgstr[0] "Đã tính lại điểm của %d người dùng." #: judge/admin/profile.py:134 @@ -381,8 +378,13 @@ msgstr[0] "Đã tính lại điểm đóng góp của %d người dùng." msgid "Recalulate contribution points" msgstr "Tính lại điểm đóng góp" -#: judge/admin/runtime.py:72 templates/contest/contest.html:275 -#: templates/contest/contest.html:309 +#: judge/admin/runtime.py:58 templates/user/edit-profile.html:124 +#: templates/user/edit-profile.html:246 templates/user/edit-profile.html:372 +msgid "Regenerate" +msgstr "Tạo lại" + +#: judge/admin/runtime.py:72 templates/contest/contest.html:277 +#: templates/contest/contest.html:311 msgid "Description" msgstr "Mô tả" @@ -443,11 +445,11 @@ msgstr "Chấm lại các bài đã chọn" msgid "Rescore the selected submissions" msgstr "Tính điểm lại các bài đã chọn" -#: judge/admin/submission.py:211 templates/tag/list.html:114 +#: judge/admin/submission.py:211 templates/tag/list.html:112 msgid "Problem code" msgstr "Mã bài tập" -#: judge/admin/submission.py:216 templates/tag/list.html:115 +#: judge/admin/submission.py:216 templates/tag/list.html:113 msgid "Problem name" msgstr "Tên bài" @@ -470,6 +472,10 @@ msgstr "%.2f MB" msgid "Memory" msgstr "Bộ nhớ" +#: judge/admin/submission.py:247 +msgid "Locked" +msgstr "Đã khóa" + #: judge/admin/tag.py:82 judge/admin/tag.py:83 msgid "Tag Data" msgstr "" @@ -482,37 +488,38 @@ msgstr "Online Judge" msgid "Comment body" msgstr "Bình luận" -#: judge/comments.py:47 judge/views/blog.py:47 judge/views/comment.py:39 +#: judge/comments.py:47 judge/views/blog.py:47 judge/views/comment.py:41 #: judge/views/ticket.py:56 msgid " Reason: " -msgstr " Lý Do:" +msgstr " Lý Do: " -#: judge/comments.py:48 judge/views/blog.py:48 judge/views/comment.py:40 +#: judge/comments.py:48 judge/views/blog.py:48 judge/views/comment.py:42 #: judge/views/ticket.py:57 msgid "Your part is silent, little toad." msgstr "Im lặng đi, bạn không có quyền nói ở đây." -#: judge/comments.py:50 templates/comments/list.html:149 +#: judge/comments.py:50 judge/comments.py:127 judge/views/user.py:285 +#, python-format msgid "" -"You need to have solved at least one problem before your voice can be heard." -msgstr "Bạn cần giải được ít nhất một bài trước khi bình luận." +"You need to have solved at least %d problems before your voice can be heard." +msgstr "Bạn cần giải được ít nhất %d bài trước khi bình luận." -#: judge/comments.py:94 +#: judge/comments.py:98 msgid "Posted comment" msgstr "Đăng bình luận" #: judge/contest_format/atcoder.py:19 msgid "AtCoder" -msgstr "AtCoder" +msgstr "" #: judge/contest_format/atcoder.py:117 judge/contest_format/default.py:79 -#: judge/contest_format/icpc.py:216 judge/contest_format/legacy_ioi.py:97 -#: judge/contest_format/vnoj.py:121 +#: judge/contest_format/icpc.py:216 judge/contest_format/legacy_ioi.py:104 +#: judge/contest_format/vnoj.py:238 msgid "The maximum score submission for each problem will be used." msgstr "Điểm của bài sẽ là điểm của lần nộp bài có điểm lớn nhất." #: judge/contest_format/atcoder.py:122 judge/contest_format/icpc.py:221 -#: judge/contest_format/vnoj.py:126 +#: judge/contest_format/vnoj.py:243 #, python-format msgid "" "Each submission before the first maximum score submission will incur a " @@ -524,7 +531,7 @@ msgstr[0] "" "Các lần nộp bài trước lần nộp bài có điểm lớn nhất sẽ tính **penalty %d " "phút**." -#: judge/contest_format/atcoder.py:126 judge/contest_format/vnoj.py:131 +#: judge/contest_format/atcoder.py:126 judge/contest_format/vnoj.py:248 msgid "" "Ties will be broken by the time of the last score altering submission " "(including penalty)." @@ -532,7 +539,8 @@ msgstr "" "Các thí sinh bằng điểm sẽ được phân định bằng thời gian của **lần nộp bài " "cuối cùng làm thay đổi kết quả** (cộng với penalty)." -#: judge/contest_format/atcoder.py:128 judge/contest_format/vnoj.py:138 +#: judge/contest_format/atcoder.py:128 judge/contest_format/legacy_ioi.py:111 +#: judge/contest_format/vnoj.py:255 msgid "Ties will be broken by the time of the last score altering submission." msgstr "" "Các thí sinh bằng điểm sẽ được phân định bằng thời gian của **lần nộp bài " @@ -590,7 +598,7 @@ msgstr "" "cuối cùng trong **tất cả*** bài tập." #: judge/contest_format/ecoo.py:149 judge/contest_format/ioi.py:102 -#: judge/contest_format/legacy_ioi.py:103 +#: judge/contest_format/legacy_ioi.py:116 msgid "Ties by score will **not** be broken." msgstr "Các thí sinh bằng điểm bằng điểm sẽ có cùng thứ hạng." @@ -598,7 +606,7 @@ msgstr "Các thí sinh bằng điểm bằng điểm sẽ có cùng thứ hạng msgid "ICPC" msgstr "" -#: judge/contest_format/icpc.py:225 judge/contest_format/vnoj.py:133 +#: judge/contest_format/icpc.py:225 judge/contest_format/vnoj.py:250 msgid "" "Ties will be broken by the sum of the last score altering submission time on " "problems with a non-zero score (including penalty), followed by the time of " @@ -609,7 +617,8 @@ msgstr "" "(cộng với penalty). Nếu vẫn bằng nhau, phân định bằng **thời gian của lần " "nộp bài cuối cùng làm thay đổi kết quả**." -#: judge/contest_format/icpc.py:228 judge/contest_format/vnoj.py:140 +#: judge/contest_format/icpc.py:228 judge/contest_format/legacy_ioi.py:108 +#: judge/contest_format/vnoj.py:257 msgid "" "Ties will be broken by the sum of the last score altering submission time on " "problems with a non-zero score, followed by the time of the last score " @@ -620,7 +629,7 @@ msgstr "" "Nếu vẫn bằng nhau, phân định bằng **lần nộp bài cuối cùng làm thay đổi kết " "quả**." -#: judge/contest_format/icpc.py:233 +#: judge/contest_format/icpc.py:233 judge/contest_format/vnoj.py:262 #, python-format msgid "The scoreboard will be frozen in the **last %d minute**." msgid_plural "The scoreboard will be frozen in the **last %d minutes**." @@ -634,7 +643,7 @@ msgstr "IOI" msgid "The maximum score for each problem batch will be used." msgstr "Điểm của bài là tổng điểm của các subtask." -#: judge/contest_format/ioi.py:99 judge/contest_format/legacy_ioi.py:100 +#: judge/contest_format/ioi.py:99 judge/contest_format/legacy_ioi.py:113 msgid "" "Ties will be broken by the sum of the last score altering submission time on " "problems with a non-zero score." @@ -646,7 +655,7 @@ msgstr "" msgid "IOI (pre-2016)" msgstr "" -#: judge/contest_format/vnoj.py:19 +#: judge/contest_format/vnoj.py:49 msgid "VNOJ" msgstr "" @@ -692,106 +701,109 @@ msgstr "Danh sách bài" msgid "posted {time}" msgstr "đã đăng {time}" -#: judge/custom_translations.py:28 templates/comments/list.html:54 +#: judge/custom_translations.py:28 templates/comments/list.html:65 +#: templates/user/comment.html:58 #, python-brace-format msgid "commented {time}" msgstr "đã bình luận {time}" -#: judge/custom_translations.py:31 templates/contest/list.html:127 +#: judge/custom_translations.py:31 templates/contest/list.html:112 #: templates/organization/home.html:152 #, python-format msgid "%(duration)s long" msgstr "Thời gian làm bài: %(duration)s" -#: judge/custom_translations.py:32 templates/contest/list.html:126 +#: judge/custom_translations.py:32 templates/contest/list.html:109 #: templates/organization/home.html:151 #, python-format msgid "%(time_limit)s window" msgstr "" "Thời gian làm bài: %(time_limit)s (bắt đầu tính giờ khi nhấn tham gia kỳ thi)" -#: judge/forms.py:34 +#: judge/forms.py:35 #, python-brace-format -msgid "" -"Two-factor authentication tokens must be {TOTP_CODE_LENGTH} decimal digits." -msgstr "" +msgid "Two-factor authentication tokens must be {count} decimal digit." +msgid_plural "Two-factor authentication tokens must be {count} decimal digits." +msgstr[0] "" -#: judge/forms.py:37 +#: judge/forms.py:40 msgid "Invalid two-factor authentication token." msgstr "" -#: judge/forms.py:40 -msgid "Scratch codes must be 16 base32 characters." +#: judge/forms.py:43 +msgid "Scratch codes must be 16 Base32 characters." msgstr "" -#: judge/forms.py:42 judge/forms.py:537 +#: judge/forms.py:45 judge/forms.py:545 msgid "Invalid scratch code." msgstr "" -#: judge/forms.py:53 +#: judge/forms.py:52 msgid "Subscribe to contest updates" msgstr "Đăng ký để nhận các cập nhật về kỳ thi" -#: judge/forms.py:54 +#: judge/forms.py:53 msgid "Enable experimental features" msgstr "Bật các tính năng đang thử nghiệm" -#: judge/forms.py:85 -msgid "You must solve at least one problem before you can update your profile." +#: judge/forms.py:84 +#, python-format +msgid "You must solve at least %d problems before you can update your profile." msgstr "" -"Bạn phải giải ít nhất một bài trước khi có thể bỏ thay đổi thông tin người " -"dùng" +"Bạn phải giải ít nhất %d bài trước khi có thể thay đổi thông tin người dùng" -#: judge/forms.py:94 judge/views/organization.py:131 +#: judge/forms.py:93 judge/views/organization.py:131 judge/views/register.py:60 #, python-brace-format -msgid "You may not be part of more than {count} public organizations." -msgstr "Bạn không thể là thành viên của nhiều hơn {count} tổ chức công khai." +msgid "You may not be part of more than {count} public organization." +msgid_plural "You may not be part of more than {count} public organizations." +msgstr[0] "" +"Bạn không thể là thành viên của nhiều hơn {count} tổ chức công khai." -#: judge/forms.py:145 judge/forms.py:215 +#: judge/forms.py:146 judge/forms.py:216 #, python-format msgid "You cannot set time limit higher than %d seconds" msgstr "Bạn không được giới hạn thời gian lớn hơn %d giây" -#: judge/forms.py:162 +#: judge/forms.py:163 #, python-format msgid "Maximum file size is %s." msgstr "Độ lớn tối đa của file là %s." -#: judge/forms.py:164 +#: judge/forms.py:165 msgid "Statement file" msgstr "File đề bài" -#: judge/forms.py:177 +#: judge/forms.py:178 msgid "Private users" msgstr "Các thành viên riêng tư" -#: judge/forms.py:178 +#: judge/forms.py:179 msgid "If private, only these users may see the problem." msgstr "" "Nếu bài tập này không công khai, chỉ những thành viên này có thể thấy được " "bài tập." -#: judge/forms.py:185 judge/forms.py:642 +#: judge/forms.py:186 judge/forms.py:650 msgid "You can paste a list of usernames into this box." msgstr "" "Có thể gán một danh sách các username vào đây thay vì gõ tay (phân cách bằng " "khoảng trắng hoặc dấu phẩy)." -#: judge/forms.py:194 +#: judge/forms.py:195 #, python-format msgid "Problem id code must starts with `%s`" msgstr "Mã bài tập phải bắt đầu bằng `%s`" -#: judge/forms.py:202 judge/forms.py:355 +#: judge/forms.py:203 judge/forms.py:356 #, python-format msgid "File size is too big! Maximum file size is %s" msgstr "File quá lớn! Độ lớn tối đa của file là %s." -#: judge/forms.py:206 +#: judge/forms.py:207 msgid "You don't have permission to upload file-type statement." msgstr "Bạn không có quyền đăng file đề" -#: judge/forms.py:237 +#: judge/forms.py:238 msgid "" "If public, all members in organization can view it. Set it as " "private if you want to use it in a contest, otherwise, users can see the " @@ -802,11 +814,11 @@ msgstr "" "không, các thành viên có thể xem bài tập này kể cả khi họ không tham gia kỳ " "thi!" -#: judge/forms.py:240 +#: judge/forms.py:241 msgid "Problem code, e.g: voi19_post" msgstr "Mã bài, ví dụ: voi19_post" -#: judge/forms.py:241 +#: judge/forms.py:242 msgid "" "The full name of the problem, as shown in the problem list. For example: " "VOI19 - A cong B" @@ -814,7 +826,7 @@ msgstr "" "Tên đầy đủ của bài, được hiển thị trong danh sách bài. Ví dụ: VOI19 - A cộng " "B" -#: judge/forms.py:243 +#: judge/forms.py:244 msgid "" "Points awarded for problem completion. From 0 to 2. You can approximate: 0.5 " "is as hard as Problem 1 of VOI; 1 = Problem 2 of VOI; 1.5 = Problem 3 of VOI." @@ -822,45 +834,45 @@ msgstr "" "Điểm của bài, từ 0 tới 2. Có thể xấp xỉ điểm như sau: bài có điểm 0.5 sẽ khó " "như bài 1 thi HSG QG; 1 điểm = bài 2; 1.5 điểm = bài 3." -#: judge/forms.py:249 judge/forms.py:698 +#: judge/forms.py:250 judge/forms.py:706 msgid "Only accept alphanumeric characters (a-z, 0-9) and underscore (_)" msgstr "" "Chỉ có thể chứa các ký tự viết thường (a-z), số (0-9) và dấu gạch dưới (_)" -#: judge/forms.py:263 +#: judge/forms.py:264 msgid "Download comments?" msgstr "Tải bình luận?" -#: judge/forms.py:264 judge/forms.py:293 +#: judge/forms.py:265 judge/forms.py:294 msgid "Download submissions?" msgstr "Tải bài nộp?" -#: judge/forms.py:265 judge/forms.py:294 +#: judge/forms.py:266 judge/forms.py:295 msgid "Filter by problem code glob:" msgstr "Lọc theo mã bài:" -#: judge/forms.py:269 judge/forms.py:298 +#: judge/forms.py:270 judge/forms.py:299 msgid "Leave empty to include all submissions" msgstr "Bỏ trống để tải tất cả bài nộp" -#: judge/forms.py:272 judge/forms.py:301 +#: judge/forms.py:273 judge/forms.py:302 #: templates/problem/manage_submission.html:156 msgid "Filter by result:" msgstr "Lọc theo kết quả:" -#: judge/forms.py:278 judge/forms.py:308 +#: judge/forms.py:279 judge/forms.py:309 msgid "Please select at least one thing to download." msgstr "Vui lòng chọn ít nhất một mục để tải." -#: judge/forms.py:325 +#: judge/forms.py:326 msgid "Source file" msgstr "File nộp" -#: judge/forms.py:343 +#: judge/forms.py:344 msgid "Source code/file is missing or redundant. Please try again" msgstr "Code/file code đang thiếu hoặc bị thừa. Xin hãy thử lại" -#: judge/forms.py:350 +#: judge/forms.py:351 #, python-format msgid "" "Wrong file type for language %(lang)s, expected %(lang_ext)s, found %(ext)s" @@ -868,92 +880,93 @@ msgstr "" "Sai loại file cho ngôn ngữ %(lang)s, chỉ chấp nhận: %(lang_ext)s, file nộp: " "%(ext)s" -#: judge/forms.py:366 +#: judge/forms.py:367 msgid "Any judge" msgstr "" -#: judge/forms.py:377 judge/models/tag.py:35 +#: judge/forms.py:378 judge/models/tag.py:35 msgid "Problem URL" msgstr "Link bài" -#: judge/forms.py:378 +#: judge/forms.py:379 msgid "Full URL to the problem, e.g. https://oj.vnoi.info/problem/post" msgstr "Link tới bài tập, ví dụ: https://oj.vnoi.info/problem/post" -#: judge/forms.py:410 judge/views/register.py:26 +#: judge/forms.py:418 judge/views/register.py:26 #: templates/blog/top-contrib.html:8 templates/blog/top-pp.html:8 #: templates/contest/official-ranking-table.html:4 -#: templates/contest/ranking.html:340 -#: templates/registration/registration_form.html:144 +#: templates/contest/ranking.html:624 +#: templates/problem/submission-diff.html:112 +#: templates/registration/registration_form.html:150 #: templates/user/base-users-table.html:5 msgid "Username" msgstr "Tên truy cập" -#: judge/forms.py:411 templates/registration/registration_form.html:156 -#: templates/registration/registration_form.html:170 +#: judge/forms.py:419 templates/registration/registration_form.html:162 +#: templates/registration/registration_form.html:176 msgid "Password" msgstr "Mật khẩu" -#: judge/forms.py:434 +#: judge/forms.py:442 #, python-format msgid "This account has been banned. Reason: %s" msgstr "Tài khoản này đã bị cấm vì lý do: %s" -#: judge/forms.py:465 +#: judge/forms.py:473 msgid "Invalid code length." msgstr "" -#: judge/forms.py:496 +#: judge/forms.py:504 msgid "Invalid WebAuthn response." msgstr "" -#: judge/forms.py:499 +#: judge/forms.py:507 msgid "No WebAuthn challenge issued." msgstr "" -#: judge/forms.py:505 +#: judge/forms.py:513 msgid "Invalid WebAuthn credential ID." msgstr "" -#: judge/forms.py:535 +#: judge/forms.py:543 msgid "Invalid two-factor authentication token or scratch code." msgstr "" -#: judge/forms.py:539 +#: judge/forms.py:547 msgid "Must specify either totp_token or webauthn_response." msgstr "" -#: judge/forms.py:543 judge/models/problem.py:135 +#: judge/forms.py:551 judge/models/problem.py:140 msgid "Problem code must be ^[a-z0-9_]+$" msgstr "Mã bài phải khớp regex ^[a-z0-9_]+$" -#: judge/forms.py:548 +#: judge/forms.py:556 msgid "Problem with code already exists." msgstr "Mã bài tập đã tồn tại." -#: judge/forms.py:562 judge/models/contest.py:75 +#: judge/forms.py:570 judge/models/contest.py:75 msgid "Contest id must be ^[a-z0-9_]+$" msgstr "id kỳ thi phải khớp regex ^[a-z0-9_]+$" -#: judge/forms.py:567 +#: judge/forms.py:575 msgid "Contest with key already exists." msgstr "Mã kỳ thi đã tồn tại." -#: judge/forms.py:605 +#: judge/forms.py:613 msgid "Problems must have distinct order." msgstr "Các bài tập phải có thứ tự khác nhau." -#: judge/forms.py:652 +#: judge/forms.py:660 #, python-format msgid "Contest duration cannot be longer than %d days" msgstr "Thời gian diễn ra contest không được kéo dài quá %d ngày" -#: judge/forms.py:664 +#: judge/forms.py:672 #, python-format msgid "Contest id must starts with `%s`" msgstr "Mã kỳ thi phải bắt đầu bằng `%s`" -#: judge/forms.py:693 +#: judge/forms.py:701 msgid "" "Users are able to pratice contest problems even if the contest has ended, so " "don't set the contest time too high if you don't really need it." @@ -962,6 +975,7 @@ msgstr "" "gian diễn ra kỳ thi quá dài nếu không cần thiết." #: judge/jinja2/datetime.py:29 templates/blog/content.html:58 +#: templates/submission/info-base.html:6 msgid "N j, Y, g:i a" msgstr "j, M, Y, G:i" @@ -980,16 +994,16 @@ msgid "Leave as LaTeX" msgstr "Giữ nguyên dạng LaTeX" #: judge/models/choices.py:60 -msgid "SVG with PNG fallback" -msgstr "SVG với PNG dự phòng" +msgid "SVG only" +msgstr "" #: judge/models/choices.py:61 msgid "MathML only" msgstr "Chỉ dạng MathML" #: judge/models/choices.py:62 -msgid "MathJax with SVG/PNG fallback" -msgstr "MathJax với SVG/PNG dự phòng" +msgid "MathJax with SVG fallback" +msgstr "MathJax với SVG dự phòng" #: judge/models/choices.py:63 msgid "Detect best quality" @@ -1007,7 +1021,7 @@ msgstr "bình luận" msgid "posted time" msgstr "thời gian đăng" -#: judge/models/comment.py:45 judge/models/comment.py:209 +#: judge/models/comment.py:45 judge/models/comment.py:219 msgid "associated page" msgstr "trang liên kết" @@ -1019,9 +1033,9 @@ msgstr "đánh giá" msgid "body of comment" msgstr "nhận xét" -#: judge/models/comment.py:49 -msgid "hide the comment" -msgstr "ẩn bình luận" +#: judge/models/comment.py:49 templates/contest/list.html:64 +msgid "hidden" +msgstr "ẩn" #: judge/models/comment.py:50 msgid "parent" @@ -1035,26 +1049,26 @@ msgstr "bình luận" msgid "comments" msgstr "nhận xét" -#: judge/models/comment.py:97 judge/models/comment.py:154 -#: judge/models/problem.py:656 +#: judge/models/comment.py:103 judge/models/comment.py:164 +#: judge/models/problem.py:663 #, python-format msgid "Editorial for %s" msgstr "Hướng giải cho %s" -#: judge/models/comment.py:182 +#: judge/models/comment.py:192 #, python-format msgid "%(page)s by %(user)s" msgstr "%(page)s bởi %(user)s" -#: judge/models/comment.py:204 +#: judge/models/comment.py:214 msgid "comment vote" msgstr "đánh giá bình luận" -#: judge/models/comment.py:205 +#: judge/models/comment.py:215 msgid "comment votes" msgstr "đánh giá bình luận" -#: judge/models/comment.py:214 +#: judge/models/comment.py:224 msgid "Override comment lock" msgstr "Ghi đè khóa nhận xét" @@ -1126,16 +1140,16 @@ msgstr "" msgid "These users will be able to view the contest, but not edit it." msgstr "Những người này có thể xem kỳ thi nhưng không thể chỉnh sửa chúng." -#: judge/models/contest.py:85 judge/models/runtime.py:145 +#: judge/models/contest.py:85 judge/models/runtime.py:148 msgid "description" msgstr "mô tả" -#: judge/models/contest.py:86 judge/models/problem.py:602 -#: judge/models/runtime.py:147 +#: judge/models/contest.py:86 judge/models/problem.py:609 +#: judge/models/runtime.py:150 msgid "problems" msgstr "bài" -#: judge/models/contest.py:87 judge/models/contest.py:525 +#: judge/models/contest.py:87 judge/models/contest.py:532 msgid "start time" msgstr "thời gian bắt đầu" @@ -1143,8 +1157,8 @@ msgstr "thời gian bắt đầu" msgid "end time" msgstr "thời gian kết thúc" -#: judge/models/contest.py:89 judge/models/problem.py:163 -#: judge/models/problem.py:627 +#: judge/models/contest.py:89 judge/models/problem.py:165 +#: judge/models/problem.py:634 msgid "time limit" msgstr "giới hạn thời gian" @@ -1160,7 +1174,7 @@ msgstr "" "Nếu được thiết lập, bảng xếp hạng sẽ dược đóng băng trong X phút cuối. Chỉ " "hỗ trợ format ICPC và VNOJ." -#: judge/models/contest.py:93 judge/models/problem.py:181 +#: judge/models/contest.py:93 judge/models/problem.py:183 msgid "publicly visible" msgstr "hiển thị công khai" @@ -1220,16 +1234,24 @@ msgid "Notify users when there are new announcements." msgstr "Báo cho thí sinh khi có thông báo mới." #: judge/models/contest.py:117 -msgid "Rating floor for contest" -msgstr "Rating thấp nhất có thể tham gia kỳ thi" +msgid "rating floor" +msgstr "" + +#: judge/models/contest.py:118 +msgid "Do not rate users who have a lower rating." +msgstr "Thí sinh có rating thấp hơn sẽ không được xếp hạng." #: judge/models/contest.py:119 -msgid "Rating ceiling for contest" +msgid "rating ceiling" msgstr "Rating cao nhất có thể tham gia kỳ thi" +#: judge/models/contest.py:120 +msgid "Do not rate users who have a higher rating." +msgstr "Thí sinh có rating cao hơn sẽ không được xếp hạng." + #: judge/models/contest.py:121 msgid "rate all" -msgstr "Rating tất cả" +msgstr "Rate tất cả" #: judge/models/contest.py:121 msgid "Rate all users who joined." @@ -1289,11 +1311,11 @@ msgid "" "not." msgstr "Hiện tóm tắt các cài đặt của kỳ thi ở trang kỳ thi." -#: judge/models/contest.py:143 judge/models/problem.py:209 +#: judge/models/contest.py:143 judge/models/problem.py:217 msgid "private to organizations" msgstr "dành riêng cho tổ chức" -#: judge/models/contest.py:144 judge/models/problem.py:207 +#: judge/models/contest.py:144 judge/models/problem.py:215 #: judge/models/profile.py:119 msgid "organizations" msgstr "tổ chức" @@ -1302,12 +1324,13 @@ msgstr "tổ chức" msgid "If private, only these organizations may see the contest" msgstr "Nếu là private, thì chỉ các tổ chức này mới có thể xem kỳ thi" -#: judge/models/contest.py:146 judge/models/problem.py:190 +#: judge/models/contest.py:146 judge/models/interface.py:76 +#: judge/models/problem.py:193 msgid "OpenGraph image" msgstr "Ảnh OpenGraph" #: judge/models/contest.py:147 judge/models/profile.py:59 -msgid "Logo override image" +msgid "logo override image" msgstr "Logo" #: judge/models/contest.py:149 @@ -1328,7 +1351,7 @@ msgstr "số lượng tham gia ảo" msgid "contest summary" msgstr "tổng kết kỳ thi" -#: judge/models/contest.py:155 judge/models/problem.py:192 +#: judge/models/contest.py:155 judge/models/problem.py:195 msgid "Plain-text, shown in meta description tag, e.g. for social media." msgstr "" "Các thông tin này sẽ hiển thị khi chia sẻ kỳ thi lên mạng xã hội (ví dụ " @@ -1346,7 +1369,7 @@ msgstr "" "Một mã tùy chọn để nhắc nhở các thí sinh trước khi họ được phép tham gia kỳ " "thi. Để trống để vô hiệu hóa." -#: judge/models/contest.py:159 judge/models/problem.py:186 +#: judge/models/contest.py:159 judge/models/problem.py:189 msgid "personae non gratae" msgstr "các người dùng bị cấm" @@ -1432,256 +1455,273 @@ msgstr "Không cho phép tham gia ảo" msgid "Disallow virtual joining after contest has ended." msgstr "Không cho phép tham gia ảo sau khi kỳ thi kết thúc." -#: judge/models/contest.py:489 +#: judge/models/contest.py:186 +msgid "ranking access code" +msgstr "mã truy cập bảng xếp hạng" + +#: judge/models/contest.py:187 +msgid "" +"An optional code to view the contest ranking. Leave it blank to disable." +msgstr "Một mã tùy chọn để xem bảng xếp hạng. Để trống để vô hiệu hóa." + +#: judge/models/contest.py:496 msgid "See private contests" msgstr "Xem các kỳ thi riêng tư" -#: judge/models/contest.py:490 +#: judge/models/contest.py:497 msgid "Edit own contests" msgstr "Sửa các kỳ thi sở hữu" -#: judge/models/contest.py:491 +#: judge/models/contest.py:498 msgid "Edit all contests" msgstr "Sửa tất cả các kỳ thi" -#: judge/models/contest.py:492 +#: judge/models/contest.py:499 msgid "Clone contest" msgstr "Nhân bản kỳ thi" -#: judge/models/contest.py:493 templates/contest/moss.html:73 +#: judge/models/contest.py:500 templates/contest/moss.html:75 msgid "MOSS contest" msgstr "MOSS kỳ thi" -#: judge/models/contest.py:494 +#: judge/models/contest.py:501 msgid "Rate contests" msgstr "Đánh gia các kỳ thi" -#: judge/models/contest.py:495 +#: judge/models/contest.py:502 msgid "Contest access codes" msgstr "Mã truy cập kỳ thi" -#: judge/models/contest.py:496 +#: judge/models/contest.py:503 msgid "Create private contests" msgstr "Tạo kỳ thi riêng tư" -#: judge/models/contest.py:497 +#: judge/models/contest.py:504 msgid "Change contest visibility" msgstr "" -#: judge/models/contest.py:498 +#: judge/models/contest.py:505 msgid "Edit contest problem label script" msgstr "" -#: judge/models/contest.py:499 +#: judge/models/contest.py:506 msgid "Change lock status of contest" msgstr "" -#: judge/models/contest.py:501 judge/models/contest.py:626 -#: judge/models/contest.py:665 judge/models/contest.py:689 +#: judge/models/contest.py:508 judge/models/contest.py:633 +#: judge/models/contest.py:672 judge/models/contest.py:696 #: judge/models/submission.py:93 msgid "contest" msgstr "kỳ thi" -#: judge/models/contest.py:502 +#: judge/models/contest.py:509 msgid "contests" msgstr "kỳ thi" -#: judge/models/contest.py:506 +#: judge/models/contest.py:513 msgid "announced contest" msgstr "kỳ thi được thông báo" -#: judge/models/contest.py:507 +#: judge/models/contest.py:514 msgid "announcement title" msgstr "tiêu đề thông báo" -#: judge/models/contest.py:508 +#: judge/models/contest.py:515 msgid "announcement body" msgstr "nội dung thông báo" -#: judge/models/contest.py:509 +#: judge/models/contest.py:516 msgid "announcement timestamp" msgstr "thời điểm thông báo" -#: judge/models/contest.py:523 +#: judge/models/contest.py:530 msgid "associated contest" msgstr "kỳ thi liên quan" -#: judge/models/contest.py:526 +#: judge/models/contest.py:533 msgid "score" msgstr "điểm" -#: judge/models/contest.py:527 +#: judge/models/contest.py:534 msgid "cumulative time" msgstr "thời gian tích lũy" -#: judge/models/contest.py:528 +#: judge/models/contest.py:535 msgid "frozen score" msgstr "" -#: judge/models/contest.py:529 +#: judge/models/contest.py:536 msgid "Frozen score in the scoreboard." msgstr "" -#: judge/models/contest.py:530 +#: judge/models/contest.py:537 msgid "frozen cumulative time" msgstr "" -#: judge/models/contest.py:531 +#: judge/models/contest.py:538 msgid "Frozen cumulative time in the scoreboard." msgstr "" -#: judge/models/contest.py:532 +#: judge/models/contest.py:539 msgid "is disqualified" msgstr "bị loại" -#: judge/models/contest.py:533 +#: judge/models/contest.py:540 msgid "Whether this participation is disqualified." msgstr "có bị hủy tư cách tham gia" -#: judge/models/contest.py:534 +#: judge/models/contest.py:541 msgid "tie-breaking field" msgstr "" -#: judge/models/contest.py:535 +#: judge/models/contest.py:542 msgid "frozen tie-breaking field" msgstr "" -#: judge/models/contest.py:536 +#: judge/models/contest.py:543 msgid "virtual participation id" msgstr "mã số tham gia ảo" -#: judge/models/contest.py:537 +#: judge/models/contest.py:544 msgid "0 means non-virtual, otherwise the n-th virtual participation." msgstr "0 nghĩa là tham gia trực tiếp, ngược lại là lần đăng ký ảo thứ n." -#: judge/models/contest.py:538 +#: judge/models/contest.py:545 msgid "contest format specific data" msgstr "định dạng dữ liệu của kỳ thi" -#: judge/models/contest.py:610 +#: judge/models/contest.py:617 #, python-format msgid "%(user)s spectating in %(contest)s" msgstr "%(user)s spectating trong %(contest)s" -#: judge/models/contest.py:612 +#: judge/models/contest.py:619 #, python-format msgid "%(user)s in %(contest)s, v%(id)d" msgstr "%(user)s trong %(contest)s, v%(id)d" -#: judge/models/contest.py:615 +#: judge/models/contest.py:622 #, python-format msgid "%(user)s in %(contest)s" msgstr "%(user)s trong %(contest)s" -#: judge/models/contest.py:618 +#: judge/models/contest.py:625 msgid "contest participation" msgstr "tham gia kỳ thi" -#: judge/models/contest.py:619 +#: judge/models/contest.py:626 msgid "contest participations" msgstr "tham gia kỳ thi" -#: judge/models/contest.py:625 judge/models/contest.py:649 -#: judge/models/contest.py:690 judge/models/problem.py:601 -#: judge/models/problem.py:606 judge/models/problem.py:625 +#: judge/models/contest.py:632 judge/models/contest.py:656 +#: judge/models/contest.py:697 judge/models/problem.py:608 +#: judge/models/problem.py:613 judge/models/problem.py:632 #: judge/models/problem_data.py:56 msgid "problem" msgstr "vấn đề" -#: judge/models/contest.py:627 judge/models/contest.py:653 -#: judge/models/problem.py:174 +#: judge/models/contest.py:634 judge/models/contest.py:660 +#: judge/models/problem.py:176 msgid "points" msgstr "điểm" -#: judge/models/contest.py:628 +#: judge/models/contest.py:635 msgid "partial" msgstr "một phần" -#: judge/models/contest.py:629 judge/models/contest.py:654 +#: judge/models/contest.py:636 judge/models/contest.py:661 msgid "is pretested" msgstr "có pretest?" -#: judge/models/contest.py:630 judge/models/interface.py:45 +#: judge/models/contest.py:637 judge/models/interface.py:45 msgid "order" msgstr "thứ tự" -#: judge/models/contest.py:631 +#: judge/models/contest.py:638 msgid "output prefix length override" msgstr "ghi đè độ dài output prefix" -#: judge/models/contest.py:633 +#: judge/models/contest.py:640 msgid "" "Maximum number of submissions for this problem, or leave blank for no limit." msgstr "" -#: judge/models/contest.py:636 +#: judge/models/contest.py:643 msgid "Why include a problem you can't submit to?" msgstr "Tại sao lại thêm bài mà bạn không thể nộp?" -#: judge/models/contest.py:641 +#: judge/models/contest.py:648 msgid "contest problem" msgstr "bài của kỳ thi" -#: judge/models/contest.py:642 +#: judge/models/contest.py:649 msgid "contest problems" msgstr "bài của kỳ thi" -#: judge/models/contest.py:647 judge/models/submission.py:239 +#: judge/models/contest.py:654 judge/models/submission.py:237 msgid "submission" msgstr "nộp bài" -#: judge/models/contest.py:651 judge/models/contest.py:666 +#: judge/models/contest.py:658 judge/models/contest.py:673 msgid "participation" msgstr "tham dự" -#: judge/models/contest.py:655 +#: judge/models/contest.py:662 msgid "Whether this submission was ran only on pretests." msgstr "Bài nộp này chỉ chạy trên pretest." -#: judge/models/contest.py:659 +#: judge/models/contest.py:666 msgid "contest submission" msgstr "bài nộp của contest" -#: judge/models/contest.py:660 +#: judge/models/contest.py:667 msgid "contest submissions" msgstr "bài nộp của contest" -#: judge/models/contest.py:668 +#: judge/models/contest.py:675 msgid "rank" msgstr "hạng" -#: judge/models/contest.py:669 +#: judge/models/contest.py:676 msgid "rating" msgstr "" -#: judge/models/contest.py:670 +#: judge/models/contest.py:677 msgid "raw rating" msgstr "" -#: judge/models/contest.py:671 +#: judge/models/contest.py:678 msgid "contest performance" msgstr "" -#: judge/models/contest.py:672 +#: judge/models/contest.py:679 msgid "last rated" msgstr "lần xếp hạng cuối" -#: judge/models/contest.py:676 +#: judge/models/contest.py:683 msgid "contest rating" msgstr "xếp hạng kỳ thi" -#: judge/models/contest.py:677 +#: judge/models/contest.py:684 msgid "contest ratings" msgstr "xếp hạng kỳ thi" -#: judge/models/contest.py:697 +#: judge/models/contest.py:704 msgid "contest moss result" msgstr "kết quả moss" -#: judge/models/contest.py:698 +#: judge/models/contest.py:705 msgid "contest moss results" msgstr "kết quả moss" +#: judge/models/interface.py:19 judge/models/problem.py:62 +msgid "key" +msgstr "khóa" + +#: judge/models/interface.py:20 +msgid "value" +msgstr "giá trị" + #: judge/models/interface.py:26 msgid "configuration item" msgstr "cấu hình" @@ -1718,7 +1758,7 @@ msgstr "mục cha" msgid "post title" msgstr "tiêu đề bài viết" -#: judge/models/interface.py:69 judge/models/problem.py:645 +#: judge/models/interface.py:69 judge/models/problem.py:652 msgid "authors" msgstr "tác giả" @@ -1726,7 +1766,7 @@ msgstr "tác giả" msgid "slug" msgstr "slug" -#: judge/models/interface.py:71 judge/models/problem.py:643 +#: judge/models/interface.py:71 judge/models/problem.py:650 msgid "public visibility" msgstr "hiển thị công khai" @@ -1746,10 +1786,6 @@ msgstr "nội dung" msgid "post summary" msgstr "đăng bài tóm tắt" -#: judge/models/interface.py:76 -msgid "openGraph image" -msgstr "ảnh OpenGraph" - #: judge/models/interface.py:78 msgid "global post" msgstr "bài đăng chung" @@ -1759,7 +1795,7 @@ msgid "Display this blog post at the homepage." msgstr "Hiển thị bài đăng này ở trang chủ." #: judge/models/interface.py:80 judge/models/profile.py:118 -#: judge/models/profile.py:147 judge/models/profile.py:407 +#: judge/models/profile.py:147 judge/models/profile.py:412 msgid "organization" msgstr "tổ chức" @@ -1787,129 +1823,137 @@ msgstr "bỏ phiếu blog" msgid "blog votes" msgstr "bỏ phiếu blog" -#: judge/models/problem.py:33 +#: judge/models/problem.py:31 #, python-format msgid "Disallowed characters: %(value)s" msgstr "" -#: judge/models/problem.py:38 +#: judge/models/problem.py:36 msgid "problem category ID" msgstr "ID của nhóm bài" -#: judge/models/problem.py:39 +#: judge/models/problem.py:37 msgid "problem category name" msgstr "phân nhóm bài" -#: judge/models/problem.py:46 +#: judge/models/problem.py:44 msgid "problem type" msgstr "dạng đề" -#: judge/models/problem.py:47 judge/models/problem.py:158 +#: judge/models/problem.py:45 judge/models/problem.py:161 msgid "problem types" msgstr "dạng đề" -#: judge/models/problem.py:51 +#: judge/models/problem.py:49 msgid "problem group ID" msgstr "ID của nhóm bài" -#: judge/models/problem.py:52 +#: judge/models/problem.py:50 msgid "problem group name" msgstr "tên nhóm bài" -#: judge/models/problem.py:59 judge/models/problem.py:161 +#: judge/models/problem.py:57 judge/models/problem.py:163 msgid "problem group" msgstr "nhóm bài" -#: judge/models/problem.py:60 +#: judge/models/problem.py:58 msgid "problem groups" msgstr "nhóm bài" #: judge/models/problem.py:64 -msgid "key" -msgstr "khóa" - -#: judge/models/problem.py:66 msgid "link" msgstr "liên kết" -#: judge/models/problem.py:67 +#: judge/models/problem.py:65 msgid "full name" msgstr "tên đầy đủ" -#: judge/models/problem.py:68 judge/models/profile.py:44 +#: judge/models/problem.py:66 judge/models/profile.py:44 #: judge/models/runtime.py:24 msgid "short name" msgstr "tên viết tắt" -#: judge/models/problem.py:69 -msgid "Displayed on pages under this license" +#: judge/models/problem.py:67 +msgid "Displayed on pages under this license." msgstr "Được hiển thị trên các trang theo giấy phép này" -#: judge/models/problem.py:70 +#: judge/models/problem.py:68 msgid "icon" msgstr "biểu tượng" -#: judge/models/problem.py:70 -msgid "URL to the icon" +#: judge/models/problem.py:68 +msgid "URL to the icon." msgstr "URL cho biểu tượng" -#: judge/models/problem.py:71 +#: judge/models/problem.py:69 msgid "license text" msgstr "văn bản cấp phép" -#: judge/models/problem.py:80 +#: judge/models/problem.py:78 msgid "license" msgstr "giấy phép" -#: judge/models/problem.py:81 +#: judge/models/problem.py:79 msgid "licenses" msgstr "giấy phép" -#: judge/models/problem.py:122 +#: judge/models/problem.py:121 msgid "Follow global setting" msgstr "" -#: judge/models/problem.py:123 judge/models/problem.py:131 +#: judge/models/problem.py:122 judge/models/problem.py:130 msgid "Always visible" msgstr "Có thể xem" -#: judge/models/problem.py:124 +#: judge/models/problem.py:123 msgid "Visible if problem solved" msgstr "Có thể xem nếu đã AC" -#: judge/models/problem.py:125 +#: judge/models/problem.py:124 msgid "Only own submissions" msgstr "Chỉ có thể xem submission của bản thân" -#: judge/models/problem.py:129 +#: judge/models/problem.py:128 msgid "Visible for authors" msgstr "Chỉ tác giả có thể xem" -#: judge/models/problem.py:130 +#: judge/models/problem.py:129 msgid "Visible if user is not in a contest" msgstr "Hiển thị khi không ở trong kỳ thi" -#: judge/models/problem.py:134 judge/models/tag.py:28 +#: judge/models/problem.py:134 +msgid "Show all testcase result" +msgstr "Hiện kết quả tất cả testcase" + +#: judge/models/problem.py:135 +msgid "Show batch result only" +msgstr "Chỉ hiện kết quả batch" + +#: judge/models/problem.py:136 +msgid "Show submission result only" +msgstr "Chỉ hiện kết quả bài nộp" + +#: judge/models/problem.py:139 judge/models/tag.py:28 msgid "problem code" msgstr "mã bài" -#: judge/models/problem.py:136 +#: judge/models/problem.py:141 msgid "A short, unique code for the problem, used in the url after /problem/" msgstr "Mã bài, xuất hiện trong url, phía sau /problem/" -#: judge/models/problem.py:138 judge/models/tag.py:32 +#: judge/models/problem.py:142 judge/models/tag.py:32 msgid "problem name" msgstr "tên bài toán" -#: judge/models/problem.py:139 judge/models/tag.py:33 +#: judge/models/problem.py:143 judge/models/tag.py:33 msgid "The full name of the problem, as shown in the problem list." msgstr "Tên đầy đủ của bài, được hiển thị trong danh sách bài." -#: judge/models/problem.py:141 +#: judge/models/problem.py:144 msgid "PDF statement URL" msgstr "" -#: judge/models/problem.py:142 +#: judge/models/problem.py:145 msgid "" "URL to PDF statement. The PDF file must be embeddable (Mobile web " "browsersmay not support embedding). Fallback included." @@ -1917,58 +1961,58 @@ msgstr "" "Đường dẫn tới PDF của đề bài (nếu có). Đường dẫn này phải có thể preview " "trực tiếp." -#: judge/models/problem.py:144 +#: judge/models/problem.py:147 msgid "Problem source" msgstr "Nguồn bài tập" -#: judge/models/problem.py:145 +#: judge/models/problem.py:148 msgid "" "Source of problem. Please credit the source of the problemif it is not yours" msgstr "Nguồn của bài tập. Hãy ghi nguồn của bài tập nếu có" -#: judge/models/problem.py:147 +#: judge/models/problem.py:150 msgid "problem body" msgstr "bài toán" -#: judge/models/problem.py:149 +#: judge/models/problem.py:152 msgid "creators" msgstr "người tạo" -#: judge/models/problem.py:150 +#: judge/models/problem.py:153 msgid "These users will be able to edit the problem, and be listed as authors." msgstr "" "Những người này có thể chỉnh sửa bài tập và được liệt kê trong danh sách tác " "giả." -#: judge/models/problem.py:152 +#: judge/models/problem.py:155 msgid "curators" msgstr "giám khảo" -#: judge/models/problem.py:153 +#: judge/models/problem.py:156 msgid "" "These users will be able to edit the problem, but not be listed as authors." msgstr "" "Những người này có thể chỉnh sửa bài tập nhưng không được liệt kê trong danh " "sách tác giả." -#: judge/models/problem.py:155 +#: judge/models/problem.py:158 msgid "testers" msgstr "" -#: judge/models/problem.py:157 +#: judge/models/problem.py:160 msgid "These users will be able to view the private problem, but not edit it." msgstr "" "Những người này có thể xem bài tập riêng tư nhưng không thể chỉnh sửa chúng." -#: judge/models/problem.py:159 +#: judge/models/problem.py:162 msgid "The type of problem, as shown on the problem's page." msgstr "Dạng của bài tập, được hiển thị trong trang bài." -#: judge/models/problem.py:162 +#: judge/models/problem.py:164 msgid "The group of problem, shown under Category in the problem list." msgstr "Nhóm của bài tập, hiển thị trong danh sách bài tập." -#: judge/models/problem.py:164 +#: judge/models/problem.py:166 msgid "" "The time limit for this problem, in seconds. Fractional seconds (e.g. 1.5) " "are supported." @@ -1976,211 +2020,213 @@ msgstr "" "Giới hạn thời gian (tính bằng giây) cho bài tập này. Phần lẻ giây (chẳng hạn " "1.5) cũng được hỗ trợ." -#: judge/models/problem.py:168 judge/models/problem.py:630 +#: judge/models/problem.py:170 judge/models/problem.py:637 msgid "memory limit" msgstr "giới hạn bộ nhớ" -#: judge/models/problem.py:169 +#: judge/models/problem.py:171 msgid "" "The memory limit for this problem, in kilobytes (e.g. 64mb = 65536 " "kilobytes)." msgstr "Giới hạn bộ nhớ theo KILOBYTES. Muốn bài 256MB thì điền 262144." -#: judge/models/problem.py:175 +#: judge/models/problem.py:177 msgid "" "Points awarded for problem completion. Points are displayed with a 'p' " "suffix if partial." msgstr "Điểm của bài tập." -#: judge/models/problem.py:178 +#: judge/models/problem.py:180 msgid "allows partial points" msgstr "cho phép nhận điểm với từng test đúng" -#: judge/models/problem.py:179 +#: judge/models/problem.py:181 msgid "allowed languages" msgstr "ngôn ngữ cho phép" -#: judge/models/problem.py:180 +#: judge/models/problem.py:182 msgid "List of allowed submission languages." msgstr "Danh sách các ngôn ngữ được nộp bài này." -#: judge/models/problem.py:182 +#: judge/models/problem.py:184 msgid "manually managed" msgstr "quản lý test thủ công" -#: judge/models/problem.py:183 +#: judge/models/problem.py:185 msgid "Whether judges should be allowed to manage data or not." msgstr "Quản lý test thủ công thay vì dùng Web UI." -#: judge/models/problem.py:184 +#: judge/models/problem.py:186 msgid "date of publishing" msgstr "ngày đăng" -#: judge/models/problem.py:185 +#: judge/models/problem.py:188 msgid "" -"Doesn't have magic ability to auto-publish due to backward compatibility" +"Doesn't have the magic ability to auto-publish due to backward compatibility." msgstr "Không thể tự động công khai vì vấn đề tương thích ngược" -#: judge/models/problem.py:187 +#: judge/models/problem.py:190 msgid "Bans the selected users from submitting to this problem." msgstr "Cấm những người dùng được chọn nộp bài cho bài tập này." -#: judge/models/problem.py:189 +#: judge/models/problem.py:192 msgid "The license under which this problem is published." msgstr "Giấy phép mà theo đó bài tập này được công bố." -#: judge/models/problem.py:191 +#: judge/models/problem.py:194 msgid "problem summary" msgstr "tổng quan bài tập" -#: judge/models/problem.py:193 +#: judge/models/problem.py:196 msgid "number of users" msgstr "số thành viên" -#: judge/models/problem.py:194 +#: judge/models/problem.py:197 msgid "The number of users who solved the problem." msgstr "Số thành viên đã giải được bài tập." -#: judge/models/problem.py:195 +#: judge/models/problem.py:198 msgid "solve rate" msgstr "tỉ lệ giải được" -#: judge/models/problem.py:196 +#: judge/models/problem.py:199 msgid "allow full markdown access" msgstr "" -#: judge/models/problem.py:197 +#: judge/models/problem.py:200 msgid "submission source visibility" msgstr "hiển thị submission" -#: judge/models/problem.py:200 +#: judge/models/problem.py:203 msgid "Testcase visibility" msgstr "Chế độ hiển thị testcase" -#: judge/models/problem.py:208 -msgid "If private, only these organizations may see the problem." -msgstr "Nếu riêng tư, chỉ những tổ chức này có thể xem bài tập." +#: judge/models/problem.py:207 +msgid "Testcase result visibility" +msgstr "Chế độ hiển thị kết quả testcase" -#: judge/models/problem.py:214 -msgid "" -"Allow user to view result of testcase. Should be allow for most of problems " -"except for ICPC" +#: judge/models/problem.py:210 +msgid "What testcase result should be showed to users?" msgstr "" -#: judge/models/problem.py:219 +#: judge/models/problem.py:216 +msgid "If private, only these organizations may see the problem." +msgstr "Nếu riêng tư, chỉ những tổ chức này có thể xem bài tập." + +#: judge/models/problem.py:222 msgid "Allow user to view checker feedback." msgstr "" -#: judge/models/problem.py:588 +#: judge/models/problem.py:595 msgid "See hidden problems" msgstr "" -#: judge/models/problem.py:589 +#: judge/models/problem.py:596 msgid "Edit own problems" msgstr "" -#: judge/models/problem.py:590 +#: judge/models/problem.py:597 msgid "Create organization problem" msgstr "" -#: judge/models/problem.py:591 +#: judge/models/problem.py:598 msgid "Edit all problems" msgstr "" -#: judge/models/problem.py:592 +#: judge/models/problem.py:599 msgid "Edit all public problems" msgstr "" -#: judge/models/problem.py:593 templates/problem/problem-list-tabs.html:7 +#: judge/models/problem.py:600 templates/problem/problem-list-tabs.html:7 msgid "Suggest new problem" msgstr "Đề xuất bài mới" -#: judge/models/problem.py:594 +#: judge/models/problem.py:601 msgid "Edit problems with full markup" msgstr "" -#: judge/models/problem.py:595 templates/problem/problem.html:197 +#: judge/models/problem.py:602 templates/problem/problem.html:197 msgid "Clone problem" msgstr "Nhân bản bài" -#: judge/models/problem.py:596 +#: judge/models/problem.py:603 msgid "Upload file-type statement" msgstr "" -#: judge/models/problem.py:597 +#: judge/models/problem.py:604 msgid "Change is_public field" msgstr "" -#: judge/models/problem.py:598 +#: judge/models/problem.py:605 msgid "Change is_manually_managed field" msgstr "" -#: judge/models/problem.py:599 +#: judge/models/problem.py:606 msgid "See organization-private problems" msgstr "" -#: judge/models/problem.py:607 judge/models/problem.py:626 +#: judge/models/problem.py:614 judge/models/problem.py:633 #: judge/models/runtime.py:120 msgid "language" msgstr "ngôn ngữ" -#: judge/models/problem.py:608 +#: judge/models/problem.py:615 msgid "translated name" msgstr "tên bộ dịch" -#: judge/models/problem.py:609 +#: judge/models/problem.py:616 msgid "translated description" msgstr "mô tả bộ dịch" -#: judge/models/problem.py:614 +#: judge/models/problem.py:621 msgid "problem translation" msgstr "dịch đầu bài" -#: judge/models/problem.py:615 +#: judge/models/problem.py:622 msgid "problem translations" msgstr "dịch đầu bài" -#: judge/models/problem.py:619 +#: judge/models/problem.py:626 msgid "clarified problem" msgstr "bài tập được làm rõ" -#: judge/models/problem.py:620 +#: judge/models/problem.py:627 msgid "clarification body" msgstr "nội dung làm rõ" -#: judge/models/problem.py:621 +#: judge/models/problem.py:628 msgid "clarification timestamp" msgstr "thời gian làm rõ" -#: judge/models/problem.py:636 +#: judge/models/problem.py:643 msgid "language-specific resource limit" msgstr "giới hạn theo ngôn ngữ" -#: judge/models/problem.py:637 +#: judge/models/problem.py:644 msgid "language-specific resource limits" msgstr "giới hạn theo ngôn ngữ" -#: judge/models/problem.py:641 +#: judge/models/problem.py:648 msgid "associated problem" msgstr "bài liên quan" -#: judge/models/problem.py:644 +#: judge/models/problem.py:651 msgid "publish date" msgstr "ngày công bố" -#: judge/models/problem.py:646 +#: judge/models/problem.py:653 msgid "editorial content" msgstr "nội dung lời giải" -#: judge/models/problem.py:669 +#: judge/models/problem.py:676 msgid "See hidden solutions" msgstr "Xem lời giải ẩn" -#: judge/models/problem.py:671 +#: judge/models/problem.py:678 msgid "solution" msgstr "lời giải" -#: judge/models/problem.py:672 +#: judge/models/problem.py:679 msgid "solutions" msgstr "lời giải" @@ -2224,38 +2270,38 @@ msgstr "" msgid "Output Only" msgstr "" -#: judge/models/problem_data.py:38 -msgid "Custom Grader" -msgstr "" - -#: judge/models/problem_data.py:42 +#: judge/models/problem_data.py:41 msgid "Standard Input/Output" msgstr "Đầu vào/ra chuẩn" -#: judge/models/problem_data.py:43 +#: judge/models/problem_data.py:42 msgid "Via files" msgstr "Sử dụng file" -#: judge/models/problem_data.py:47 +#: judge/models/problem_data.py:46 msgid "Themis checker" msgstr "" -#: judge/models/problem_data.py:48 +#: judge/models/problem_data.py:47 msgid "Testlib checker" msgstr "" -#: judge/models/problem_data.py:49 +#: judge/models/problem_data.py:48 msgid "CMS checker" msgstr "" -#: judge/models/problem_data.py:50 +#: judge/models/problem_data.py:49 msgid "COCI checker" msgstr "" -#: judge/models/problem_data.py:51 +#: judge/models/problem_data.py:50 msgid "PEG checker" msgstr "" +#: judge/models/problem_data.py:51 +msgid "DMOJ checker" +msgstr "" + #: judge/models/problem_data.py:58 msgid "data zip file" msgstr "tập tin dữ liệu nén dạng zip" @@ -2264,11 +2310,11 @@ msgstr "tập tin dữ liệu nén dạng zip" msgid "generator file" msgstr "trình sinh test" -#: judge/models/problem_data.py:62 judge/models/problem_data.py:139 +#: judge/models/problem_data.py:62 judge/models/problem_data.py:143 msgid "output prefix length" msgstr "" -#: judge/models/problem_data.py:63 judge/models/problem_data.py:140 +#: judge/models/problem_data.py:63 judge/models/problem_data.py:144 msgid "output limit length" msgstr "hạn chế chiều dài đầu ra" @@ -2276,7 +2322,7 @@ msgstr "hạn chế chiều dài đầu ra" msgid "init.yml generation feedback" msgstr "phản hồi init.yml" -#: judge/models/problem_data.py:65 judge/models/problem_data.py:141 +#: judge/models/problem_data.py:65 judge/models/problem_data.py:145 msgid "checker" msgstr "trình chấm" @@ -2284,75 +2330,83 @@ msgstr "trình chấm" msgid "Grader" msgstr "" -#: judge/models/problem_data.py:67 judge/models/problem_data.py:142 +#: judge/models/problem_data.py:67 +msgid "enable unicode" +msgstr "" + +#: judge/models/problem_data.py:68 +msgid "disable bigInteger / bigDecimal" +msgstr "" + +#: judge/models/problem_data.py:69 judge/models/problem_data.py:146 msgid "checker arguments" msgstr "tham số của trình chấm" -#: judge/models/problem_data.py:68 judge/models/problem_data.py:143 -msgid "checker arguments as a JSON object" +#: judge/models/problem_data.py:70 judge/models/problem_data.py:147 +msgid "Checker arguments as a JSON object." msgstr "tham số của trình chấm là một JSON" -#: judge/models/problem_data.py:70 +#: judge/models/problem_data.py:72 msgid "custom checker file" msgstr "File trình chấm ngoài" -#: judge/models/problem_data.py:76 +#: judge/models/problem_data.py:80 msgid "custom grader file" msgstr "File trình grader" -#: judge/models/problem_data.py:82 +#: judge/models/problem_data.py:86 msgid "custom header file" msgstr "File header" -#: judge/models/problem_data.py:88 +#: judge/models/problem_data.py:92 msgid "grader arguments" msgstr "tham số grader" -#: judge/models/problem_data.py:89 +#: judge/models/problem_data.py:93 msgid "grader arguments as a JSON object" msgstr "tham số của grader là một JSON" -#: judge/models/problem_data.py:126 +#: judge/models/problem_data.py:130 msgid "problem data set" msgstr "tập dữ liệu bài" -#: judge/models/problem_data.py:128 +#: judge/models/problem_data.py:132 msgid "case position" msgstr "vị trí phép thử" -#: judge/models/problem_data.py:129 +#: judge/models/problem_data.py:133 msgid "case type" msgstr "kiểu test" -#: judge/models/problem_data.py:130 +#: judge/models/problem_data.py:134 msgid "Normal case" msgstr "Test đơn" -#: judge/models/problem_data.py:131 +#: judge/models/problem_data.py:135 msgid "Batch start" msgstr "Bắt đầu nhóm test" -#: judge/models/problem_data.py:132 +#: judge/models/problem_data.py:136 msgid "Batch end" msgstr "Hết nhóm test" -#: judge/models/problem_data.py:134 +#: judge/models/problem_data.py:138 msgid "input file name" msgstr "tên file input" -#: judge/models/problem_data.py:135 +#: judge/models/problem_data.py:139 msgid "output file name" msgstr "tên file output" -#: judge/models/problem_data.py:136 +#: judge/models/problem_data.py:140 msgid "generator arguments" msgstr "tham số trình sinh" -#: judge/models/problem_data.py:137 +#: judge/models/problem_data.py:141 msgid "point value" msgstr "điểm" -#: judge/models/problem_data.py:138 +#: judge/models/problem_data.py:142 msgid "case is pretest?" msgstr "là pretests?" @@ -2365,11 +2419,11 @@ msgid "organization slug" msgstr "tên viết tắt trên đường dẫn" #: judge/models/profile.py:43 -msgid "Organization name shown in URL" +msgid "Organization name shown in URLs." msgstr "Tên viết tắt của tổ chức, được dùng trong đường dẫn tới tổ chức" #: judge/models/profile.py:45 -msgid "Displayed beside user name during contests" +msgid "Displayed beside user name during contests." msgstr "Hiển thị bên cạnh tên trong các kỳ thi" #: judge/models/profile.py:46 @@ -2381,7 +2435,7 @@ msgid "administrators" msgstr "quản trị viên" #: judge/models/profile.py:48 -msgid "Those who can edit this organization" +msgid "Those who can edit this organization." msgstr "Những người có thể chỉnh sửa tổ chức" #: judge/models/profile.py:49 @@ -2393,7 +2447,7 @@ msgid "is open organization?" msgstr "tổ chức công khai" #: judge/models/profile.py:51 -msgid "Allow joining organization" +msgid "Allow joining organization." msgstr "Nếu chọn, mọi người đều có thể tham gia tổ chức." #: judge/models/profile.py:52 @@ -2411,12 +2465,12 @@ msgstr "số thành viên tối đa" #: judge/models/profile.py:55 msgid "" "Maximum amount of users in this organization, only applicable to private " -"organizations" +"organizations." msgstr "" "Số người dùng tối đa trong tổ chức này, chỉ áp dụng đối với tổ chức riêng tư" #: judge/models/profile.py:57 -msgid "Student access code" +msgid "Student access code." msgstr "Mã truy cập sinh viên" #: judge/models/profile.py:61 @@ -2463,8 +2517,8 @@ msgid "self-description" msgstr "tự mô tả" #: judge/models/profile.py:134 -msgid "location" -msgstr "vị trí" +msgid "time zone" +msgstr "múi giờ" #: judge/models/profile.py:136 msgid "preferred language" @@ -2535,7 +2589,7 @@ msgid "math engine" msgstr "" #: judge/models/profile.py:167 -msgid "the rendering engine used to render math" +msgid "The rendering engine used to render math." msgstr "chọn công cụ để hiện công thức toán" #: judge/models/profile.py:168 @@ -2543,7 +2597,7 @@ msgid "TOTP 2FA enabled" msgstr "" #: judge/models/profile.py:169 -msgid "check to enable TOTP-based two-factor authentication" +msgid "Check to enable TOTP-based two-factor authentication." msgstr "" #: judge/models/profile.py:170 @@ -2551,7 +2605,7 @@ msgid "WebAuthn 2FA enabled" msgstr "" #: judge/models/profile.py:171 -msgid "check to enable WebAuthn-based two-factor authentication" +msgid "Check to enable WebAuthn-based two-factor authentication." msgstr "" #: judge/models/profile.py:172 @@ -2559,11 +2613,11 @@ msgid "TOTP key" msgstr "Mã TOTP" #: judge/models/profile.py:173 -msgid "32 character base32-encoded key for TOTP" +msgid "32-character Base32-encoded key for TOTP." msgstr "mã 32 ký tự base32-encoded cho TOTP" #: judge/models/profile.py:175 -msgid "TOTP key must be empty or base32" +msgid "TOTP key must be empty or Base32." msgstr "Mã TOTP cần rỗng hoặc base32" #: judge/models/profile.py:176 @@ -2571,12 +2625,12 @@ msgid "scratch codes" msgstr "" #: judge/models/profile.py:177 -msgid "JSON array of 16 character base32-encoded codes for scratch codes" +msgid "JSON array of 16-character Base32-encoded codes for scratch codes." msgstr "" #: judge/models/profile.py:181 msgid "" -"Scratch codes must be empty or a JSON array of 16-character base32 codes" +"Scratch codes must be empty or a JSON array of 16-character Base32 codes." msgstr "" #: judge/models/profile.py:183 @@ -2588,7 +2642,7 @@ msgid "API token" msgstr "" #: judge/models/profile.py:185 -msgid "64 character hex-encoded API access token" +msgid "64-character hex-encoded API access token." msgstr "" #: judge/models/profile.py:187 @@ -2608,99 +2662,99 @@ msgid "display name override" msgstr "" #: judge/models/profile.py:192 -msgid "Name displayed in place of username" +msgid "Name displayed in place of username." msgstr "" -#: judge/models/profile.py:363 +#: judge/models/profile.py:368 msgid "Shows in-progress development stuff" msgstr "" -#: judge/models/profile.py:364 +#: judge/models/profile.py:369 msgid "Edit TOTP settings" msgstr "" -#: judge/models/profile.py:365 +#: judge/models/profile.py:370 msgid "Can upload image directly to server via martor" msgstr "" -#: judge/models/profile.py:366 +#: judge/models/profile.py:371 msgid "Can set high problem timelimit" msgstr "" -#: judge/models/profile.py:367 +#: judge/models/profile.py:372 msgid "Can set long contest duration" msgstr "" -#: judge/models/profile.py:368 +#: judge/models/profile.py:373 msgid "Can create unlimitted number of testcases for a problem" msgstr "" -#: judge/models/profile.py:370 +#: judge/models/profile.py:375 msgid "user profile" msgstr "hồ sơ người dùng" -#: judge/models/profile.py:371 +#: judge/models/profile.py:376 msgid "user profiles" msgstr "hồ sơ người dùng" -#: judge/models/profile.py:377 +#: judge/models/profile.py:382 msgid "device name" msgstr "" -#: judge/models/profile.py:378 +#: judge/models/profile.py:383 msgid "credential ID" msgstr "" -#: judge/models/profile.py:379 +#: judge/models/profile.py:384 msgid "public key" msgstr "" -#: judge/models/profile.py:380 +#: judge/models/profile.py:385 msgid "sign counter" msgstr "" -#: judge/models/profile.py:398 +#: judge/models/profile.py:403 #, python-format msgid "WebAuthn credential: %(name)s" msgstr "" -#: judge/models/profile.py:401 +#: judge/models/profile.py:406 msgid "WebAuthn credential" msgstr "" -#: judge/models/profile.py:402 +#: judge/models/profile.py:407 msgid "WebAuthn credentials" msgstr "" -#: judge/models/profile.py:409 +#: judge/models/profile.py:414 msgid "request time" msgstr "thời gian yêu cầu" -#: judge/models/profile.py:410 +#: judge/models/profile.py:415 msgid "state" msgstr "trạng thái" -#: judge/models/profile.py:411 templates/organization/requests/tabs.html:4 +#: judge/models/profile.py:416 templates/organization/requests/tabs.html:4 msgid "Pending" msgstr "Đang chờ" -#: judge/models/profile.py:412 templates/organization/requests/tabs.html:10 +#: judge/models/profile.py:417 templates/organization/requests/tabs.html:10 msgid "Approved" msgstr "Phê duyệt" -#: judge/models/profile.py:413 templates/organization/requests/tabs.html:13 +#: judge/models/profile.py:418 templates/organization/requests/tabs.html:13 msgid "Rejected" msgstr "Bị từ chối" -#: judge/models/profile.py:415 +#: judge/models/profile.py:420 msgid "reason" msgstr "lý do" -#: judge/models/profile.py:418 +#: judge/models/profile.py:423 msgid "organization join request" msgstr "yêu cầu tham gia tổ chức" -#: judge/models/profile.py:419 +#: judge/models/profile.py:424 msgid "organization join requests" msgstr "yêu cầu tham gia tổ chức" @@ -2736,7 +2790,7 @@ msgstr "tên phổ biến" #: judge/models/runtime.py:30 msgid "" "Common name for the language. For example, the common name for C++03, C++11, " -"and C++14 would be \"C++\"" +"and C++14 would be \"C++\"." msgstr "" "Tên phổ biến cho các ngôn ngữ. Ví dụ, tên gọi chung cho C ++ 03, 11 C ++ và " "C ++ 14 sẽ là \"C++\"" @@ -2848,65 +2902,77 @@ msgid "order in which to display this runtime" msgstr "thứ tự hiện thị runtime" #: judge/models/runtime.py:133 -msgid "Server name, hostname-style" +msgid "judge name" +msgstr "tên máy chấm" + +#: judge/models/runtime.py:133 +msgid "Server name, hostname-style." msgstr "Tên server, tên host" -#: judge/models/runtime.py:134 +#: judge/models/runtime.py:135 msgid "time of creation" msgstr "thời gian tạo" -#: judge/models/runtime.py:135 -msgid "A key to authenticate this judge" +#: judge/models/runtime.py:136 +msgid "A key to authenticate this judge." msgstr "" -#: judge/models/runtime.py:136 +#: judge/models/runtime.py:137 msgid "authentication key" msgstr "mã xác thực" -#: judge/models/runtime.py:137 +#: judge/models/runtime.py:138 msgid "block judge" msgstr "khóa trình chấm" -#: judge/models/runtime.py:138 +#: judge/models/runtime.py:139 msgid "" "Whether this judge should be blocked from connecting, even if its key is " "correct." msgstr "" -#: judge/models/runtime.py:140 +#: judge/models/runtime.py:141 +msgid "disable judge" +msgstr "tắt trình chấm" + +#: judge/models/runtime.py:142 +msgid "Whether this judge should be removed from judging queue." +msgstr "" + +#: judge/models/runtime.py:143 msgid "judge online status" msgstr "trạng thái online của máy chấm" -#: judge/models/runtime.py:141 +#: judge/models/runtime.py:144 msgid "judge start time" msgstr "thời gian bắt đầu chấm" -#: judge/models/runtime.py:142 +#: judge/models/runtime.py:145 msgid "response time" msgstr "thời gian đáp ứng" -#: judge/models/runtime.py:143 +#: judge/models/runtime.py:146 msgid "system load" msgstr "mức tải của hệ thống" -#: judge/models/runtime.py:144 +#: judge/models/runtime.py:147 msgid "Load for the last minute, divided by processors to be fair." msgstr "Mức tải ở phút vừa qua, chia cho số bộ vi xử lý." -#: judge/models/runtime.py:146 +#: judge/models/runtime.py:149 msgid "last connected IP" msgstr "" -#: judge/models/runtime.py:148 judge/models/runtime.py:190 +#: judge/models/runtime.py:151 judge/models/runtime.py:200 msgid "judges" msgstr "các máy chấm" -#: judge/models/runtime.py:189 +#: judge/models/runtime.py:199 msgid "judge" msgstr "máy chấm" #: judge/models/submission.py:22 judge/models/submission.py:56 -#: judge/models/tests/test_submission.py:104 judge/utils/problems.py:74 +#: judge/models/tests/test_submission.py:104 judge/utils/problems.py:66 msgid "Accepted" msgstr "Kết quả đúng (AC)" @@ -2935,7 +3001,7 @@ msgid "Runtime Error" msgstr "Lỗi Runtime (RE)" #: judge/models/submission.py:29 judge/models/submission.py:41 -#: judge/models/submission.py:64 judge/utils/problems.py:76 +#: judge/models/submission.py:64 judge/utils/problems.py:68 msgid "Compile Error" msgstr "Lỗi dịch (CE)" @@ -2943,9 +3009,9 @@ msgstr "Lỗi dịch (CE)" msgid "Internal Error" msgstr "Lỗi Nội Bộ" -#: judge/models/submission.py:31 -msgid "Short circuit" -msgstr "Ngắn mạch" +#: judge/models/submission.py:31 judge/models/submission.py:58 +msgid "Short Circuited" +msgstr "Ngắn Mạch" #: judge/models/submission.py:32 judge/models/submission.py:42 #: judge/models/submission.py:70 @@ -2969,10 +3035,6 @@ msgstr "Chấm điểm" msgid "Completed" msgstr "Đã Hoàn Thành" -#: judge/models/submission.py:58 -msgid "Short Circuited" -msgstr "Ngắn Mạch" - #: judge/models/submission.py:65 msgid "Internal Error (judging server error)" msgstr "Lỗi nội bộ (máy chủ chấm bài lỗi)" @@ -2981,15 +3043,15 @@ msgstr "Lỗi nội bộ (máy chủ chấm bài lỗi)" msgid "submission time" msgstr "thời điểm nộp bài" -#: judge/models/submission.py:76 judge/models/submission.py:260 +#: judge/models/submission.py:76 judge/models/submission.py:258 msgid "execution time" msgstr "thời gian chạy tối đa" -#: judge/models/submission.py:77 judge/models/submission.py:261 +#: judge/models/submission.py:77 judge/models/submission.py:259 msgid "memory usage" msgstr "bộ nhớ sử dụng" -#: judge/models/submission.py:78 judge/models/submission.py:262 +#: judge/models/submission.py:78 judge/models/submission.py:260 msgid "points granted" msgstr "điểm được cho" @@ -3041,89 +3103,89 @@ msgstr "chỉ được chạy pretest" msgid "submission lock" msgstr "khoá bài nộp" -#: judge/models/submission.py:206 +#: judge/models/submission.py:204 #, python-format msgid "Submission %(id)d of %(problem)s by %(user)s" msgstr "Bài nộp %(id)d cho %(problem)s của %(user)s" -#: judge/models/submission.py:231 +#: judge/models/submission.py:229 msgid "Abort any submission" msgstr "Ngưng chấm bài nộp" -#: judge/models/submission.py:232 +#: judge/models/submission.py:230 msgid "Rejudge the submission" msgstr "Chấm lại bài nộp" -#: judge/models/submission.py:233 +#: judge/models/submission.py:231 msgid "Rejudge a lot of submissions" msgstr "Chấm lại nhiều bài nộp" -#: judge/models/submission.py:234 +#: judge/models/submission.py:232 msgid "Submit without limit" msgstr "Nộp bài không giới hạn" -#: judge/models/submission.py:235 +#: judge/models/submission.py:233 msgid "View all submission" msgstr "Xem tất cả bài nộp" -#: judge/models/submission.py:236 +#: judge/models/submission.py:234 msgid "Resubmit others' submission" msgstr "Nộp lại bài nộp của người khác" -#: judge/models/submission.py:237 +#: judge/models/submission.py:235 msgid "Change lock status of submission" msgstr "Đổi trạng thái khoá của bài nộp" -#: judge/models/submission.py:240 templates/contest/moss.html:57 +#: judge/models/submission.py:238 msgid "submissions" msgstr "bài nộp" -#: judge/models/submission.py:244 judge/models/submission.py:256 +#: judge/models/submission.py:242 judge/models/submission.py:254 msgid "associated submission" msgstr "bài nộp liên quan" -#: judge/models/submission.py:246 +#: judge/models/submission.py:244 msgid "source code" msgstr "mã nguồn" -#: judge/models/submission.py:249 +#: judge/models/submission.py:247 #, python-format msgid "Source of %(submission)s" msgstr "Mã nguồn của %(submission)s" -#: judge/models/submission.py:258 +#: judge/models/submission.py:256 msgid "test case ID" msgstr "mã testcase" -#: judge/models/submission.py:259 +#: judge/models/submission.py:257 msgid "status flag" msgstr "cờ trạng thái" -#: judge/models/submission.py:263 +#: judge/models/submission.py:261 msgid "points possible" msgstr "khả năng điểm" -#: judge/models/submission.py:264 +#: judge/models/submission.py:262 msgid "batch number" msgstr "nhóm test số" -#: judge/models/submission.py:265 +#: judge/models/submission.py:263 msgid "judging feedback" msgstr "phản hồi từ trình chấm" -#: judge/models/submission.py:266 +#: judge/models/submission.py:264 msgid "extended judging feedback" msgstr "phản hồi đầy đủ từ trình chấm" -#: judge/models/submission.py:267 +#: judge/models/submission.py:265 msgid "program output" msgstr "output của chương trình" -#: judge/models/submission.py:275 +#: judge/models/submission.py:279 msgid "submission test case" msgstr "test case của bài" -#: judge/models/submission.py:276 +#: judge/models/submission.py:280 msgid "submission test cases" msgstr "các test case của bài" @@ -3219,10 +3281,6 @@ msgstr "" msgid "ticket" msgstr "" -#: judge/models/ticket.py:39 -msgid "poster" -msgstr "" - #: judge/models/ticket.py:41 msgid "message body" msgstr "nội dung" @@ -3281,87 +3339,79 @@ msgstr "Không được để nhóm test rỗng." msgid "How did you corrupt the custom checker path?" msgstr "Phá kiểu gì mà mất tiêu checker rồi?" -#: judge/utils/problem_data.py:152 -msgid "Why don't you use a cpp/pas/py checker?" -msgstr "Tại sao không dùng checker C++, Pascal hoặc Python?" +#: judge/utils/problem_data.py:147 +msgid "Only C++, Pascal, or Java checkers are supported." +msgstr "Chỉ hỗ trợ checker viết bằng C++, Pascal hoặc Java." -#: judge/utils/problem_data.py:166 +#: judge/utils/problem_data.py:159 msgid "How did you corrupt the custom grader path?" msgstr "Phá kiểu gì mà mất tiêu grader rồi?" -#: judge/utils/problem_data.py:185 +#: judge/utils/problem_data.py:178 msgid "You must specify both input and output files." msgstr "" -#: judge/utils/problem_data.py:189 +#: judge/utils/problem_data.py:182 msgid "Input/Output file must be a string." msgstr "Tên file input/output phải là xâu." -#: judge/utils/problem_data.py:200 +#: judge/utils/problem_data.py:193 msgid "Only accept `.cpp` interactor" msgstr "Chỉ chấp nhận `.cpp` interactor" -#: judge/utils/problem_data.py:212 +#: judge/utils/problem_data.py:205 msgid "Only accept `.cpp` entry" msgstr "Chỉ chấp nhận `.cpp`" -#: judge/utils/problem_data.py:215 +#: judge/utils/problem_data.py:208 msgid "Only accept `.h` header" msgstr "Chỉ chấp nhận file header `.h`" -#: judge/utils/problem_data.py:231 -msgid "Only accept `.py` custom judge" -msgstr "Chỉ chấP nhận `.py` grader" - -#: judge/utils/problem_data.py:243 +#: judge/utils/problem_data.py:229 #, python-format msgid "Points must be defined for non-batch case #%d." msgstr "Test #%d phải được set điểm vì không nằm trong nhóm nào." -#: judge/utils/problem_data.py:248 +#: judge/utils/problem_data.py:234 #, python-format msgid "Input file for case %(case)d does not exist: %(file)s" msgstr "Không tìm thấy file input của case %(case)d: %(file)s" -#: judge/utils/problem_data.py:251 +#: judge/utils/problem_data.py:237 #, python-format msgid "Output file for case %(case)d does not exist: %(file)s" msgstr "Không tìm thấy file output của case %(case)d: %(file)s" -#: judge/utils/problem_data.py:276 +#: judge/utils/problem_data.py:262 #, python-format msgid "Batch start case #%d requires points." msgstr "Bắt đầu nhóm, case #%d yêu cầu điểm." -#: judge/utils/problem_data.py:297 +#: judge/utils/problem_data.py:283 #, python-format -msgid "Attempt to end batch outside of one in case #%d" +msgid "Attempt to end batch outside of one in case #%d." msgstr "Kết thúc nhóm nhưng không có bắt đầu ở case #%d" -#: judge/utils/problem_data.py:315 +#: judge/utils/problem_data.py:301 msgid "How did you corrupt the zip path?" msgstr "" -#: judge/utils/problem_data.py:321 +#: judge/utils/problem_data.py:307 msgid "How did you corrupt the generator path?" msgstr "" -#: judge/utils/problems.py:75 +#: judge/utils/problems.py:67 msgid "Wrong" msgstr "" -#: judge/utils/problems.py:77 +#: judge/utils/problems.py:69 msgid "Timeout" msgstr "" -#: judge/utils/problems.py:78 +#: judge/utils/problems.py:70 msgid "Error" msgstr "" -#: judge/utils/problems.py:89 -msgid "Can't pass both queryset and keyword filters" -msgstr "" - #: judge/utils/pwned.py:109 msgid "This password is too common." msgstr "Mật khẩu này quá phổ biến." @@ -3398,13 +3448,14 @@ msgctxt "hours and minutes" msgid "%h:%m" msgstr "%h giờ, %m phút" -#: judge/views/blog.py:34 judge/views/comment.py:26 +#: judge/views/blog.py:33 judge/views/comment.py:27 msgid "Messing around, are we?" msgstr "" -#: judge/views/blog.py:43 judge/views/comment.py:35 -msgid "You must solve at least one problem before you can vote." -msgstr "Bạn phải giải ít nhất một bài trước khi có thể bỏ phiếu." +#: judge/views/blog.py:42 judge/views/comment.py:36 +#, python-format +msgid "You must solve at least %d problems before you can vote." +msgstr "Bạn phải giải ít nhất %d bài trước khi có thể bỏ phiếu." #: judge/views/blog.py:58 msgid "Blog post not found." @@ -3414,32 +3465,32 @@ msgstr "Không tìm thấy blog." msgid "You cannot vote your own blog" msgstr "Bạn không thể tự bỏ phiếu blog của mình." -#: judge/views/blog.py:78 judge/views/comment.py:70 +#: judge/views/blog.py:78 judge/views/comment.py:72 msgid "You cannot vote twice." msgstr "" -#: judge/views/blog.py:128 +#: judge/views/blog.py:129 #, python-format msgid "Page %d of Posts" msgstr "Blog - Trang %d" -#: judge/views/blog.py:278 judge/views/blog.py:281 +#: judge/views/blog.py:275 judge/views/blog.py:278 msgid "Creating new blog post" msgstr "Tạo blog mới" -#: judge/views/blog.py:291 judge/views/contests.py:1089 -#: judge/views/organization.py:295 judge/views/organization.py:597 -#: judge/views/organization.py:621 judge/views/problem.py:827 -#: judge/views/problem.py:859 judge/views/tag.py:182 +#: judge/views/blog.py:288 judge/views/contests.py:1147 +#: judge/views/organization.py:301 judge/views/organization.py:612 +#: judge/views/organization.py:636 judge/views/problem.py:830 +#: judge/views/problem.py:862 judge/views/tag.py:182 msgid "Created on site" msgstr "Tạo trên trang web" -#: judge/views/blog.py:304 judge/views/blog.py:335 judge/views/contests.py:258 -#: judge/views/contests.py:1100 +#: judge/views/blog.py:301 judge/views/blog.py:332 judge/views/contests.py:258 +#: judge/views/contests.py:1158 msgid "Permission denied" msgstr "Từ chối quyền" -#: judge/views/blog.py:305 +#: judge/views/blog.py:302 #, python-format msgid "" "You cannot create blog post.\n" @@ -3448,34 +3499,34 @@ msgstr "" "Không thể tạo blog.\n" "Lưu ý: Bạn cần giải ít nhất %d bài để có thể tạo blog." -#: judge/views/blog.py:317 judge/views/blog.py:320 +#: judge/views/blog.py:314 judge/views/blog.py:317 msgid "Updating blog post" msgstr "Cập nhật blog" -#: judge/views/blog.py:329 judge/views/comment.py:131 -#: judge/views/contests.py:1160 judge/views/organization.py:338 -#: judge/views/problem.py:931 +#: judge/views/blog.py:326 judge/views/comment.py:133 +#: judge/views/contests.py:1218 judge/views/organization.py:347 +#: judge/views/problem.py:934 msgid "Edited from site" msgstr "Chỉnh sửa từ trang web" -#: judge/views/blog.py:336 +#: judge/views/blog.py:333 msgid "You cannot edit blog post." msgstr "Bạn không thể chỉnh sửa blog." -#: judge/views/comment.py:50 +#: judge/views/comment.py:52 msgid "Comment not found." msgstr "Không tìm thấy bình luận." -#: judge/views/comment.py:53 +#: judge/views/comment.py:55 msgid "You cannot vote on your own comments." msgstr "Bạn không thể tự đánh giá bình luận của mình." -#: judge/views/comment.py:152 +#: judge/views/comment.py:154 msgid "Editing comment" msgstr "Đang chỉnh sửa bình luận" #: judge/views/contests.py:65 judge/views/contests.py:248 -#: judge/views/contests.py:251 judge/views/contests.py:520 +#: judge/views/contests.py:251 judge/views/contests.py:546 msgid "No such contest" msgstr "Không có kỳ thi nào" @@ -3497,162 +3548,166 @@ msgstr "Không thể tìm thấy kỳ thi nào." msgid "Access to contest \"%s\" denied" msgstr "Bị từ chối truy cập vào kỳ thi \"%s\"" -#: judge/views/contests.py:332 +#: judge/views/contests.py:358 msgid "Clone Contest" msgstr "Nhân bản kỳ thi" -#: judge/views/contests.py:336 +#: judge/views/contests.py:362 msgid "You are not allowed to clone contests." msgstr "Bạn không có quyền nhân bản kỳ thi." -#: judge/views/contests.py:341 judge/views/contests.py:387 -#: judge/views/contests.py:1111 judge/views/contests.py:1178 +#: judge/views/contests.py:367 judge/views/contests.py:413 +#: judge/views/contests.py:1169 judge/views/contests.py:1236 msgid "You are not allowed to edit this contest." msgstr "Bạn không có quyền chỉnh sửa kỳ thi này." -#: judge/views/contests.py:374 +#: judge/views/contests.py:400 #, python-format msgid "Cloned contest from %s" msgstr "Nhân bản từ kỳ thi %s" -#: judge/views/contests.py:380 +#: judge/views/contests.py:406 msgid "Create contest announcement" msgstr "Tạo thông báo kỳ thi" -#: judge/views/contests.py:432 +#: judge/views/contests.py:458 msgid "Contest not ongoing" msgstr "Kỳ thi đang không diễn ra" -#: judge/views/contests.py:433 +#: judge/views/contests.py:459 #, python-format msgid "\"%s\" is not currently ongoing." msgstr "\"%s\" đang không diễn ra." -#: judge/views/contests.py:438 +#: judge/views/contests.py:464 msgid "Banned from joining" msgstr "Không thể tham gia" -#: judge/views/contests.py:439 +#: judge/views/contests.py:465 msgid "" "You have been declared persona non grata for this contest. You are " "permanently barred from joining this contest." msgstr "Bạn đã được coi là cá nhận không tham gia kỳ thi này." -#: judge/views/contests.py:445 +#: judge/views/contests.py:471 msgid "Virtual joining not allowed" msgstr "Không được phép tham gia ảo" -#: judge/views/contests.py:446 +#: judge/views/contests.py:472 msgid "Virtual joining is not allowed for this contest." msgstr "Kỳ thi này không cho phép tham gia ảo." -#: judge/views/contests.py:504 +#: judge/views/contests.py:530 #, python-format msgid "Enter access code for \"%s\"" msgstr "Nhập access code của \"%s\"" -#: judge/views/contests.py:521 +#: judge/views/contests.py:547 #, python-format msgid "You are not in contest \"%s\"." msgstr "Bạn đang không tham gia kỳ thi \"%s\"." -#: judge/views/contests.py:540 -msgid "ContestCalendar requires integer year and month" -msgstr "Tháng và năm phải là số nguyên" - -#: judge/views/contests.py:580 +#: judge/views/contests.py:605 #, python-format msgid "Contests in %(month)s" msgstr "Kỳ thi trong tháng %(month)s" -#: judge/views/contests.py:580 +#: judge/views/contests.py:605 msgid "F Y" msgstr "" -#: judge/views/contests.py:638 +#: judge/views/contests.py:663 #, python-format msgid "%s Statistics" msgstr "%s Thống kê" -#: judge/views/contests.py:821 +#: judge/views/contests.py:848 #, python-format msgid "%s Rankings" msgstr "Bảng xếp hạng của %s" -#: judge/views/contests.py:852 +#: judge/views/contests.py:892 msgid "???" msgstr "" -#: judge/views/contests.py:894 +#: judge/views/contests.py:930 +msgid "Ranking access code required" +msgstr "" + +#: judge/views/contests.py:931 +msgid "You need to provide a valid ranking access code to access this page." +msgstr "" + +#: judge/views/contests.py:942 #, python-format msgid "%s Official Rankings" msgstr "Bảng xếp hạng chính thức của %s" -#: judge/views/contests.py:927 +#: judge/views/contests.py:984 #, python-format -msgid "Your participation in %s" -msgstr "Các lần tham gia %s của bạn" +msgid "Your participation in %(contest)s" +msgstr "Các lần tham gia %(contest)s của bạn" -#: judge/views/contests.py:928 +#: judge/views/contests.py:985 #, python-format msgid "%(user)s's participation in %(contest)s" msgstr "Các lần tham gia %(contest)s của %(user)s" -#: judge/views/contests.py:936 +#: judge/views/contests.py:994 msgid "Live" msgstr "Trực tuyến" -#: judge/views/contests.py:947 templates/contest/contest-tabs.html:16 +#: judge/views/contests.py:1005 templates/contest/contest-tabs.html:16 msgid "Participation" msgstr "Tham gia" -#: judge/views/contests.py:979 +#: judge/views/contests.py:1037 msgid "You are not allowed to run MOSS." msgstr "Bạn không có quyền chạy MOSS." -#: judge/views/contests.py:992 +#: judge/views/contests.py:1050 #, python-format msgid "%s MOSS Results" msgstr "" -#: judge/views/contests.py:1019 +#: judge/views/contests.py:1077 #, python-format msgid "Running MOSS for %s..." msgstr "" -#: judge/views/contests.py:1042 +#: judge/views/contests.py:1100 #, python-format msgid "Contest tag: %s" msgstr "Thẻ kỳ thi %s" -#: judge/views/contests.py:1050 +#: judge/views/contests.py:1108 msgid "You are not allowed to create contests." msgstr "Bạn không có quyền tạo kỳ thi mới." -#: judge/views/contests.py:1058 judge/views/contests.py:1061 +#: judge/views/contests.py:1116 judge/views/contests.py:1119 #: templates/organization/tabs.html:27 msgid "Create new contest" msgstr "Tạo kỳ thi mới" -#: judge/views/contests.py:1126 +#: judge/views/contests.py:1184 #, python-brace-format msgid "Editing contest {0}" msgstr "Sửa kỳ thi {0}" -#: judge/views/contests.py:1129 +#: judge/views/contests.py:1187 #, python-format msgid "Editing contest %s" msgstr "Sửa kỳ thi %s" -#: judge/views/contests.py:1180 +#: judge/views/contests.py:1238 msgid "Please wait until the contest has ended to download data." msgstr "Vui lòng đợi sau khi kỳ thi kết thúc để tải dữ liệu." -#: judge/views/contests.py:1185 +#: judge/views/contests.py:1243 msgid "Download contest data" msgstr "Tải dữ liệu kỳ thi" -#: judge/views/contests.py:1218 +#: judge/views/contests.py:1276 #, python-format msgid "Preparing data for %s..." msgstr "Chuẩn bị dữ liệu cho %s..." @@ -3676,7 +3731,7 @@ msgstr "không cho phép %s" msgid "corrupt page %s" msgstr "trang lỗi %s" -#: judge/views/language.py:12 templates/status/judge-status-table.html:9 +#: judge/views/language.py:12 templates/status/judge-status-table.html:10 #: templates/status/status-tabs.html:6 msgid "Runtimes" msgstr "Các ngôn ngữ" @@ -3712,107 +3767,120 @@ msgstr "Bạn đã trong tổ chức." msgid "This organization is not open." msgstr "Tổ chức này không phải là mở." -#: judge/views/organization.py:141 +#: judge/views/organization.py:143 judge/views/organization.py:145 msgid "Leaving organization" msgstr "Rời khỏi tổ chức" -#: judge/views/organization.py:141 +#: judge/views/organization.py:143 #, python-format msgid "You are not in \"%s\"." msgstr "Bạn đang không ở trong \"%s\"." -#: judge/views/organization.py:159 +#: judge/views/organization.py:145 +msgid "You cannot leave an organization you own." +msgstr "Bạn không thể rời tổ chức của chính mình." + +#: judge/views/organization.py:163 #, python-format msgid "Can't request to join %s" msgstr "Không thể yêu cầu tham gia %s" -#: judge/views/organization.py:160 +#: judge/views/organization.py:164 #, python-format msgid "You already have a pending request to join %s." msgstr "" -#: judge/views/organization.py:167 +#: judge/views/organization.py:171 #, python-format msgid "Request to join %s" msgstr "Yêu cầu tham gia %s" -#: judge/views/organization.py:185 +#: judge/views/organization.py:189 msgid "Join request detail" msgstr "Chi tiết yêu cầu tham gia" -#: judge/views/organization.py:213 judge/views/organization.py:214 +#: judge/views/organization.py:217 judge/views/organization.py:218 #, python-format msgid "Managing join requests for %s" msgstr "Quản lý các yêu cầu tham gia %s" -#: judge/views/organization.py:247 +#: judge/views/organization.py:251 #, python-format -msgid "" -"Your organization can only receive %(can_add)d more members. You cannot " -"approve %(to_approve)d users." -msgstr "" -"Tổ chức của bạn chỉ có thể nhận thêm %(can_add)d thành viên. Bạn không thể " -"nhận thêm %(to_approve)d thành viên." +msgid "Your organization can only receive %d more member." +msgid_plural "Your organization can only receive %d more members." +msgstr[0] "Tổ chức của bạn chỉ có thể nhận thêm %d thành viên." + +#: judge/views/organization.py:253 +#, python-format +msgid "You cannot approve %d user." +msgid_plural "You cannot approve %d users." +msgstr[0] "Bạn không thể chấp nhận %d thành viên." -#: judge/views/organization.py:260 +#: judge/views/organization.py:266 #, python-format msgid "Approved %d user." msgid_plural "Approved %d users." -msgstr[0] "Chấp nhận %d thành viên." +msgstr[0] "Đã chấp nhận %d thành viên." -#: judge/views/organization.py:261 +#: judge/views/organization.py:267 #, python-format msgid "Rejected %d user." msgid_plural "Rejected %d users." -msgstr[0] "Từ chối %d thành viên." +msgstr[0] "Đã từ chối %d thành viên." -#: judge/views/organization.py:291 templates/organization/list-tabs.html:5 +#: judge/views/organization.py:297 templates/organization/list-tabs.html:5 msgid "Create new organization" msgstr "Tạo tổ chức mới" -#: judge/views/organization.py:314 judge/views/organization.py:318 +#: judge/views/organization.py:323 judge/views/organization.py:327 msgid "Can't create organization" msgstr "Không thể tạo tổ chức" -#: judge/views/organization.py:319 +#: judge/views/organization.py:328 msgid "You are not allowed to create new organizations." msgstr "Bạn không có quyền tạo tổ chức mới." -#: judge/views/organization.py:328 +#: judge/views/organization.py:337 #, python-format msgid "Editing %s" msgstr "Đang chỉnh sửa %s" -#: judge/views/organization.py:346 judge/views/organization.py:354 +#: judge/views/organization.py:355 judge/views/organization.py:363 msgid "Can't edit organization" msgstr "Không thể chỉnh sửa tổ chức" -#: judge/views/organization.py:347 +#: judge/views/organization.py:356 msgid "You are not allowed to edit this organization." msgstr "Bạn không có quyền chỉnh sửa tổ chức này." -#: judge/views/organization.py:355 +#: judge/views/organization.py:364 msgid "You are not allowed to kick people from this organization." msgstr "Bạn không có quyền loại người từ tổ chức này." -#: judge/views/organization.py:360 judge/views/organization.py:364 +#: judge/views/organization.py:369 judge/views/organization.py:373 +#: judge/views/organization.py:378 msgid "Can't kick user" msgstr "Không thể loại thành viên" -#: judge/views/organization.py:361 +#: judge/views/organization.py:370 msgid "The user you are trying to kick does not exist!" msgstr "Thành viên bạn muốn loại không tồn tại!" -#: judge/views/organization.py:365 +#: judge/views/organization.py:374 #, python-format -msgid "The user you are trying to kick is not in organization: %s." +msgid "The user you are trying to kick is not in organization: %s" msgstr "Thành viên mà bạn muốn loại không thuộc tổ chức: %s." -#: judge/views/organization.py:401 +#: judge/views/organization.py:379 +#, python-format +msgid "The user you are trying to kick is an admin of organization: %s." +msgstr "Thành viên mà bạn muốn loại là admin của tổ chức: %s." + +#: judge/views/organization.py:415 msgid "Cannot view organization's private data" msgstr "Không thể xem các dữ liệu riêng tư của tổ chức" -#: judge/views/organization.py:402 +#: judge/views/organization.py:416 msgid "You must join the organization to view its private data." msgstr "Bạn phải tham gia tổ chức để xem các dữ liệu riêng tư của tổ chức." @@ -3843,75 +3911,84 @@ msgstr "Không thể tìm thấy lời giải của bài tập \"%s\"." msgid "Problem list" msgstr "Danh sách bài" -#: judge/views/problem.py:520 +#: judge/views/problem.py:521 msgid "Suggested problem list" msgstr "Danh sách các bài được đề xuất" -#: judge/views/problem.py:594 judge/views/problem.py:602 +#: judge/views/problem.py:595 judge/views/problem.py:603 #, python-format msgid "Submit to %s" msgstr "Nộp bài %s" -#: judge/views/problem.py:651 +#: judge/views/problem.py:648 +msgid "You submitted too many submissions." +msgstr "Bạn đã nộp bài quá nhiều lần." + +#: judge/views/problem.py:652 msgid "Banned from submitting" msgstr "Không thể nộp bài" -#: judge/views/problem.py:652 +#: judge/views/problem.py:653 msgid "" "You have been declared persona non grata for this problem. You are " -"permanently barred from submitting this problem." +"permanently barred from submitting to this problem." msgstr "" -"Bạn đã được coi là cá nhận không tính điểm cho đề này. Bạn không thể nộp bài." +"Bạn đã được coi là cá nhân không tính điểm cho đề này. Bạn không thể nộp bài " +"này." -#: judge/views/problem.py:656 +#: judge/views/problem.py:657 msgid "Too many submissions" msgstr "Quá nhiều bài nộp" -#: judge/views/problem.py:657 +#: judge/views/problem.py:658 msgid "You have exceeded the submission limit for this problem." msgstr "Bạn đã vượt quá giới hạn lần nộp của bài này." -#: judge/views/problem.py:757 +#: judge/views/problem.py:739 +msgid "You are not allowed to submit to this problem." +msgstr "Bạn không có quyền nộp bài này." + +#: judge/views/problem.py:760 msgid "Clone Problem" msgstr "Nhân bản bài tập" -#: judge/views/problem.py:785 +#: judge/views/problem.py:788 #, python-format msgid "Cloned problem from %s" msgstr "Nhân bản từ bài %s" -#: judge/views/problem.py:808 judge/views/problem.py:811 +#: judge/views/problem.py:811 judge/views/problem.py:814 msgid "Creating new problem" msgstr "Tạo bài tập mới" -#: judge/views/problem.py:845 judge/views/problem.py:848 +#: judge/views/problem.py:848 judge/views/problem.py:851 msgid "Suggesting new problem" msgstr "Đề xuất bài tập" -#: judge/views/problem.py:872 +#: judge/views/problem.py:875 #, python-brace-format msgid "Editing problem {0}" msgstr "Sửa đề bài {0}" -#: judge/views/problem.py:875 +#: judge/views/problem.py:878 #, python-format msgid "Editing problem %s" msgstr "Sửa đề bài %s" -#: judge/views/problem.py:942 +#: judge/views/problem.py:945 msgid "Can't edit problem" msgstr "Không thể sửa bài" -#: judge/views/problem.py:943 +#: judge/views/problem.py:946 msgid "You are not allowed to edit this problem." msgstr "Bạn không có quyền chỉnh sửa bài tập này." #: judge/views/problem_data.py:40 -msgid "Checker arguments must be a JSON object" +msgid "Checker arguments must be a JSON object." msgstr "Tham số trình chấm phải là một JSON" #: judge/views/problem_data.py:42 -msgid "Checker arguments is invalid JSON" +msgid "Checker arguments is invalid JSON." msgstr "Tham số trình chấm không hợp lệ (không phải JSON)" #: judge/views/problem_data.py:52 @@ -4016,11 +4093,11 @@ msgid "Best solutions for problem %(number)s in %(contest)s" msgstr "Các bài giải tốt nhất cho bài %(number)s trong %(contest)s" #: judge/views/register.py:27 -msgid "A username must contain letters, numbers, or underscores" +msgid "A username must contain letters, numbers, or underscores." msgstr "Tên người dùng phải chứa chữ cái, số hoặc dấu gạch chân" -#: judge/views/register.py:29 templates/registration/registration_form.html:137 -#: templates/user/edit-profile.html:286 +#: judge/views/register.py:29 templates/registration/registration_form.html:143 +#: templates/user/edit-profile.html:280 msgid "Full name" msgstr "Họ và tên" @@ -4049,23 +4126,23 @@ msgstr "" "Nhà cung cấp email của bạn không được phép do phát tán thư rác. Xin vui lòng " "sử dụng một nhà cung cấp email có uy tín." -#: judge/views/register.py:58 +#: judge/views/register.py:67 msgid "Register" msgstr "Đăng ký" -#: judge/views/register.py:106 +#: judge/views/register.py:115 msgid "Activation Key Invalid" msgstr "Mã kích hoạt không hợp lệ" -#: judge/views/register.py:117 +#: judge/views/register.py:126 msgid "Authentication failure" msgstr "Xác thực không thành công" -#: judge/views/stats.py:116 +#: judge/views/stats.py:115 msgid "New Problems" msgstr "Bài mới" -#: judge/views/status.py:24 templates/submission/list.html:317 +#: judge/views/status.py:24 templates/submission/list.html:315 msgid "Status" msgstr "Trạng thái" @@ -4081,92 +4158,84 @@ msgstr "Trạng thái của OJ" msgid "Version matrix" msgstr "Phiên bản của trình chấm" -#: judge/views/submission.py:81 +#: judge/views/submission.py:82 #, python-format msgid "Permission denied. Solve %(problem)s in order to view it." msgstr "Từ chối quyền. Hãy giải %(problem)s để xem." -#: judge/views/submission.py:86 judge/views/submission.py:88 +#: judge/views/submission.py:87 judge/views/submission.py:89 msgid "Can't access submission" msgstr "Không thể xem bài nộp" -#: judge/views/submission.py:88 +#: judge/views/submission.py:89 msgid "Permission denied." msgstr "Từ chối quyền." -#: judge/views/submission.py:92 judge/views/submission.py:99 +#: judge/views/submission.py:93 judge/views/submission.py:100 #, python-format msgid "Submission of %(problem)s by %(user)s" msgstr "Bài nộp %(problem)s của %(user)s" -#: judge/views/submission.py:150 judge/views/submission.py:155 +#: judge/views/submission.py:151 judge/views/submission.py:156 #, python-format msgid "Comparing submission %(first)s with %(second)s" msgstr "So sánh bài nộp %(first)s và %(second)s" -#: judge/views/submission.py:333 judge/views/submission.py:334 +#: judge/views/submission.py:354 judge/views/submission.py:355 #: templates/problem/problem.html:153 templates/problem/problem.html:162 msgid "All submissions" msgstr "Danh sách bài nộp" -#: judge/views/submission.py:500 judge/views/submission.py:505 +#: judge/views/submission.py:521 judge/views/submission.py:526 msgid "All my submissions" msgstr "Danh sách bài nộp của tôi" -#: judge/views/submission.py:501 judge/views/submission.py:506 +#: judge/views/submission.py:522 judge/views/submission.py:527 #, python-format msgid "All submissions by %s" msgstr "Danh sách bài nộp bởi %s" -#: judge/views/submission.py:542 judge/views/submission.py:545 +#: judge/views/submission.py:563 judge/views/submission.py:566 #, python-format msgid "All submissions for %s" msgstr "Danh sách bài nộp cho %s" -#: judge/views/submission.py:567 -msgid "Must pass a problem" -msgstr "Phải giải được 1 bài" - -#: judge/views/submission.py:616 judge/views/submission.py:623 +#: judge/views/submission.py:637 judge/views/submission.py:644 #, python-format msgid "My submissions for %(problem)s" msgstr "Danh sách bài nộp của tôi cho %(problem)s" -#: judge/views/submission.py:617 judge/views/submission.py:627 +#: judge/views/submission.py:638 judge/views/submission.py:648 #, python-format msgid "%(user)s's submissions for %(problem)s" msgstr "Danh sách bài nộp của %(user)s cho %(problem)s" -#: judge/views/submission.py:722 -msgid "Must pass a contest" -msgstr "Phải vượt qua một kỳ thi" - -#: judge/views/submission.py:729 +#: judge/views/submission.py:750 #, python-brace-format msgid "All submissions in {0}" msgstr "Danh sách các bài nộp trong {0}" -#: judge/views/submission.py:741 judge/views/submission.py:759 +#: judge/views/submission.py:762 judge/views/submission.py:780 #, python-format msgid "My submissions in %(contest)s" msgstr "Danh sách bài nộp của tôi trong %(contest)s" -#: judge/views/submission.py:742 judge/views/submission.py:763 +#: judge/views/submission.py:763 judge/views/submission.py:784 #, python-format msgid "%(user)s's submissions in %(contest)s" msgstr "Danh sách bài nộp của %(user)s trong %(contest)s" -#: judge/views/submission.py:781 judge/views/submission.py:799 +#: judge/views/submission.py:802 judge/views/submission.py:820 #, python-brace-format msgid "{user}'s submissions for {problem} in {contest}" msgstr "Danh sách bài nộp của {user} cho {problem} trong {contest}" -#: judge/views/submission.py:786 +#: judge/views/submission.py:807 #, python-brace-format msgid "{user}'s submissions for problem {number} in {contest}" msgstr "Danh sách bài nộp của {user} cho bài {number} trong {contest}" -#: judge/views/submission.py:807 +#: judge/views/submission.py:828 #, python-brace-format msgid "{user}'s submissions for problem {label} in {contest}" msgstr "Danh sách bài nộp của {user} cho bài {label} trong {contest}" @@ -4309,85 +4378,69 @@ msgstr "Đăng nhập" msgid "M j, Y, G:i" msgstr "j M, Y, G:i" -#: judge/views/user.py:237 +#: judge/views/user.py:227 msgid "Ban user" msgstr "" -#: judge/views/user.py:246 +#: judge/views/user.py:236 #, python-format msgid "Banned by %s" msgstr "Ban bởi %s" -#: judge/views/user.py:373 +#: judge/views/user.py:279 +#, python-format +msgid "Page %d of Comments" +msgstr "Bình luận - Trang %d" + +#: judge/views/user.py:396 msgid "Preparing your data..." msgstr "" -#: judge/views/user.py:377 templates/user/edit-profile.html:358 +#: judge/views/user.py:400 templates/user/edit-profile.html:351 msgid "Download your data" msgstr "" -#: judge/views/user.py:435 +#: judge/views/user.py:458 msgid "Updated on site" msgstr "Cập Nhật trên trang web" -#: judge/views/user.py:469 templates/admin/auth/user/change_form.html:14 -#: templates/admin/auth/user/change_form.html:17 templates/base.html:283 -#: templates/user/user-tabs.html:23 +#: judge/views/user.py:492 templates/admin/auth/user/change_form.html:14 +#: templates/admin/auth/user/change_form.html:17 templates/base.html:275 +#: templates/user/user-tabs.html:26 msgid "Edit profile" msgstr "Chỉnh sửa hồ sơ" -#: judge/views/user.py:484 +#: judge/views/user.py:507 msgid "Generated API token for user" msgstr "" -#: judge/views/user.py:496 +#: judge/views/user.py:519 msgid "Removed API token for user" msgstr "" -#: judge/views/user.py:506 +#: judge/views/user.py:529 msgid "Generated scratch codes for user" msgstr "" -#: judge/views/user.py:512 templates/user/user-list-tabs.html:4 +#: judge/views/user.py:535 templates/user/user-list-tabs.html:4 msgid "Leaderboard" msgstr "Bảng xếp hạng" -#: judge/views/user.py:547 templates/user/user-list-tabs.html:5 +#: judge/views/user.py:570 templates/user/user-list-tabs.html:5 msgid "Contributors" msgstr "Đóng góp" -#: judge/views/user.py:622 +#: judge/views/user.py:645 msgid "You have been successfully logged out." msgstr "Bạn đã đăng xuất thành công." -#: judge/views/widgets.py:53 judge/views/widgets.py:63 -#, python-format -msgid "Invalid upstream data: %s" -msgstr "Dữ liệu nguồn không hợp lệ %s" - -#: judge/views/widgets.py:73 -msgid "Bad latitude or longitude" -msgstr "Sai tọa độ" - -#: judge/views/widgets.py:132 -msgid "CSRF verification failed" -msgstr "Xác thực CSRF không thành công" +#: judge/views/user.py:653 +msgid "Password reset" +msgstr "Đặt lại mật khẩu" -#: judge/views/widgets.py:133 -msgid "" -"This error should not happend in normal operation. Mostly this is because we " -"are under a DDOS attack and we need to raise our shield to protect the site " -"from the attack.\n" -"\n" -"If you see this error, please return to the homepage and try again.DO NOT " -"hit F5/reload/refresh page, it will cause this error again." +#: judge/views/user.py:663 +msgid "You have sent too many password reset requests. Please try again later." msgstr "" -"Đại đa số trường hợp thì lỗi này không xuất hiện. Đây có thể là do chúng tôi " -"đang bị tấn công DDOS và chúng tôi phải nâng cao tính bảo mật để bảo vệ " -"server.\n" -"\n" -"Nếu bạn thấy lỗi này, hãy trở về trang chủ. ĐỪNG nhấn F5/tải lại trang, vì " -"nó sẽ tiếp tục gây ra lỗi này." #: templates/admin/judge/contest/change_form.html:9 msgid "Are you sure you want to rejudge ALL the submissions?" @@ -4410,22 +4463,34 @@ msgstr "" msgid "Rate all ratable contests" msgstr "" -#: templates/admin/judge/judge/change_form.html:15 -#: templates/admin/judge/judge/change_form.html:18 +#: templates/admin/judge/judge/change_form.html:16 +#: templates/admin/judge/judge/change_form.html:19 msgid "Disconnect" msgstr "Ngắt kết nối" -#: templates/admin/judge/judge/change_form.html:20 -#: templates/admin/judge/judge/change_form.html:23 +#: templates/admin/judge/judge/change_form.html:21 +#: templates/admin/judge/judge/change_form.html:24 msgid "Terminate" msgstr "Chấm dứt" +#: templates/admin/judge/judge/change_form.html:27 +#: templates/admin/judge/judge/change_form.html:30 +#: templates/user/edit-profile.html:361 templates/user/edit-profile.html:364 +msgid "Disable" +msgstr "Tắt" + +#: templates/admin/judge/judge/change_form.html:33 +#: templates/admin/judge/judge/change_form.html:36 +#: templates/user/edit-profile.html:392 +msgid "Enable" +msgstr "Bật" + #: templates/admin/judge/problem/change_form.html:14 msgid "View Submissions" msgstr "Xem các bài nộp" #: templates/admin/judge/problem/change_form.html:17 -#: templates/user/user-base.html:63 +#: templates/user/user-base.html:39 msgid "View submissions" msgstr "Xem các bài nộp" @@ -4434,60 +4499,54 @@ msgstr "Xem các bài nộp" msgid "Edit user" msgstr "Cập nhật người dùng" -#: templates/admin/judge/submission/change_form.html:14 -#: templates/admin/judge/submission/change_form.html:17 -#: templates/submission/source.html:76 templates/submission/status.html:83 -msgid "Rejudge" -msgstr "Chấm lại" - -#: templates/base.html:260 +#: templates/base.html:252 msgid "Report issue" msgstr "Báo cáo vấn đề" -#: templates/base.html:276 +#: templates/base.html:268 #, python-format msgid "Hello, %(username)s." msgstr "Xin chào, %(username)s." -#: templates/base.html:285 +#: templates/base.html:277 msgid "Stop impersonating" msgstr "Ngừng mạo danh" -#: templates/base.html:290 +#: templates/base.html:282 msgid "Log out" msgstr "Đăng xuất" -#: templates/base.html:299 +#: templates/base.html:291 #: templates/registration/password_reset_complete.html:4 msgid "Log in" msgstr "Đăng nhập" -#: templates/base.html:300 templates/registration/registration_form.html:182 +#: templates/base.html:292 templates/registration/registration_form.html:188 msgid "or" msgstr "hoặc" -#: templates/base.html:301 +#: templates/base.html:293 msgid "Sign up" msgstr "Đăng ký" -#: templates/base.html:314 +#: templates/base.html:306 msgid "spectating" msgstr "spectating" -#: templates/base.html:325 +#: templates/base.html:317 msgid "Go to Rankings" msgstr "Tới bảng xếp hạng" -#: templates/base.html:332 +#: templates/base.html:324 msgid "This site works best with JavaScript enabled." msgstr "Trang web này hoạt động tốt nhất khi JavaScript được cho phép." -#: templates/base.html:362 +#: templates/base.html:354 #, python-format msgid "proudly powered by %(dmoj)s" msgstr "dựa trên nền tảng %(dmoj)s" -#: templates/base.html:363 +#: templates/base.html:355 #, python-format msgid "follow us on %(github)s and %(facebook)s" msgstr "theo dõi VNOI trên %(github)s và %(facebook)s" @@ -4502,7 +4561,8 @@ msgstr "Blog" #: templates/blog/blog-post.html:10 templates/blog/blog-post.html:19 #: templates/blog/content.html:22 templates/blog/content.html:31 -#: templates/comments/list.html:25 templates/comments/list.html:34 +#: templates/comments/list.html:36 templates/comments/list.html:45 +#: templates/user/comment.html:29 templates/user/comment.html:38 msgid "Please log in to vote" msgstr "Hãy đăng nhập để bình chọn" @@ -4511,13 +4571,17 @@ msgstr "Hãy đăng nhập để bình chọn" msgid "posted on {time}" msgstr "đã đăng vào {time}" -#: templates/blog/blog-post.html:55 +#: templates/blog/blog-post.html:45 +msgid "Continue reading..." +msgstr "Đọc tiếp..." + +#: templates/blog/blog-post.html:58 #, python-brace-format msgid "o{time}" msgstr "o{time}" -#: templates/blog/content.html:49 templates/comments/list.html:84 -#: templates/comments/list.html:99 templates/contest/contest-tabs.html:27 +#: templates/blog/content.html:49 templates/comments/list.html:94 +#: templates/comments/list.html:110 templates/contest/contest-tabs.html:27 #: templates/contest/tag-title.html:9 templates/flatpages/admin_link.html:3 #: templates/license.html:10 templates/problem/editorial.html:14 msgid "Edit" @@ -4537,59 +4601,57 @@ msgid "Please read the [guidelines][0] before creating a new blog post." msgstr "Hãy đọc [nội quy blog][0] trước khi tạo blog mới." #: templates/blog/edit.html:43 templates/contest/edit.html:138 -#: templates/organization/edit.html:54 +#: templates/organization/edit.html:26 #: templates/organization/requests/pending.html:34 #: templates/problem/editor.html:131 templates/ticket/edit-notes.html:4 msgid "Update" msgstr "Cập nhật" #: templates/blog/edit.html:43 templates/contest/edit.html:140 -#: templates/organization/edit.html:52 templates/organization/new.html:10 +#: templates/organization/edit.html:24 templates/organization/new.html:10 #: templates/problem/suggest.html:19 templates/tag/create.html:27 msgid "Create" msgstr "Tạo" -#: templates/blog/list.html:93 +#: templates/blog/list.html:48 msgid "Blog" msgstr "Blog" -#: templates/blog/list.html:95 +#: templates/blog/list.html:50 msgid "Events" msgstr "Sự kiện" -#: templates/blog/list.html:114 +#: templates/blog/list.html:69 msgid "My open tickets" msgstr "Báo cáo của tôi" -#: templates/blog/list.html:135 +#: templates/blog/list.html:90 msgid "New tickets" msgstr "Báo cáo mới" -#: templates/blog/list.html:156 +#: templates/blog/list.html:111 templates/contest/list.html:192 msgid "Ongoing contests" -msgstr "Kỳ thi đang diễn ra" +msgstr "Các kỳ thi đang diễn ra" -#: templates/blog/list.html:164 templates/contest/list.html:187 -#: templates/contest/list.html:226 +#: templates/blog/list.html:119 #, python-format -msgid "Ends in %(countdown)s" +msgid "Ends in %(countdown)s." msgstr "Kết thúc trong %(countdown)s." -#: templates/blog/list.html:174 +#: templates/blog/list.html:129 templates/contest/list.html:229 msgid "Upcoming contests" -msgstr "Kỳ thi sắp tới" +msgstr "Các kỳ thi sắp tới" -#: templates/blog/list.html:182 templates/contest/contest.html:55 -#: templates/contest/list.html:260 +#: templates/blog/list.html:137 templates/contest/contest.html:55 #, python-format msgid "Starting in %(countdown)s." msgstr "Bắt đầu trong %(countdown)s." -#: templates/blog/list.html:199 +#: templates/blog/list.html:154 msgid "Comment stream" msgstr "Dòng bình luận" -#: templates/blog/list.html:228 templates/organization/home.html:161 +#: templates/blog/list.html:183 templates/organization/home.html:161 msgid "New problems" msgstr "Bài mới" @@ -4609,7 +4671,68 @@ msgstr "Xem đầy đủ" msgid "Top users" msgstr "Top thành viên" -#: templates/comments/list.html:3 templates/user/prepare-data.html:97 +#: templates/comments/base-media-js.html:11 +msgid "Replying to comment" +msgstr "Trả lời bình luận" + +#: templates/comments/base-media-js.html:71 +#, python-brace-format +msgid "edit {edits}" +msgstr "chỉnh sửa {edits}" + +#: templates/comments/base-media-js.html:74 +msgid "original" +msgstr "" + +#: templates/comments/base-media-js.html:76 templates/comments/list.html:78 +#: templates/user/comment.html:75 +msgid "edited" +msgstr "chỉnh sửa" + +#: templates/comments/base-media-js.html:100 +#, python-brace-format +msgid "Could not vote: {error}" +msgstr "" + +#: templates/comments/base-media-js.html:136 +msgid "Are you sure you want to hide this comment?" +msgstr "Bạn có chắc muốn ẩn bình luận này?" + +#: templates/comments/base-media-js.html:144 +msgid "Could not hide comment." +msgstr "Không thể ẩn bình luận." + +#: templates/comments/base-media-js.html:179 +msgid "updated" +msgstr "đã cập nhật" + +#: templates/comments/base-media-js.html:181 +msgid "Failed to update comment body." +msgstr "Không thể cập nhật bình luận." + +#: templates/comments/base-media-js.html:184 +#, python-brace-format +msgid "Could not edit comment: {error}" +msgstr "Không thể chỉnh sửa bình luận: {error}" + +#: templates/comments/base-media-js.html:225 +msgid "" +"Looks like you're trying to post a source code!\n" +"\n" +"The comment section are not for posting source code.\n" +"If you want to submit your solution, please use the \"Submit solution\" " +"button.\n" +"\n" +"Are you sure you want to post this?" +msgstr "" + +#: templates/comments/edit-ajax.html:16 templates/comments/list.html:168 +#: templates/ticket/ticket.html:205 +msgid "Post!" +msgstr "Đăng!" + +#: templates/comments/list.html:3 templates/user/prepare-data.html:98 +#: templates/user/user-tabs.html:13 msgid "Comments" msgstr "Bình luận" @@ -4617,93 +4740,61 @@ msgstr "Bình luận" msgid "Please read the [guidelines][0] before commenting." msgstr "Hãy đọc [nội quy][0] trước khi bình luận." -#: templates/comments/list.html:54 +#: templates/comments/list.html:11 +msgid "Comments are disabled on this page." +msgstr "Bình luận đã bị vô hiệu hóa trên trang này." + +#: templates/comments/list.html:16 +msgid "There are no comments at the moment." +msgstr "Không có bình luận tại thời điểm này." + +#: templates/comments/list.html:65 templates/user/comment.html:58 #, python-brace-format msgid "commented on {time}" msgstr "đã bình luận lúc {time}" -#: templates/comments/list.html:65 +#: templates/comments/list.html:76 templates/user/comment.html:73 #, python-format msgid "edit %(edits)s" msgstr "sửa %(edits)s" -#: templates/comments/list.html:67 templates/comments/media-js.html:69 -msgid "edited" -msgstr "chỉnh sửa" - -#: templates/comments/list.html:76 +#: templates/comments/list.html:87 templates/user/comment.html:84 msgid "Link" msgstr "Liên kết" -#: templates/comments/list.html:89 templates/comments/list.html:96 +#: templates/comments/list.html:92 templates/comments/list.html:106 msgid "Reply" msgstr "Phản hồi" -#: templates/comments/list.html:104 templates/comments/votes.html:1 +#: templates/comments/list.html:98 templates/comments/votes.html:1 msgid "Votes" msgstr "" -#: templates/comments/list.html:105 +#: templates/comments/list.html:99 msgid "Hide" msgstr "Ẩn" -#: templates/comments/list.html:120 +#: templates/comments/list.html:124 templates/user/comment.html:96 msgid "This comment is hidden due to too much negative feedback." msgstr "Bình luận này đã bị ẩn vì có quá nhiều phản ứng tiêu cực." -#: templates/comments/list.html:121 +#: templates/comments/list.html:125 templates/user/comment.html:97 msgid "Show it anyway." msgstr "Nhấn để xem." -#: templates/comments/list.html:138 -msgid "There are no comments at the moment." -msgstr "Không có bình luận tại thời điểm này." - -#: templates/comments/list.html:144 +#: templates/comments/list.html:146 msgid "New comment" msgstr "Bình luận mới" -#: templates/comments/list.html:158 +#: templates/comments/list.html:160 msgid "Invalid comment body." msgstr "Bình luận không hợp lệ." -#: templates/comments/list.html:166 -msgid "Post!" -msgstr "Đăng!" - -#: templates/comments/list.html:174 -msgid "Comments are disabled on this page." -msgstr "Bình luận đã bị vô hiệu hóa trên trang này." - -#: templates/comments/media-js.html:11 -msgid "Replying to comment" -msgstr "Trả lời bình luận" - -#: templates/comments/media-js.html:64 -#, python-brace-format -msgid "edit {edits}" -msgstr "chỉnh sửa {edits}" - -#: templates/comments/media-js.html:67 -msgid "original" -msgstr "" - -#: templates/comments/media-js.html:216 -msgid "" -"Looks like you're trying to post a source code!\n" -"\n" -"The comment section are not for posting source code.\n" -"If you want to submit your solution, please use the \"Submit solution\" " -"button.\n" -"\n" -"Are you sure you want to post this?" -msgstr "" - #: templates/comments/votes.html:5 msgid "Voter" msgstr "" -#: templates/comments/votes.html:6 templates/user/user-problems.html:101 +#: templates/comments/votes.html:6 templates/user/user-problems.html:103 msgid "Score" msgstr "Điểm" @@ -4711,6 +4802,18 @@ msgstr "Điểm" msgid "No votes" msgstr "" +#: templates/common-content.html:32 templates/user/edit-profile.html:219 +msgid "Click to copy" +msgstr "" + +#: templates/common-content.html:33 templates/user/edit-profile.html:220 +msgid "Copy" +msgstr "" + +#: templates/common-content.html:44 templates/user/edit-profile.html:231 +msgid "Copied!" +msgstr "" + #: templates/contest/access_code.html:26 msgid "Invalid access code." msgstr "Mã truy cập không hợp lệ." @@ -4759,6 +4862,10 @@ msgstr "Nhập mã mới cho kỳ thi nhân bản:" msgid "Clone!" msgstr "Nhân bản!" +#: templates/contest/contest-all-problems.html:32 +msgid "Submit" +msgstr "Nộp bài" + #: templates/contest/contest-list-tabs.html:7 msgid "Prev" msgstr "Trước" @@ -4776,21 +4883,21 @@ msgstr "Sau" msgid "Export" msgstr "" -#: templates/contest/contest-list-tabs.html:26 +#: templates/contest/contest-list-tabs.html:25 msgid "Hide private contests" msgstr "Ẩn các kỳ thi riêng tư" -#: templates/contest/contest-list-tabs.html:30 +#: templates/contest/contest-list-tabs.html:29 msgid "Add new contest" msgstr "Thêm kỳ thi" -#: templates/contest/contest-list-tabs.html:32 +#: templates/contest/contest-list-tabs.html:31 #: templates/problem/problem-list-tabs.html:9 #: templates/tag/tag-list-tabs.html:7 msgid "List" msgstr "Danh sách" -#: templates/contest/contest-list-tabs.html:33 +#: templates/contest/contest-list-tabs.html:32 msgid "Calendar" msgstr "Lịch" @@ -4798,7 +4905,7 @@ msgstr "Lịch" msgid "Info" msgstr "Thông tin" -#: templates/contest/contest-tabs.html:6 templates/submission/list.html:353 +#: templates/contest/contest-tabs.html:6 templates/submission/list.html:351 #: templates/user/user-tabs.html:8 msgid "Statistics" msgstr "Thống kê" @@ -4816,8 +4923,8 @@ msgid "Hidden Rankings" msgstr "" #: templates/contest/contest-tabs.html:21 -#: templates/contest/prepare-data.html:96 templates/organization/tabs.html:18 -#: templates/user/prepare-data.html:103 +#: templates/contest/prepare-data.html:97 templates/organization/tabs.html:18 +#: templates/user/prepare-data.html:104 msgid "Submissions" msgstr "Các bài nộp" @@ -4834,7 +4941,7 @@ msgstr "Nhân bản" msgid "Leave contest" msgstr "Rời khỏi kỳ thi" -#: templates/contest/contest-tabs.html:49 templates/contest/list.html:320 +#: templates/contest/contest-tabs.html:49 templates/contest/list.html:306 msgid "Virtual join" msgstr "Tham gia ảo" @@ -4885,7 +4992,7 @@ msgstr "Bạn còn lại %(countdown)s thời gian." #: templates/contest/contest.html:66 #, python-format -msgid "Contest ends in %(countdown)s" +msgid "Contest ends in %(countdown)s." msgstr "Kỳ thi sẽ kết thúc sau %(countdown)s." #: templates/contest/contest.html:72 templates/contest/contest.html:74 @@ -4965,7 +5072,7 @@ msgid "" "have a rating of at least **%(rating_floor)d**." msgstr "" "Kỳ thi này **tính rating** với các thành viên nộp bài ít nhất một lần trong " -"kỳ thi, có rating từ**%(rating_floor)d** trở lên." +"kỳ thi, có rating từ **%(rating_floor)d** trở lên." #: templates/contest/contest.html:126 #, python-format @@ -5039,13 +5146,8 @@ msgstr "Bảng điểm sẽ bị ẩn trong toàn bộ kỳ thi." msgid "An **access code is required** to join the contest." msgstr "Kỳ thi này yêu cầu bạn phải có **mã truy cập** để tham gia." -#: templates/contest/contest.html:190 templates/contest/edit.html:108 -#: templates/contest/edit.html:115 -msgid "Problems" -msgstr "Bài" - -#: templates/contest/contest.html:192 templates/contest/prepare-data.html:129 -#: templates/user/prepare-data.html:136 +#: templates/contest/contest.html:192 templates/contest/prepare-data.html:130 +#: templates/user/prepare-data.html:137 msgid "Download data" msgstr "Tải dữ liệu" @@ -5054,27 +5156,31 @@ msgid "Editorials" msgstr "Lời giải" #: templates/contest/contest.html:258 templates/problem/editor.html:126 -#: templates/problem/list.html:196 +#: templates/problem/list.html:190 msgid "Editorial" msgstr "Lời giải" -#: templates/contest/contest.html:269 +#: templates/contest/contest.html:267 +msgid "All problems" +msgstr "Tất cả bài tập" + +#: templates/contest/contest.html:271 msgid "Announcements" msgstr "Thông báo" -#: templates/contest/contest.html:273 templates/contest/contest.html:307 +#: templates/contest/contest.html:275 templates/contest/contest.html:309 msgid "When" msgstr "Thời gian" -#: templates/contest/contest.html:274 templates/ticket/list.html:262 +#: templates/contest/contest.html:276 templates/ticket/list.html:197 msgid "Title" msgstr "Tiêu đề" -#: templates/contest/contest.html:293 +#: templates/contest/contest.html:295 msgid "Add an announcement" msgstr "Tạo thông báo" -#: templates/contest/contest.html:303 templates/problem/problem.html:397 +#: templates/contest/contest.html:305 templates/problem/problem.html:380 msgid "Clarifications" msgstr "Làm rõ" @@ -5107,7 +5213,7 @@ msgid "or leave blank for no limit." msgstr "hoặc để trống để không giới hạn." #: templates/contest/edit.html:127 templates/problem/editor.html:115 -#: templates/user/edit-profile.html:421 +#: templates/user/edit-profile.html:422 msgid "Delete" msgstr "Xoá" @@ -5116,12 +5222,12 @@ msgstr "Xoá" msgid "\"The %(SITE_NAME)s's contest list - past, present, and future.\"" msgstr "\"Danh sách kỳ thi %(SITE_NAME)s - quá khứ, hiện tại, và tương lai.\"" -#: templates/contest/list.html:36 templates/contest/media-js.html:10 +#: templates/contest/list.html:18 templates/contest/media-js.html:10 #: templates/contest/media-js.html:17 msgid "Are you sure you want to join?" msgstr "Bạn có chắc bạn muốn tham gia?" -#: templates/contest/list.html:37 +#: templates/contest/list.html:19 msgid "" "Joining a contest for the first time starts your timer, after which it " "becomes unstoppable." @@ -5129,72 +5235,70 @@ msgstr "" "Truy cập vào một kỳ thi lần đầu tiên sẽ bắt đầu việc đếm ngược thời gian kì " "thi và không thể dừng lại được." -#: templates/contest/list.html:82 -msgid "hidden" -msgstr "ẩn" - -#: templates/contest/list.html:95 +#: templates/contest/list.html:77 msgid "private" msgstr "riêng tư" -#: templates/contest/list.html:100 +#: templates/contest/list.html:82 msgid "rated" msgstr "" -#: templates/contest/list.html:137 +#: templates/contest/list.html:123 #, python-format msgid "This contest has %(count)s virtual participation" msgid_plural "This contest has %(count)s virtual participations" msgstr[0] "Kỳ thi này có %(count)s lượt tham gia ảo" -#: templates/contest/list.html:148 +#: templates/contest/list.html:134 msgid "Spectate" msgstr "Theo dõi" -#: templates/contest/list.html:154 +#: templates/contest/list.html:140 msgid "Join" msgstr "Tham gia" -#: templates/contest/list.html:164 -msgid "Active Contests" +#: templates/contest/list.html:150 +msgid "Active contests" msgstr "Các kỳ thi đang tham gia" -#: templates/contest/list.html:168 templates/contest/list.html:210 -#: templates/contest/list.html:248 templates/contest/list.html:293 +#: templates/contest/list.html:154 templates/contest/list.html:196 +#: templates/contest/list.html:234 templates/contest/list.html:279 msgid "Contest" msgstr "Kỳ thi" -#: templates/contest/list.html:169 templates/contest/list.html:211 -#: templates/contest/list.html:296 templates/organization/tabs.html:15 +#: templates/contest/list.html:155 templates/contest/list.html:197 +#: templates/contest/list.html:282 templates/organization/tabs.html:15 msgid "Users" msgstr "Thành viên" -#: templates/contest/list.html:185 +#: templates/contest/list.html:171 #, python-format msgid "Window ends in %(countdown)s" msgstr "Thời gian làm bài kết thúc trong %(countdown)s." -#: templates/contest/list.html:206 -msgid "Ongoing Contests" -msgstr "Các kỳ thi đang diễn ra" +#: templates/contest/list.html:173 templates/contest/list.html:212 +#, python-format +msgid "Ends in %(countdown)s" +msgstr "Kết thúc trong %(countdown)s." -#: templates/contest/list.html:243 -msgid "Upcoming Contests" -msgstr "Các kỳ thi sắp tới" +#: templates/contest/list.html:246 +#, python-format +msgid "Starting in %(countdown)s" +msgstr "Bắt đầu trong %(countdown)s." -#: templates/contest/list.html:271 +#: templates/contest/list.html:257 msgid "There are no scheduled contests at this time." msgstr "Chưa có kỳ thi được lên lịch trong tương lai." -#: templates/contest/list.html:276 -msgid "Past Contests" +#: templates/contest/list.html:262 +msgid "Past contests" msgstr "Các kỳ thi đã qua" -#: templates/contest/list.html:283 +#: templates/contest/list.html:269 msgid "Search contest..." msgstr "Tìm kỳ thi..." -#: templates/contest/list.html:334 +#: templates/contest/list.html:320 msgid "There are no past contests." msgstr "Không có kỳ thi nào." @@ -5223,22 +5327,28 @@ msgid "Joining this contest will leave %(contest)s." msgstr "Tham gia kỳ thi này sẽ rời kỳ thi %(contest)s" #: templates/contest/moss.html:27 -msgid "Are you sure you want MOSS the contest?" +msgid "Are you sure you want to MOSS the contest?" msgstr "Bạn có chắc muốn MOSS kỳ thi này?" -#: templates/contest/moss.html:32 +#: templates/contest/moss.html:30 msgid "Are you sure you want to delete the MOSS results?" msgstr "Bạn có chắc muốn xóa kết quả MOSS?" -#: templates/contest/moss.html:59 +#: templates/contest/moss.html:55 +#, python-format +msgid "%(count)s submission" +msgid_plural "%(count)s submissions" +msgstr[0] "%(count)s bài nộp" + +#: templates/contest/moss.html:61 msgid "No submissions" msgstr "" -#: templates/contest/moss.html:73 +#: templates/contest/moss.html:75 msgid "Re-MOSS contest" msgstr "" -#: templates/contest/moss.html:81 +#: templates/contest/moss.html:83 msgid "Delete MOSS results" msgstr "" @@ -5248,7 +5358,7 @@ msgid "Rank" msgstr "Hạng" #: templates/contest/official-ranking-table.html:5 -#: templates/contest/ranking.html:341 +#: templates/contest/ranking.html:625 msgid "Full Name" msgstr "Tên đầy đủ" @@ -5264,38 +5374,35 @@ msgstr "Đang chuẩn bị dữ liệu kỳ thi." msgid "Track progress" msgstr "Theo dõi quá trình" -#: templates/contest/prepare-data.html:83 templates/user/prepare-data.html:84 +#: templates/contest/prepare-data.html:84 templates/user/prepare-data.html:85 #, python-format -msgid "" -"You may only prepare a new data download for this contest once every " -"%(ratelimit)s." -msgstr "" -"Bạn chỉ được chuẩn bị dữ liệu mới cho kỳ thi này một lần trong %(ratelimit)s." +msgid "You may only prepare a new data download once every %(duration)s." +msgstr "Bạn chỉ được chuẩn bị dữ liệu mới một lần mỗi %(duration)s." -#: templates/contest/prepare-data.html:85 +#: templates/contest/prepare-data.html:86 msgid "" "Once the contest data is ready, you will find a download link on this page." msgstr "" "Khi dữ liệu kỳ thi đã chuẩn bị xong, bạn sẽ tìm thấy liên kết tải tại trang " "này." -#: templates/contest/prepare-data.html:113 templates/user/prepare-data.html:120 +#: templates/contest/prepare-data.html:114 templates/user/prepare-data.html:121 msgid "Prepare new download" msgstr "Chuẩn bị dữ liệu mới" -#: templates/contest/prepare-data.html:115 templates/user/prepare-data.html:122 +#: templates/contest/prepare-data.html:116 templates/user/prepare-data.html:123 msgid "Prepare download" msgstr "Chuẩn bị dữ liệu" -#: templates/contest/prepare-data.html:119 templates/user/prepare-data.html:126 +#: templates/contest/prepare-data.html:120 templates/user/prepare-data.html:127 msgid "Download prepared data" msgstr "Tải dữ liệu đã chuẩn bị" -#: templates/contest/prepare-data.html:125 templates/user/prepare-data.html:132 +#: templates/contest/prepare-data.html:126 templates/user/prepare-data.html:133 msgid "Your data is ready!" msgstr "Dữ liệu của bạn đã sẵn sàng!" -#: templates/contest/prepare-data.html:127 templates/user/prepare-data.html:134 +#: templates/contest/prepare-data.html:128 templates/user/prepare-data.html:135 #, python-format msgid "You will need to wait %(countdown)s to prepare a new data download." msgstr "Bạn phải đợi %(countdown)s để chuẩn bị dữ liệu mới." @@ -5313,61 +5420,77 @@ msgid "Only the following organizations may access this contest:" msgstr "Chỉ có các tổ chức sau có thể truy cập vào kỳ thi này:" #: templates/contest/ranking-table.html:5 -msgid "Penalty" -msgstr "" - -#: templates/contest/ranking-table.html:20 #, python-format msgid "%(cnt)s virtual participation of this user" msgstr "Lần tham gia ảo thứ %(cnt)s của người dùng" -#: templates/contest/ranking-table.html:30 +#: templates/contest/ranking-table.html:18 +msgid "Un-Disqualify" +msgstr "" + +#: templates/contest/ranking-table.html:21 +msgid "Disqualify" +msgstr "" + +#: templates/contest/ranking-table.html:37 #, python-brace-format msgid "Started on {time}" msgstr "Bắt đầu vào {time}" -#: templates/contest/ranking-table.html:30 +#: templates/contest/ranking-table.html:37 #, python-brace-format msgid "Started {time}" msgstr "Bắt đầu {time}" -#: templates/contest/ranking-table.html:35 +#: templates/contest/ranking-table.html:42 msgid "Participation ended." msgstr "Thời gian tham gia đã kết thúc." -#: templates/contest/ranking-table.html:45 -msgid "Un-Disqualify" +#: templates/contest/ranking-table.html:57 +msgid "Penalty" msgstr "" -#: templates/contest/ranking-table.html:48 -msgid "Disqualify" -msgstr "" +#: templates/contest/ranking.html:311 +msgid "Search organizations" +msgstr "Tìm tổ chức" -#: templates/contest/ranking.html:120 +#: templates/contest/ranking.html:581 msgid "Are you sure you want to disqualify this participation?" msgstr "" -#: templates/contest/ranking.html:125 +#: templates/contest/ranking.html:586 msgid "Are you sure you want to un-disqualify this participation?" msgstr "" -#: templates/contest/ranking.html:394 +#: templates/contest/ranking.html:683 msgid "View user participation" msgstr "Xem thành viên tham gia" -#: templates/contest/ranking.html:399 +#: templates/contest/ranking.html:690 +msgid "Filter" +msgstr "Lọc" + +#: templates/contest/ranking.html:695 templates/status/oj-status.html:74 +msgid "Apply" +msgstr "Chọn" + +#: templates/contest/ranking.html:696 +msgid "Clear" +msgstr "Đặt lại" + +#: templates/contest/ranking.html:704 msgid "Show full name/organization" msgstr "Hiển thị tên/tổ chức" -#: templates/contest/ranking.html:403 +#: templates/contest/ranking.html:708 msgid "Show virtual participations" msgstr "Hiển thị xếp hạng của virtual" -#: templates/contest/ranking.html:405 +#: templates/contest/ranking.html:710 msgid "Download as CSV" msgstr "Tải bảng xếp hạng dưới dạng CSV" -#: templates/contest/ranking.html:420 +#: templates/contest/ranking.html:725 #, python-format msgid "" "The scoreboard was frozen with %(frozen_minutes)s minutes remaining - " @@ -5378,7 +5501,7 @@ msgstr "" "nộp trong %(frozen_minutes)s phút cuối của kỳ thi vẫn được hiển thị là đang " "chấm." -#: templates/contest/ranking.html:427 +#: templates/contest/ranking.html:732 #, python-format msgid "" "The scoreboard is cached for %(cache_timeout)s seconds, your submission " @@ -5387,27 +5510,27 @@ msgstr "" "Bảng điểm được lưu trữ trong %(cache_timeout)s giây, bài nộp của bạn có thể " "cần một thời gian nhất định trước khi nó xuất hiện ở đây." -#: templates/contest/ranking.html:436 +#: templates/contest/ranking.html:741 msgid "Cell colours" msgstr "Màu ô" -#: templates/contest/ranking.html:441 +#: templates/contest/ranking.html:746 msgid "Solved first" msgstr "Giải đầu tiên" -#: templates/contest/ranking.html:444 +#: templates/contest/ranking.html:749 msgid "Solved" msgstr "Đã giải" -#: templates/contest/ranking.html:447 +#: templates/contest/ranking.html:752 msgid "Tried, incorrect" msgstr "Đã thử, sai" -#: templates/contest/ranking.html:450 +#: templates/contest/ranking.html:755 msgid "Tried, pending" msgstr "Đã thử, đang chấm" -#: templates/contest/ranking.html:453 +#: templates/contest/ranking.html:758 msgid "Untried" msgstr "Chưa thử" @@ -5432,8 +5555,8 @@ msgid "Changes you made may not be saved." msgstr "Những thay đổi của bạn có thể không được lưu lại." #: templates/license.html:12 -msgid "Source:" -msgstr "Nguồn:" +msgid "Visit source" +msgstr "Xem nguồn" #: templates/newsletter/common.html:6 #: templates/newsletter/subscription_unsubscribe_activated.html:3 @@ -5537,7 +5660,7 @@ msgstr "Bạn sẽ phải tham gia lại để có hiện trên bảng xếp h msgid "You will have to request membership in order to join again." msgstr "Bạn sẽ phải yêu cầu tư cách thành viên nếu muốn tham gia lại." -#: templates/organization/home.html:65 templates/user/user-about.html:34 +#: templates/organization/home.html:65 templates/user/user-about.html:33 #: templates/user/user-tabs.html:7 msgid "About" msgstr "Thông tin" @@ -5634,7 +5757,7 @@ msgid "There are no requests to approve." msgstr "Không có yêu cầu để chấp nhận." #: templates/organization/requests/pending.html:17 -#: templates/problem/data.html:675 +#: templates/problem/data.html:678 msgid "Delete?" msgstr "Xoá?" @@ -5643,8 +5766,8 @@ msgid "Your reason for joining:" msgstr "Lý do tham gia:" #: templates/organization/requests/request.html:20 -msgid "Request" -msgstr "Yêu cầu" +msgid "Request!" +msgstr "Yêu cầu!" #: templates/organization/requests/tabs.html:7 msgid "Log" @@ -5679,39 +5802,43 @@ msgstr "Cập nhật preview" msgid "Enter a new code for the cloned problem:" msgstr "Nhập mã bài cho bài nhân bản:" +#: templates/problem/data.html:96 +msgid "precision (decimal digits)" +msgstr "" + #: templates/problem/data.html:116 -msgid "Expected checker's extension must be in [cpp, py, pas], found " -msgstr "Trình chấm ngoài phải có đuôi file là cpp, py hoặc pas, không phải " +msgid "Expected checker's extension must be in [cpp, pas, java], found " +msgstr "Trình chấm ngoài phải có đuôi file là cpp, pas hoặc java, không phải " -#: templates/problem/data.html:235 +#: templates/problem/data.html:236 msgid "Instruction" msgstr "Hướng dẫn" -#: templates/problem/data.html:245 +#: templates/problem/data.html:246 msgid "Please press this button if you have just updated the zip data" msgstr "Nhấn nút này nếu bạn mới thay đổi file test" -#: templates/problem/data.html:359 +#: templates/problem/data.html:360 #, python-brace-format msgid "You are about to create more than ${testcase_soft_limit} testcases." msgstr "Bạn đang tạo nhiều hơn ${testcase_soft_limit} testcase." -#: templates/problem/data.html:360 +#: templates/problem/data.html:361 msgid "Please do not create too many testcases if not really necessary." msgstr "Xin đừng tạo quá nhiều testcase nếu không thật sự cần thiết." -#: templates/problem/data.html:366 templates/problem/data.html:465 +#: templates/problem/data.html:367 templates/problem/data.html:466 msgid "Too many testcases" msgstr "Quá nhiều testcase" -#: templates/problem/data.html:368 templates/problem/data.html:467 +#: templates/problem/data.html:369 templates/problem/data.html:468 #, python-brace-format msgid "Number of testcases must not exceed ${window.testcase_limit}" msgstr "" "Quá nhiều testcase, số lượng testcase không được vượt quá ${window." "testcase_limit}" -#: templates/problem/data.html:438 +#: templates/problem/data.html:439 msgid "" "No input/output files. Make sure your files are following themis/cms test " "format" @@ -5719,7 +5846,7 @@ msgstr "" "Không tìm thấy file input/output. Hãy chắc chắn rằng file test sử dụng " "format của themis hoặc cms" -#: templates/problem/data.html:442 +#: templates/problem/data.html:443 #, python-brace-format msgid "" "The number of input files (${inFiles.length}) do not match the number of " @@ -5728,63 +5855,67 @@ msgstr "" "Số lượng file input (${inFiles.length}) không khớp với số lượng file output " "(${outFiles.length})!" -#: templates/problem/data.html:469 +#: templates/problem/data.html:470 #, python-brace-format msgid "" "Because of that, only the first ${window.testcase_limit} testcases will be " "saved!" msgstr "Do đó, chỉ ${window.testcase_limit} các test đầu tiên sẽ được lưu lại!" -#: templates/problem/data.html:635 +#: templates/problem/data.html:525 +msgid "Test file must be a ZIP file" +msgstr "Tập tin dữ liệu phải có định dạng ZIP" + +#: templates/problem/data.html:638 msgid "View YAML" msgstr "Xem YAML" -#: templates/problem/data.html:654 +#: templates/problem/data.html:657 msgid "Test cases have been filled automatically!" msgstr "Các test đã được điền tự động!" -#: templates/problem/data.html:656 +#: templates/problem/data.html:659 msgid "Test cases have been filled automatically and **not saved yet**!" msgstr "Các test dưới đây được điền tự động và **chưa được lưu lại**!" -#: templates/problem/data.html:658 +#: templates/problem/data.html:661 msgid "" "Please modify the table below if needed and press the `Apply` button to save!" msgstr "Hãy chỉnh sửa bảng dưới đây nếu cần thiết, và nhấn nút `Apply` để lưu!" -#: templates/problem/data.html:668 +#: templates/problem/data.html:671 msgid "Type" msgstr "Kiểu" -#: templates/problem/data.html:669 +#: templates/problem/data.html:672 msgid "Input file" msgstr "Tập tin đầu vào" -#: templates/problem/data.html:670 +#: templates/problem/data.html:673 msgid "Output file" msgstr "Tập tin đầu ra" -#: templates/problem/data.html:672 +#: templates/problem/data.html:675 msgid "Pretest?" msgstr "" -#: templates/problem/data.html:673 +#: templates/problem/data.html:676 msgid "Generator args" msgstr "Tham số trình sinh tests" -#: templates/problem/data.html:710 +#: templates/problem/data.html:713 msgid "Edit generator args" msgstr "Sửa tham số trình sinh test" -#: templates/problem/data.html:720 +#: templates/problem/data.html:723 msgid "Apply!" msgstr "" -#: templates/problem/data.html:721 +#: templates/problem/data.html:724 msgid "Add new case" msgstr "Thêm test mới" -#: templates/problem/data.html:723 +#: templates/problem/data.html:726 msgid "Save" msgstr "Lưu" @@ -5833,34 +5964,34 @@ msgid_plural "Authors:" msgstr[0] "Tác giả:" msgstr[1] "Các tác giả:" -#: templates/problem/list.html:63 +#: templates/problem/list.html:59 msgid "Filter by type..." msgstr "Lọc theo dạng..." -#: templates/problem/list.html:150 +#: templates/problem/list.html:144 msgid "Hot problems" msgstr "Những bài tập nổi bật" -#: templates/problem/list.html:174 templates/status/language-list.html:34 -#: templates/ticket/list.html:261 +#: templates/problem/list.html:168 templates/problem/submission-diff.html:111 +#: templates/status/language-list.html:34 templates/ticket/list.html:196 msgid "ID" msgstr "ID" -#: templates/problem/list.html:180 templates/problem/search-form.html:35 +#: templates/problem/list.html:174 templates/problem/search-form.html:35 #: templates/user/user-problems.html:59 msgid "Category" msgstr "Nhóm" -#: templates/problem/list.html:184 +#: templates/problem/list.html:178 msgid "Types" msgstr "Dạng" -#: templates/problem/list.html:191 +#: templates/problem/list.html:185 #, python-format msgid "%% AC" msgstr "" -#: templates/problem/list.html:194 +#: templates/problem/list.html:188 msgid "# AC" msgstr "" @@ -5930,30 +6061,44 @@ msgstr "Tính lại điểm của mọi bài nộp" #: templates/problem/manage_submission.html:172 #, python-format -msgid "This will rescore %(count)d submissions." -msgstr "Sẽ tính lại điểm của %(count)d bài nộp. " +msgid "This will rescore %(count)s submission." +msgid_plural "This will rescore %(count)s submissions." +msgstr[0] "Sẽ tính lại điểm của %(count)s bài nộp. " -#: templates/problem/manage_submission.html:176 +#: templates/problem/manage_submission.html:180 #, python-format -msgid "Are you sure you want to rescore %(count)d submissions?" -msgstr "Bạn có chắn là muốn tính lại điểm của %(count)d bài nộp?" +msgid "Are you sure you want to rescore %(count)s submission?" +msgid_plural "Are you sure you want to rescore %(count)s submissions?" +msgstr[0] "Bạn có chắc bạn muốn tính lại điểm của %(count)s bài nộp?" -#: templates/problem/manage_submission.html:177 +#: templates/problem/manage_submission.html:185 msgid "Rescore all submissions" msgstr "Tính lại điểm của mọi bài nộp" -#: templates/problem/manage_submission.html:183 +#: templates/problem/manage_submission.html:191 msgid "Compare Submissions" msgstr "So sánh bài nộp" -#: templates/problem/manage_submission.html:185 +#: templates/problem/manage_submission.html:193 msgid "Filter by user:" msgstr "Lọc user:" -#: templates/problem/manage_submission.html:189 +#: templates/problem/manage_submission.html:197 msgid "Compare submissions" msgstr "So sánh bài nộp" +#: templates/problem/problem-detail.html:3 +msgid "" +"In case the statement didn't load correctly, you can download the statement " +"here: " +msgstr "" +"Trong trường hợp đề bài hiển thị không chính xác, bạn có thể tải đề bài tại " +"đây: " + +#: templates/problem/problem-detail.html:3 +msgid "Statement" +msgstr "Đề bài" + #: templates/problem/problem-list-tabs.html:12 msgid "Suggest" msgstr "Đề xuất" @@ -6027,7 +6172,7 @@ msgid "Memory limit:" msgstr "Giới hạn bộ nhớ:" #: templates/problem/problem.html:239 templates/problem/problem.html:248 -#: templates/submission/status-testcases.html:118 +#: templates/submission/status-testcases.html:135 msgid "Input:" msgstr "" @@ -6081,35 +6226,23 @@ msgstr "" "Người dùng chưa thể truy cập bài tập này đến khi nó được thông qua bởi " "admin. Hãy đảm bảo các dữ liệu của đề bài này là chính xác." -#: templates/problem/problem.html:368 -msgid "" -"In case the statement didn't load correctly, you can download the statement " -"here: " -msgstr "" -"Trong trường hợp đề bài hiển thị không chính xác, bạn có thể tải đề bài tại " -"đây: " - -#: templates/problem/problem.html:368 -msgid "Statement" -msgstr "Đề bài" - -#: templates/problem/problem.html:386 +#: templates/problem/problem.html:369 msgid "Request clarification" msgstr "Gửi thắc mắc" -#: templates/problem/problem.html:388 +#: templates/problem/problem.html:371 msgid "Report an issue" msgstr "Báo cáo vấn đề" -#: templates/problem/problem.html:408 +#: templates/problem/problem.html:391 msgid "No clarifications have been made at this time." msgstr "Chưa có làm rõ nào được đưa ra ở thời điểm này." -#: templates/problem/raw.html:69 +#: templates/problem/raw.html:67 msgid "Time Limit:" msgstr "" -#: templates/problem/raw.html:78 +#: templates/problem/raw.html:76 msgid "Memory Limit:" msgstr "" @@ -6138,7 +6271,7 @@ msgid "Show problem types" msgstr "Hiện dạng bài" #: templates/problem/search-form.html:38 templates/problem/search-form.html:40 -#: templates/submission/list.html:338 +#: templates/submission/list.html:336 #: templates/submission/submission-list-tabs.html:4 msgid "All" msgstr "Tất cả" @@ -6151,8 +6284,8 @@ msgstr "Dạng bài" msgid "Point range" msgstr "Khoảng điểm" -#: templates/problem/search-form.html:66 templates/submission/list.html:348 -#: templates/tag/search-form.html:21 templates/ticket/list.html:250 +#: templates/problem/search-form.html:66 templates/submission/list.html:346 +#: templates/tag/search-form.html:21 templates/ticket/list.html:185 msgid "Go" msgstr "Tìm" @@ -6160,6 +6293,22 @@ msgstr "Tìm" msgid "Random" msgstr "Ngẫu nhiên" +#: templates/problem/submission-diff.html:109 +msgid "1st" +msgstr "" + +#: templates/problem/submission-diff.html:110 +msgid "2nd" +msgstr "" + +#: templates/problem/submission-diff.html:113 +msgid "Result" +msgstr "kết quả" + +#: templates/problem/submission-diff.html:115 +msgid "Date" +msgstr "Ngày" + #: templates/problem/submission-diff.html:156 msgid "Diff source code" msgstr "So sánh mã nguồn" @@ -6188,40 +6337,40 @@ msgstr "Tên file" msgid "File size" msgstr "Độ lớn của file" -#: templates/problem/submit.html:368 +#: templates/problem/submit.html:373 msgid "Warning!" msgstr "" -#: templates/problem/submit.html:369 +#: templates/problem/submit.html:374 #, python-format msgid "" "Your default language, %(language)s, is unavailable for this problem and has " "been deselected." msgstr "Ngôn ngữ mặc định của bạn, %(language)s, không chấm được ở bài này." -#: templates/problem/submit.html:377 +#: templates/problem/submit.html:382 #, python-format msgid "You have %(left)s submission left" msgid_plural "You have %(left)s submissions left" msgstr[0] "Bạn còn %(left)s lần nộp" -#: templates/problem/submit.html:382 +#: templates/problem/submit.html:387 msgid "You have 0 submissions left" msgstr "Bạn còn 0 lần nộp" -#: templates/problem/submit.html:394 +#: templates/problem/submit.html:399 msgid "Paste your source code here or load it from a file:" msgstr "Dán bài làm của bạn ở đây hoặc nhập từ file:" -#: templates/problem/submit.html:403 +#: templates/problem/submit.html:408 msgid "You can only submit file for this language." msgstr "Ngôn ngữ này chỉ chấp nhận nộp bằng file." -#: templates/problem/submit.html:431 +#: templates/problem/submit.html:436 msgid "No judge is available for this problem." msgstr "Bài tập này hiện tại không chấm." -#: templates/problem/submit.html:435 +#: templates/problem/submit.html:440 msgid "Submit!" msgstr "Nộp bài!" @@ -6424,7 +6573,7 @@ msgstr "Đội ngũ %(site_name)s" 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:36 +#: templates/registration/profile_creation.html:44 #: templates/registration/username_select.html:7 msgid "Continue >" msgstr "Tiếp tục >" @@ -6449,50 +6598,47 @@ 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:137 -#: templates/registration/registration_form.html:191 +#: templates/registration/registration_form.html:143 +#: templates/registration/registration_form.html:197 msgid "can be blank" msgstr "có thể bỏ trống" -#: templates/registration/registration_form.html:150 +#: templates/registration/registration_form.html:156 msgid "please choose a popular email provider, e.g. gmail" msgstr "hãy chọn một địa chỉ mail thông dụng, ví dụ như gmail" -#: templates/registration/registration_form.html:171 +#: templates/registration/registration_form.html:177 msgid "(again, for confirmation)" msgstr "(xác nhận mật khẩu)" -#: templates/registration/registration_form.html:178 +#: templates/registration/registration_form.html:184 msgid "(select your closest major city)" msgstr "(chọn thành phố gần bạn nhất)" -#: templates/registration/registration_form.html:183 +#: templates/registration/registration_form.html:189 msgid "pick from map" msgstr "chọn từ bản đồ" -#: templates/registration/registration_form.html:188 +#: templates/registration/registration_form.html:194 msgid "Default language" msgstr "Ngôn ngữ mặc định" -#: templates/registration/registration_form.html:191 -#: templates/user/edit-profile.html:399 +#: templates/registration/registration_form.html:197 +#: templates/user/edit-profile.html:400 msgid "Affiliated organizations" msgstr "Tổ chức đại diện" -#: templates/registration/registration_form.html:197 -#: templates/user/edit-profile.html:334 +#: templates/registration/registration_form.html:206 +#: templates/user/edit-profile.html:328 msgid "Notify me about upcoming contests" msgstr "Thông báo cho tôi về các kỳ thi sắp tới" -#: templates/registration/registration_form.html:211 -msgid "By registering, you agree to our" -msgstr "Với việc đăng ký tài khoản, bạn đã đồng ý với" - -#: templates/registration/registration_form.html:212 -msgid "Terms & Conditions" -msgstr "Điều khoản & Điều kiện" +#: templates/registration/registration_form.html:220 +msgid "By registering, you agree to our [Terms & Conditions][0]." +msgstr "" +"Với việc đăng ký tài khoản, bạn đã đồng ý với [Điều khoản & Điều kiện][0]." -#: templates/registration/registration_form.html:215 +#: templates/registration/registration_form.html:223 msgid "Register!" msgstr "Đăng ký!" @@ -6505,9 +6651,10 @@ msgstr "" "hóa 2FA." #: templates/registration/totp_disable.html:38 +#: templates/registration/two_factor_auth.html:79 msgid "" -"Enter the 6-digit code generated by your app or one of your 16-digit scratch " -"codes:" +"Enter the 6-digit code generated by your app or one of your 16-character " +"scratch codes:" msgstr "" #: templates/registration/totp_disable.html:41 @@ -6539,14 +6686,17 @@ msgid "Enable Two Factor Authentication" msgstr "" #: templates/registration/totp_enable.html:98 +#: templates/user/edit-profile.html:380 msgid "Below is a list of one-time use scratch codes." msgstr "" #: templates/registration/totp_enable.html:99 +#: templates/user/edit-profile.html:381 msgid "These codes can only be used once and are for emergency use." msgstr "" #: templates/registration/totp_enable.html:100 +#: templates/user/edit-profile.html:382 msgid "" "You can use these codes to login to your account or disable two-factor " "authentication." @@ -6561,6 +6711,7 @@ msgid "" msgstr "" #: templates/registration/totp_enable.html:102 +#: templates/user/edit-profile.html:384 msgid "Please write these down and keep them in a secure location." msgstr "" @@ -6571,7 +6722,7 @@ msgid "" msgstr "" #: templates/registration/totp_enable.html:108 -msgid "Important" +msgid "Important:" msgstr "" #: templates/registration/totp_enable.html:109 @@ -6583,21 +6734,15 @@ msgid "" msgstr "" #: templates/registration/two_factor_auth.html:33 -#: templates/user/edit-profile.html:165 +#: templates/user/edit-profile.html:164 msgid "WebAuthn is not supported by your browser." msgstr "" #: templates/registration/two_factor_auth.html:54 -#: templates/user/edit-profile.html:189 +#: templates/user/edit-profile.html:188 msgid "Failed to contact server." msgstr "" -#: templates/registration/two_factor_auth.html:79 -msgid "" -"Enter the 6-digit code generated by your app or one of your 16-character " -"scratch codes:" -msgstr "" - #: templates/registration/two_factor_auth.html:81 msgid "Enter one of your 16-character scratch codes:" msgstr "" @@ -6618,34 +6763,34 @@ msgstr "" "Nếu bạn mất thiết bị xác thực và không thể sử dụng code, hãy liên hệ với " "chúng mình qua: " -#: templates/status/judge-status-table.html:2 +#: templates/status/judge-status-table.html:3 msgid "Judge" msgstr "Máy chấm" -#: templates/status/judge-status-table.html:4 +#: templates/status/judge-status-table.html:5 msgid "Online" msgstr "Trực tuyến" -#: templates/status/judge-status-table.html:6 +#: templates/status/judge-status-table.html:7 msgid "Uptime" msgstr "Thời gian hoạt động" -#: templates/status/judge-status-table.html:7 +#: templates/status/judge-status-table.html:8 msgid "Ping" msgstr "Ping" -#: templates/status/judge-status-table.html:8 +#: templates/status/judge-status-table.html:9 msgid "Load" msgstr "Tốc độ" -#: templates/status/judge-status-table.html:34 -#: templates/status/judge-status-table.html:41 -#: templates/status/judge-status-table.html:48 -#: templates/status/judge-status-table.html:59 +#: templates/status/judge-status-table.html:36 +#: templates/status/judge-status-table.html:43 +#: templates/status/judge-status-table.html:50 +#: templates/status/judge-status-table.html:61 msgid "N/A" msgstr "Không có thông tin" -#: templates/status/judge-status-table.html:64 +#: templates/status/judge-status-table.html:66 msgid "There are no judges available at this time." msgstr "Không có máy chấm nào tại thời điểm này." @@ -6661,10 +6806,6 @@ msgstr "HH:mm:ss D MMMM, YYYY" msgid "Custom Range" msgstr "Khoảng tuỳ chọn" -#: templates/status/oj-status.html:74 -msgid "Apply" -msgstr "Chọn" - #: templates/status/oj-status.html:75 msgid "Cancel" msgstr "Huỷ" @@ -6729,7 +6870,7 @@ msgstr "Danh sách bài nộp/bài tập mới của tổ chức" msgid "Judges" msgstr "Máy chấm" -#: templates/status/status-tabs.html:5 templates/tag/list.html:116 +#: templates/status/status-tabs.html:5 templates/tag/list.html:114 msgid "OJ" msgstr "" @@ -6739,8 +6880,8 @@ msgstr "Phiên bản của trình chấm" #: templates/submission/info-base.html:8 #, python-format -msgid "on judge %(name)s" -msgstr "ở máy chấm %(name)s" +msgid "%(date)s on judge %(judge)s" +msgstr "%(date)s ở máy chấm %(judge)s" #: templates/submission/internal-error-message.html:3 #, python-format @@ -6763,27 +6904,31 @@ msgstr "Một lỗi hệ thống vừa xảy ra trong quá trình chấm bài." msgid "Error information" msgstr "Thông tin về lỗi" -#: templates/submission/list.html:75 +#: templates/submission/list.html:34 templates/submission/status.html:83 +msgid "Are you sure you want to rejudge?" +msgstr "Bạn có chắc bạn muốn chấm lại?" + +#: templates/submission/list.html:73 msgid "Filter by status..." msgstr "Lọc theo trạng thái..." -#: templates/submission/list.html:82 +#: templates/submission/list.html:80 msgid "Filter by language..." msgstr "Lọc theo ngôn ngữ..." -#: templates/submission/list.html:313 +#: templates/submission/list.html:311 msgid "Filter submissions" msgstr "Lọc các bài nộp" -#: templates/submission/list.html:336 +#: templates/submission/list.html:334 msgid "Organization" msgstr "Tổ chức" -#: templates/submission/list.html:359 +#: templates/submission/list.html:357 msgid "Total:" msgstr "Tổng:" -#: templates/submission/list.html:369 +#: templates/submission/list.html:367 msgid "You were disconnected. Refresh to show latest updates." msgstr "" "Bạn đã bị ngắt kết nối. Tải lại trang để hiển thị thông tin cập nhật mới " @@ -6838,97 +6983,101 @@ msgstr "Xem mã nguồn thô" msgid "Resubmit" msgstr "Nộp lại" -#: templates/submission/status-testcases.html:10 +#: templates/submission/status-testcases.html:4 +msgid "Partial Accepted" +msgstr "Kết quả đúng một phần" + +#: templates/submission/status-testcases.html:9 +#, python-format +msgid "Batch #%(id)d" +msgstr "" + +#: templates/submission/status-testcases.html:30 msgid "We are waiting for a suitable judge to process your submission..." msgstr "Chúng tôi đang đợi một máy chấm phù hợp để chấm bài của bạn..." -#: templates/submission/status-testcases.html:12 +#: templates/submission/status-testcases.html:32 msgid "Your submission is being processed..." msgstr "Bài nộp của bạn đang được xử lý..." -#: templates/submission/status-testcases.html:14 +#: templates/submission/status-testcases.html:34 msgid "Compilation Error" msgstr "Biên dịch gặp lỗi" -#: templates/submission/status-testcases.html:18 +#: templates/submission/status-testcases.html:38 msgid "Compilation Warnings" msgstr "Các cảnh báo biên dịch" -#: templates/submission/status-testcases.html:23 +#: templates/submission/status-testcases.html:43 msgid "Pretest Execution Results" msgstr "Kết quả pretest" -#: templates/submission/status-testcases.html:25 +#: templates/submission/status-testcases.html:45 msgid "Execution Results" msgstr "Kết quả" -#: templates/submission/status-testcases.html:51 +#: templates/submission/status-testcases.html:71 msgid "" "Your submission is successfully stored. Currently, there are no tests " "available, or this is an offline task, so please consider waiting for " "further evaluation." msgstr "" -#: templates/submission/status-testcases.html:59 -msgid "Batch " +#: templates/submission/status-testcases.html:100 +#, python-format +msgid "Case #%(id)d:" msgstr "" -#: templates/submission/status-testcases.html:80 -msgid "Case" -msgstr "Trường hợp" - -#: templates/submission/status-testcases.html:82 -msgid "Pretest" +#: templates/submission/status-testcases.html:102 +#, python-format +msgid "Pretest #%(id)d:" msgstr "" -#: templates/submission/status-testcases.html:84 -msgid "Test case" +#: templates/submission/status-testcases.html:104 +#, python-format +msgid "Test case #%(id)d:" msgstr "" -#: templates/submission/status-testcases.html:91 -msgid "Partial Accepted" -msgstr "Kết quả đúng một phần" - -#: templates/submission/status-testcases.html:120 +#: templates/submission/status-testcases.html:137 msgid "Answer:" msgstr "Kết quả:" -#: templates/submission/status-testcases.html:124 +#: templates/submission/status-testcases.html:142 msgid "Your output (clipped)" -msgstr "Output của bạn (đã được lượt bỏ)" +msgstr "Output của bạn (đã được lược bỏ)" -#: templates/submission/status-testcases.html:132 +#: templates/submission/status-testcases.html:150 msgid "Judge feedback" msgstr "Phản hồi từ trình chấm" -#: templates/submission/status-testcases.html:148 +#: templates/submission/status-testcases.html:168 msgid "Resources:" msgstr "Tài nguyên:" -#: templates/submission/status-testcases.html:157 +#: templates/submission/status-testcases.html:177 msgid "Maximum single-case runtime:" msgstr "Thời gian chạy test lâu nhất:" -#: templates/submission/status-testcases.html:162 +#: templates/submission/status-testcases.html:183 msgid "Final pretest score:" msgstr "Điểm pretest:" -#: templates/submission/status-testcases.html:164 +#: templates/submission/status-testcases.html:185 msgid "Final score:" msgstr "Điểm cuối cùng:" -#: templates/submission/status-testcases.html:171 -#: templates/submission/status-testcases.html:175 +#: templates/submission/status-testcases.html:192 +#: templates/submission/status-testcases.html:196 #, python-format msgid "%(points)s/%(total)s points" msgstr "%(points)s/%(total)s điểm" -#: templates/submission/status-testcases.html:180 +#: templates/submission/status-testcases.html:201 msgid "Passing pretests does not guarantee a full score on system tests." msgstr "" "Đúng các pretest sẽ không đảm bảo được việc đúng toàn bộ test hệ thống." -#: templates/submission/status-testcases.html:183 +#: templates/submission/status-testcases.html:204 msgid "Submission aborted!" msgstr "Bài nộp đã bị huỷ!" @@ -6940,15 +7089,15 @@ msgstr "Xem code" msgid "Download" msgstr "Tải về" -#: templates/submission/status.html:90 +#: templates/submission/status.html:99 msgid "Diff this submission" msgstr "So sánh bài nộp này" -#: templates/submission/status.html:106 +#: templates/submission/status.html:115 msgid "Press Ctrl-Enter or Command-Enter to abort." msgstr "" -#: templates/submission/status.html:108 +#: templates/submission/status.html:117 msgid "Abort" msgstr "Huỷ bỏ" @@ -7016,36 +7165,44 @@ msgstr "Xóa tìm kiếm" msgid "Filter by online judge..." msgstr "Lọc theo online judge..." -#: templates/ticket/list.html:134 templates/ticket/ticket.html:295 +#: templates/task_status.html:92 +msgid "Completed!" +msgstr "Đã Hoàn Thành!" + +#: templates/task_status.html:95 +msgid "Failed!" +msgstr "" + +#: templates/ticket/list.html:69 templates/ticket/ticket.html:116 msgid "Reopened: " msgstr "Đã mở lại: " -#: templates/ticket/list.html:137 templates/ticket/ticket.html:296 +#: templates/ticket/list.html:72 templates/ticket/ticket.html:117 msgid "Closed: " msgstr "Đã đóng: " -#: templates/ticket/list.html:218 +#: templates/ticket/list.html:153 msgid "Use desktop notification" msgstr "Dùng thông báo trên màn hình" -#: templates/ticket/list.html:224 +#: templates/ticket/list.html:159 msgid "Hide closed tickets" msgstr "Ẩn ticket đã đóng" -#: templates/ticket/list.html:229 +#: templates/ticket/list.html:164 msgid "Show my tickets only" msgstr "Chỉ hiện báo cáo của tôi" -#: templates/ticket/list.html:233 +#: templates/ticket/list.html:168 msgid "Filing user" msgstr "Filing user" -#: templates/ticket/list.html:242 templates/ticket/ticket.html:397 +#: templates/ticket/list.html:177 templates/ticket/ticket.html:220 msgid "Assignee" msgid_plural "Assignees" msgstr[0] "Người được phân công" -#: templates/ticket/list.html:264 +#: templates/ticket/list.html:199 msgid "Assignees" msgstr "Phân công" @@ -7059,7 +7216,7 @@ msgstr "đã nhắn lúc {time}" msgid "messaged {time}" msgstr "đã nhắn {time}" -#: templates/ticket/new.html:51 +#: templates/ticket/new.html:32 msgid "Create!" msgstr "Tạo!" @@ -7077,43 +7234,44 @@ msgstr "" "không dành cho việc hỏi bài. Nếu bạn cần hỗ trợ về việc giải bài, hãy hỏi " "trong phần bình luận." -#: templates/ticket/ticket.html:382 -msgid "Post" -msgstr "Bài viết" +#: templates/ticket/ticket.html:38 +#, python-brace-format +msgid "Could not change ticket: {error}" +msgstr "" -#: templates/ticket/ticket.html:390 +#: templates/ticket/ticket.html:213 msgid "Associated object" msgstr "Đối tượng" -#: templates/ticket/ticket.html:407 +#: templates/ticket/ticket.html:230 msgid "No one is assigned." msgstr "Chưa có ai được phân công" -#: templates/ticket/ticket.html:414 +#: templates/ticket/ticket.html:237 msgid "Assignee notes" msgstr "Ghi chú" -#: templates/ticket/ticket.html:421 templates/widgets/select_all.html:4 +#: templates/ticket/ticket.html:244 templates/widgets/select_all.html:4 msgid "Nothing here." msgstr "Không có gì ở đây." -#: templates/ticket/ticket.html:428 +#: templates/ticket/ticket.html:251 msgid "Upvote" msgstr "" -#: templates/ticket/ticket.html:430 +#: templates/ticket/ticket.html:253 msgid "Undo vote" msgstr "" -#: templates/ticket/ticket.html:434 +#: templates/ticket/ticket.html:257 msgid "Close ticket" msgstr "Đóng vấn đề" -#: templates/ticket/ticket.html:436 +#: templates/ticket/ticket.html:259 msgid "Reopen ticket" msgstr "Mở lại vấn đề" -#: templates/user/base-users.html:14 templates/user/base-users.html:71 +#: templates/user/base-users.html:14 templates/user/base-users.html:57 #: templates/user/contrib-list.html:14 msgid "Search by handle..." msgstr "Tìm kiếm bằng username" @@ -7122,266 +7280,249 @@ msgstr "Tìm kiếm bằng username" msgid "Contribution points" msgstr "Đóng góp" -#: templates/user/edit-profile.html:113 +#: templates/user/edit-profile.html:107 +msgid "" +"The administrators for this site require all the staff to have Two-factor " +"Authentication enabled, so it may not be disabled at this time." +msgstr "" + +#: templates/user/edit-profile.html:112 msgid "Are you sure you want to generate or regenerate your API token?" msgstr "Bạn có chắc muốn tạo mới / tạo lại API token?" -#: templates/user/edit-profile.html:114 +#: templates/user/edit-profile.html:113 msgid "This will invalidate any previous API tokens." msgstr "Việc này sẽ làm mọi API token trước trở nên không hợp lệ." -#: templates/user/edit-profile.html:115 +#: templates/user/edit-profile.html:114 msgid "" "It also allows access to your account without Two-factor Authentication." msgstr "" "Việc này sẽ khiến tài khoản của bạn có thể truy cập mà không cần xác thực 2 " "bước." -#: templates/user/edit-profile.html:116 -msgid "You will not be able to view your api token after you leave this page!" +#: templates/user/edit-profile.html:115 +msgid "You will not be able to view your API token after you leave this page!" msgstr "Bạn sẽ không thể xem API token nếu thoát trang này" -#: templates/user/edit-profile.html:117 templates/user/edit-profile.html:211 +#: templates/user/edit-profile.html:116 templates/user/edit-profile.html:210 msgid "Generating..." msgstr "Đang tạo..." -#: templates/user/edit-profile.html:125 templates/user/edit-profile.html:247 -#: templates/user/edit-profile.html:379 -msgid "Regenerate" -msgstr "Tạo lại" - -#: templates/user/edit-profile.html:131 +#: templates/user/edit-profile.html:130 msgid "Remove" msgstr "Xoá" -#: templates/user/edit-profile.html:141 +#: templates/user/edit-profile.html:140 msgid "Are you sure you want to remove your API token?" msgstr "Bạn có chắc muốn xoá API token?" -#: templates/user/edit-profile.html:148 templates/user/edit-profile.html:383 +#: templates/user/edit-profile.html:147 templates/user/edit-profile.html:376 msgid "Generate" msgstr "Tạo" -#: templates/user/edit-profile.html:194 +#: templates/user/edit-profile.html:193 msgid "Are you sure you want to delete this security key?" msgstr "Bạn có chắc muốn xoá khoá bảo mật này?" -#: templates/user/edit-profile.html:288 +#: templates/user/edit-profile.html:207 +msgid "" +"Are you sure you want to generate or regenerate a new set of scratch codes?" +msgstr "" + +#: templates/user/edit-profile.html:208 +msgid "This will invalidate any previous scratch codes you have." +msgstr "" + +#: templates/user/edit-profile.html:209 templates/user/edit-profile.html:385 +msgid "" +"You will not be able to view your scratch codes after you leave this page!" +msgstr "" + +#: templates/user/edit-profile.html:282 msgid "Display badge" msgstr "Huy hiệu hiển thị" -#: templates/user/edit-profile.html:302 -msgid "Self-description" -msgstr "Tự giới thiệu" +#: templates/user/edit-profile.html:296 +msgid "Self-description:" +msgstr "Tự giới thiệu:" -#: templates/user/edit-profile.html:310 +#: templates/user/edit-profile.html:304 msgid "Select your closest major city" msgstr "Chọn thành phố của bạn" -#: templates/user/edit-profile.html:319 -msgid "Editor theme" -msgstr "Giao diện khung code" +#: templates/user/edit-profile.html:305 +msgid "Time zone:" +msgstr "Múi giờ:" + +#: templates/user/edit-profile.html:309 +msgid "Language:" +msgstr "Ngôn ngữ:" -#: templates/user/edit-profile.html:324 -msgid "Math engine" +#: templates/user/edit-profile.html:313 +msgid "Editor theme:" +msgstr "Giao diện khung code:" + +#: templates/user/edit-profile.html:318 +msgid "Math engine:" msgstr "" -#: templates/user/edit-profile.html:345 templates/user/edit-profile.html:347 +#: templates/user/edit-profile.html:340 msgid "Change your avatar" msgstr "Đổi ảnh đại diện của bạn" -#: templates/user/edit-profile.html:352 +#: templates/user/edit-profile.html:345 msgid "Change your password" msgstr "Đổi mật khẩu của bạn" -#: templates/user/edit-profile.html:365 -msgid "Two-factor Authentication is enabled" -msgstr "Xác thực 2 yếu tố đã bật" - -#: templates/user/edit-profile.html:368 templates/user/edit-profile.html:371 -msgid "Disable" -msgstr "Tắt" +#: templates/user/edit-profile.html:358 +msgid "Two-factor Authentication is enabled:" +msgstr "Xác thực 2 yếu tố đã bật:" -#: templates/user/edit-profile.html:373 +#: templates/user/edit-profile.html:366 msgid "Refresh" msgstr "" -#: templates/user/edit-profile.html:376 -msgid "Scratch Codes:" +#: templates/user/edit-profile.html:369 +msgid "Scratch codes:" msgstr "" -#: templates/user/edit-profile.html:380 +#: templates/user/edit-profile.html:373 msgid "Hidden" msgstr "Ẩn" -#: templates/user/edit-profile.html:390 -msgid "Two-factor Authentication is disabled" -msgstr "Xác thực 2 yếu tố đã tắt" +#: templates/user/edit-profile.html:383 +msgid "If you ever need more scratch codes, you can regenerate them here." +msgstr "" #: templates/user/edit-profile.html:391 -msgid "Enable" -msgstr "Bật" +msgid "Two-factor Authentication is disabled:" +msgstr "Xác thực 2 yếu tố đã tắt:" -#: templates/user/edit-profile.html:411 -msgid "Security keys" -msgstr "Khoá bảo mật" +#: templates/user/edit-profile.html:412 +msgid "Security keys:" +msgstr "Khoá bảo mật:" -#: templates/user/edit-profile.html:428 +#: templates/user/edit-profile.html:429 msgid "Enter a name for this key" msgstr "Đặt tên cho khoá này" -#: templates/user/edit-profile.html:429 +#: templates/user/edit-profile.html:430 msgid "Add" msgstr "Thêm" -#: templates/user/edit-profile.html:438 +#: templates/user/edit-profile.html:439 msgid "Update profile" msgstr "Cập nhật" #: templates/user/pp-row.html:25 #, python-format -msgid "%(pp).2fpp" +msgid "weighted %(percent)s (%(pp)spp)" msgstr "" #: templates/user/prepare-data.html:79 msgid "We are currently preparing your data." msgstr "" -#: templates/user/prepare-data.html:86 +#: templates/user/prepare-data.html:87 msgid "Once your data is ready, you will find a download link on this page." msgstr "" #: templates/user/user-about.html:26 -msgid "Admin Notes" -msgstr "" +msgid "Admin notes:" +msgstr "Ghi chú của admin:" -#: templates/user/user-about.html:41 +#: templates/user/user-about.html:40 msgid "You have not shared any information." msgstr "Bạn chưa cung cấp thông tin về bản thân." -#: templates/user/user-about.html:43 +#: templates/user/user-about.html:42 msgid "This user has not shared any information." msgstr "Người dùng không chia sẻ thông tin." -#: templates/user/user-about.html:49 +#: templates/user/user-about.html:48 msgid "Badges & Awards" msgstr "Huy hiệu" -#: templates/user/user-about.html:56 +#: templates/user/user-about.html:55 msgid "This user has not earned any badges or awards." msgstr "Người dùng này không có huy hiệu nào." -#: templates/user/user-about.html:70 +#: templates/user/user-about.html:69 msgid "Sun" msgstr "CN" -#: templates/user/user-about.html:75 +#: templates/user/user-about.html:74 msgid "Mon" msgstr "T2" -#: templates/user/user-about.html:80 +#: templates/user/user-about.html:79 msgid "Tues" msgstr "T3" -#: templates/user/user-about.html:85 +#: templates/user/user-about.html:84 msgid "Wed" msgstr "T4" -#: templates/user/user-about.html:90 +#: templates/user/user-about.html:89 msgid "Thurs" msgstr "T5" -#: templates/user/user-about.html:95 +#: templates/user/user-about.html:94 msgid "Fri" msgstr "T6" -#: templates/user/user-about.html:100 +#: templates/user/user-about.html:99 msgid "Sat" msgstr "T7" -#: templates/user/user-about.html:109 +#: templates/user/user-about.html:108 msgid "Less" msgstr "Ít" -#: templates/user/user-about.html:115 +#: templates/user/user-about.html:114 msgid "More" msgstr "Nhiều" -#: templates/user/user-about.html:123 +#: templates/user/user-about.html:122 msgid "Rating history" msgstr "Lịch sử rating" -#: templates/user/user-about.html:194 -msgid "past year" -msgstr "năm vừa qua" - -#: templates/user/user-about.html:211 -msgid "#(cnt)d total submission" -msgstr "Số lượng bài nộp: #(cnt)d" - -#: templates/user/user-about.html:211 -msgid "#(cnt)d total submissions" -msgstr "Số lượng bài nộp: #(cnt)d" - -#: templates/user/user-about.html:216 -msgid "#(cnt)d submission in the last year" -msgstr "Số bài nộp trong năm vừa qua: #(cnt)d" - -#: templates/user/user-about.html:216 -msgid "#(cnt)d submissions in the last year" -msgstr "Số bài nộp trong năm vừa qua: #(cnt)d" - -#: templates/user/user-about.html:221 -msgid "#(cnt)d submission in #(year)d" -msgstr "Số bài nộp trong năm #(year)d: #(cnt)d" - -#: templates/user/user-about.html:221 -msgid "#(cnt)d submissions in #(year)d" -msgstr "Số bài nộp trong năm #(year)d: #(cnt)d" - -#: templates/user/user-about.html:239 -msgid "#(cnt)d submission on #(date)s" -msgstr "#(date)s: #(cnt)d bài" - -#: templates/user/user-about.html:239 -msgid "#(cnt)d submissions on #(date)s" -msgstr "#(date)s: #(cnt)d bài" - -#: templates/user/user-base.html:42 +#: templates/user/user-base.html:18 msgid "Problems solved:" msgstr "Số bài đã giải:" -#: templates/user/user-base.html:46 +#: templates/user/user-base.html:22 msgid "Rank by points:" msgstr "Hạng điểm:" -#: templates/user/user-base.html:49 +#: templates/user/user-base.html:25 msgid "Total points:" msgstr "Tổng điểm:" -#: templates/user/user-base.html:55 +#: templates/user/user-base.html:31 msgid "Contribution points:" msgstr "Đóng góp:" -#: templates/user/user-base.html:69 +#: templates/user/user-base.html:45 #, python-format msgid "%(counter)s contest written" msgid_plural "%(counter)s contests written" msgstr[0] "Đã tham gia %(counter)s kỳ thi" -#: templates/user/user-base.html:76 +#: templates/user/user-base.html:52 msgid "Rank by rating:" msgstr "Hạng rating:" -#: templates/user/user-base.html:78 +#: templates/user/user-base.html:54 msgid "Rating:" msgstr "" -#: templates/user/user-base.html:79 +#: templates/user/user-base.html:55 msgid "Min. rating:" msgstr "" -#: templates/user/user-base.html:80 +#: templates/user/user-base.html:56 msgid "Max rating:" msgstr "" @@ -7405,12 +7546,12 @@ msgstr "Tác giả của các bài" msgid "Hide problems I've solved" msgstr "Ẩn các bài tôi đã giải được" -#: templates/user/user-problems.html:95 +#: templates/user/user-problems.html:96 #, python-format -msgid "%(points).1f points" -msgstr "" +msgid "%(group)s (%(val)s points)" +msgstr "%(group)s (%(val)s điểm)" -#: templates/user/user-problems.html:112 +#: templates/user/user-problems.html:115 #, python-format msgid "%(points)s / %(total)s" msgstr "" @@ -7419,19 +7560,19 @@ msgstr "" msgid "Create new blog post" msgstr "Tạo blog mới" -#: templates/user/user-tabs.html:13 +#: templates/user/user-tabs.html:16 msgid "Impersonate" msgstr "Mạo danh" -#: templates/user/user-tabs.html:14 +#: templates/user/user-tabs.html:17 msgid "Ban this user" msgstr "Ban người dùng này" -#: templates/user/user-tabs.html:17 +#: templates/user/user-tabs.html:20 msgid "Admin User" msgstr "" -#: templates/user/user-tabs.html:20 +#: templates/user/user-tabs.html:23 msgid "Admin Profile" msgstr "" @@ -7442,130 +7583,3 @@ msgstr "Số bài" #: templates/widgets/select_all.html:8 msgid "Check all" msgstr "Chọn tất cả" - -#~ msgid "There is **%(count)s** problem in this contest." -#~ msgid_plural "There are **%(count)s** problems in this contest." -#~ msgstr[0] "Có **%(count)s** bài tập trong kỳ thi này." - -#~ msgid "on %(time)s" -#~ msgstr "vào lúc %(time)s" - -#~ msgid "You cannot change your password." -#~ msgstr "Bạn không thể thay đổi mật khẩu." - -#~ msgid "N j, Y, G:i" -#~ msgstr "j, M, Y, G:i" - -#~ msgid "Contest has not ended" -#~ msgstr "Kỳ thi chưa kết thúc" - -#~ msgid "%s spectating in %s" -#~ msgstr "%s spectating trong %s" - -#~ msgid "%s in %s, v%d" -#~ msgstr "%s trong %s, v%d" - -#~ msgid "%s in %s" -#~ msgstr "%s trong %s" - -#~ msgid "Page %s of %s" -#~ msgstr "Trang %s /%s" - -#~ msgid "%s's participation in %s" -#~ msgstr "%s tham gia vào %s" - -#~ msgid "AC Rate" -#~ msgstr "Tỷ lệ AC" - -#~ msgid "You cannot create blog post." -#~ msgstr "Bạn không thể tạo blog." - -#~ msgid "1st virtual participation of this user" -#~ msgstr "Lần tham gia ảo đầu tiên của người dùng" - -#~ msgid "2nd virtual participation of this user" -#~ msgstr "Lần tham gia ảo thứ 2 của người dùng" - -#~ msgid "Personal Info" -#~ msgstr "Thông tin chi tiết" - -#~ msgid "Already in contest" -#~ msgstr "Đã trong kỳ thi" - -#~ msgid "You are already in a contest: \"%s\"." -#~ msgstr "Bạn đang ở một kỳ thi: \"%s\"." - -#~ msgid "Add new problem" -#~ msgstr "Thêm bài" - -#~ msgid "{0} Blog Post" -#~ msgstr "{0} blog" - -#, fuzzy -#~| msgid "#(cnt)d submission in #(year)d" -#~ msgid "#(cnt)d submission in (year)d" -#~ msgstr "Số bài nộp trong năm (year)d: #(cnt)d" - -#~ msgid "%s Members" -#~ msgstr "%s thành viên" - -#~ msgid "Problems list of %s" -#~ msgstr "Danh sách bài của %s" - -#~ msgid "Problems list of" -#~ msgstr "Danh sách bài của" - -#~ msgid "Contests list of %s" -#~ msgstr "Danh sách kỳ thi của %s" - -#~ msgid "Contests list of" -#~ msgstr "Danh sách kỳ thi của" - -#~ msgid "Create Problem" -#~ msgstr "Tạo bài tập mới" - -#~ msgid "Create Contest" -#~ msgstr "Tạo kỳ thi mới" - -#~ msgid "View members" -#~ msgstr "Xem các thành viên" - -#~ msgid "Disallowed problems" -#~ msgstr "Bài không được cho phép" - -#~ msgid "These problems are NOT allowed to be submitted in this language" -#~ msgstr "Đề này KHÔNG được cho phép nộp bằng ngôn ngữ này" - -#~ msgid "volatility" -#~ msgstr "volative" - -#~ msgid "Volatility:" -#~ msgstr "Độ biến động:" - -#~ msgid "" -#~ "This image will appear in link sharing embeds. For example: Facebook, etc" -#~ msgstr "Hình ảnh hiển thị khi chia sẻ link kỳ thi. Ví dụ: Facebook, v.v." - -#~ msgid "Click here to set tests points" -#~ msgstr "Nhấn nút này để chỉnh điểm của test" - -#~ msgid "Has pretest?" -#~ msgstr "Có pretest?" - -#~ msgid "These problems are included in this group of problems" -#~ msgstr "Các bài này đã được bao gồm trong nhóm" - -#~ msgid "These problems are included in this type of problems" -#~ msgstr "Các bài đã chọn đã được bao gồm trong loại bài này" - -#~ msgid "Language statistics" -#~ msgstr "Thống kê theo ngôn ngữ" - -#~ msgid "Submission Statistics" -#~ msgstr "Thống kê bài nộp" - -#~ msgid "AC Submissions by Language" -#~ msgstr "Các bài nộp đã AC theo ngôn ngữ" - -#~ msgid "Submissions in last 30 days" -#~ msgstr "Các bài nộp trong vòng 30 ngày gần đây" diff --git a/make_style.sh b/make_style.sh index bd5520b94..80392ed84 100755 --- a/make_style.sh +++ b/make_style.sh @@ -14,11 +14,14 @@ if ! [ -x "$(command -v autoprefixer)" ]; then exit 1 fi -FILES=(sass_processed/style.css sass_processed/content-description.css sass_processed/table.css - sass_processed/ranks.css sass_processed/martor-description.css) - cd "$(dirname "$0")" || exit -sass resources:sass_processed -echo -postcss "${FILES[@]}" --verbose --use autoprefixer -d resources +build_style() { + echo "Creating $1 style..." + cp resources/vars-$1.scss resources/vars.scss + sass resources:sass_processed + postcss sass_processed/style.css sass_processed/martor-description.css sass_processed/select2-dmoj.css --verbose --use autoprefixer -d $2 +} + +build_style 'default' 'resources' +build_style 'dark' 'resources/dark' diff --git a/manage.py b/manage.py index 73945155a..adaffbd99 100755 --- a/manage.py +++ b/manage.py @@ -11,7 +11,4 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dmoj.settings') from django.core.management import execute_from_command_line - # noinspection PyUnresolvedReferences - import django_2_2_pymysql_patch # noqa: F401, imported for side effect - execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt index 77b171c43..7fafbc2d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,34 +1,35 @@ -Django>=2.2,<3 +Django>=3.2,<4 django-cleanup -django_compressor -django-mptt +django_compressor>=3 +django-mptt>=0.13 django-pagedown<2 -django-registration-redux -django-reversion +django-registration-redux>=2.10 +django-reversion>=3.0.5,<4 django-social-share -django-sortedm2m @ git+https://github.com/DMOJ/django-sortedm2m.git +django-sortedm2m>=3.1.0 django-impersonate django-redis dmoj-wpadmin @ git+https://github.com/DMOJ/dmoj-wpadmin.git lxml -Pygments +Pygments<2.12 mistune<2 -social-auth-app-django +social-auth-core==4.3.0 +social-auth-app-django==5.0.0 pytz django-statici18n pika ua-parser pyyaml jinja2 -django_jinja +django_jinja>=2.5.0 llist requests -django-fernet-fields +django-fernet-fields @ git+https://github.com/DMOJ/django-fernet-fields.git pyotp qrcode[pil] -jsonfield +jsonfield @ git+https://github.com/DMOJ/jsonfield.git pymoss -packaging +packaging<22 celery ansi2html @ git+https://github.com/DMOJ/ansi2html.git sqlparse @@ -36,8 +37,10 @@ lupa martor @ git+https://github.com/VNOI-Admin/martor.git netaddr webauthn<1 -bleach<5 +bleach[css] markdown2 @ git+https://github.com/VNOI-Admin/python-markdown2.git discord-webhook django-admin-sortable2<2 icalendar +# This is a celery dependency whose latest major version is breaking everything. +importlib-metadata<5 diff --git a/resources/vnoj/accordion/accordion.js b/resources/accordion.js similarity index 100% rename from resources/vnoj/accordion/accordion.js rename to resources/accordion.js diff --git a/resources/vnoj/accordion/accordion.css b/resources/accordion.scss similarity index 77% rename from resources/vnoj/accordion/accordion.css rename to resources/accordion.scss index 51ad16616..5d8cf126e 100644 --- a/resources/vnoj/accordion/accordion.css +++ b/resources/accordion.scss @@ -1,3 +1,5 @@ +@import "vars"; + .accordion { padding: 0; } @@ -14,9 +16,9 @@ } .accordion .card-toggle { - background: #000; + color: $color_primary0; + background: $color_primary75; border-radius: 4px 4px 0 0; - color: white; display: block; font-size: 1.1em; padding: 0.5em; @@ -25,5 +27,5 @@ } .accordion .card-toggle:hover { - background: rgba(0, 0, 0, 0.7); + background: rgba($color_primary75, 0.7); } diff --git a/resources/base-description.scss b/resources/base-description.scss index 05aaffd42..50eda87bf 100644 --- a/resources/base-description.scss +++ b/resources/base-description.scss @@ -17,7 +17,7 @@ h1, h2, h3, h4, h5, h6 { font-weight: normal; - color: #111; + color: $color_primary90; margin-bottom: 0.75em; padding: 0; background: 0; @@ -43,7 +43,7 @@ h4 { font-size: 1.4em; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid rgba($color_primary100, 0.1); line-height: 1.225; padding-bottom: 0.3em; padding-top: 0.5em; @@ -59,8 +59,8 @@ } blockquote { - color: #666; - border-left: 0.5em #EEE solid; + color: $color_primary50; + border-left: 0.5em rgba($color_primary100, 0.1) solid; margin: 1em 1em; padding: 1px 1px 1px 1.5em; } @@ -75,11 +75,11 @@ border: solid 1px #c8ccd0; } - blockquote.spoiler > * { + blockquote.spoiler > * { display: none; } - blockquote.spoiler::before { + blockquote.spoiler::before { content: ""; display: block; position: absolute; @@ -90,8 +90,8 @@ border-radius: 8px; background: #c8ccd0; } - - blockquote.spoiler::after { + + blockquote.spoiler::after { content: "Reveal spoiler!"; background-repeat: no-repeat; background-position: center right; @@ -121,13 +121,13 @@ height: 0; border: 0; font-style: italic; - border-bottom: 1px solid $border_gray; + border-bottom: 1px solid $color_primary25; margin: 25px 0 20px 0; padding: 0; } pre, code, kbd, samp, span.code { - color: #000; + color: $color_primary100; page-break-inside: avoid; font-family: $monospace-fonts; font-size: 0.98em; @@ -137,11 +137,11 @@ font-family: $monospace-fonts !important; margin: 0 2px; padding: 0 5px; - border: 1px solid $border_gray; - background-color: #f8f8f8; + border: 1px solid $color_primary25; + background-color: $color_primary5; border-radius: $widget_border_radius; font-size: 0.95em; - color: #444; + color: $color_primary75; } pre { @@ -152,16 +152,16 @@ padding: 0; background: transparent; font-size: 1em; - color: black; + color: $color_primary100; } white-space: pre-wrap; word-wrap: break-word; margin: 1.5em 0 1.5em 0; padding: 1em; - border: 1px solid $border_gray; - background-color: #f8f8f8; - color: black; + border: 1px solid $color_primary25; + background-color: $color_primary5; + color: $color_primary100; border-radius: $widget_border_radius; } @@ -206,7 +206,7 @@ } ul, ol { - padding: 0 0 0 2em !important; + padding: 0 0 0 2em; } li p:last-child { diff --git a/resources/base.scss b/resources/base.scss index a351c2d08..1aea1a7d5 100644 --- a/resources/base.scss +++ b/resources/base.scss @@ -18,14 +18,14 @@ } a { - color: #1958c1; + color: $color_link75; &:hover { - color: #0645ad; + color: $color_link100; } &:active { - color: #faa700; + color: $color_link200; } } @@ -40,7 +40,7 @@ img { } table.sortable thead { - background-color: $background_gray; + background-color: $color_primary10; color: #666; font-weight: bold; cursor: default; @@ -71,12 +71,12 @@ hr { height: 0; border: 0; font-style: italic; - border-bottom: 1px solid $border_gray; + border-bottom: 1px solid $color_primary25; padding: 0; } .dashed { - border-bottom: 1px dashed $border_gray; + border-bottom: 1px dashed $color_primary25; } th { @@ -85,10 +85,10 @@ th { .form-area { display: inline-block; - background: $background_light_gray; - padding: 5px 10px 10px 15px; + background: $color_primary5; + padding: 5px 10px 10px; border-radius: $widget_border_radius; - border: 1px solid $border_gray; + border: 1px solid $color_primary25; } div.info-float { @@ -97,7 +97,7 @@ div.info-float { } footer { - color: gray; + color: $color_primary50; display: block; width: 100%; position: absolute; @@ -110,9 +110,9 @@ body { margin: 0 auto; font-size: $base_font_size; line-height: 1.231; - background: $background_light_gray; + background: $color_primary5; font-family: "Segoe UI", "Lucida Grande", Arial, sans-serif; - color: #000; + color: $color_primary100; height: 100%; } @@ -154,225 +154,6 @@ h4 { margin: 0; } -header { - background: #111; - color: #aaa; - text-align: left; - display: block; - height: 60px; - margin-top: -10px; - padding: 10px 10px 10px 5%; -} - -#user-links { - top: 0; - right: 0; - position: absolute; - color: #5c5954; - padding-right: 10px; - display: inline-flex; - min-height: 100%; - align-items: center; - white-space: nowrap; - - a { - color: #FFF; - } - - li { - text-transform: none; - } - - & > ul { - display: block; - margin: 0; - - & > li > a > span { - font-size: 13px; - height: 36px; - padding-top: 8px; - display: block; - white-space: nowrap; - - & > img { - vertical-align: middle; - border-radius: $widget_border_radius; - display: inline; - margin: 2px 6px 0 5px; - } - - & > span { - color: #eee; - vertical-align: middle; - display: inline; - margin-top: 11px; - margin-right: 9px; - padding: 0; - } - } - } -} - -#nav-background { - position: absolute; - z-index: 3; - top: 0; - left: 0; - width: 100%; - height: 56px; - background: $widget_black; -} - -#nav-shadow { - height: 3px; - background: linear-gradient(rgba($widget_black, 0.5), transparent); -} - -#nav-container { - background: $widget_black; - - // opacity: 0.77 - // filter: alpha(opacity=77) - height: 100%; -} - -#navigation { - position: fixed; - top: 0; - left: 0; - right: 0; -} - -nav { - position: relative; - position: -webkit-sticky; - position: sticky; - top: 0; - width: 100%; - margin: 0 auto; - z-index: 500; - text-align: left; - - ul { - margin: 0 0 0 -5px !important; - padding: 0 0 0 1%; - text-align: left; - display: inline; - list-style: none; - background: transparent; - - li { - display: inline-block; - color: #FFF; - text-transform: uppercase; - position: relative; - font-family: 'Source Sans Pro', sans-serif; - &.home-nav-element a { - padding: 0; - height: 44px; - - &:hover { - border-bottom: none; - padding-top: 0; - padding-bottom: 0; - } - } - - a, button { - text-decoration: none; - vertical-align: middle; - color: #FFF; - padding: 14px 7px; - height: 20px; - display: inline-table; - - &:link { - color: #FFF; - } - - &:hover { - color: #FFF; - background: rgba(255, 255, 255, 0.25); - margin: 0; - } - - &.active { - color: #FFF; - - border: 5px solid transparent; - border-bottom: 5px solid $highlight_blue; - padding: 9px 2px; - } - - .nav-expand { - display: none; - } - } - - ul { - padding: 0; - position: absolute; - left: 5px; - display: none; - color: #fff; - background: $widget_black; - margin: 0 !important; - box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4); - - li { - a.active { - border: none; - border-left: 5px solid $highlight_blue; - } - } - - li { - display: block; - - a, button { - padding: 8px 20px 8px 8px !important; - font-size: 0.8em; - line-height: 18px; - display: block; - border-left: 4px solid lightskyblue; - white-space: nowrap; - } - } - } - - button { - background: none; - text-align: left; - border: none; - width: 100%; - border-radius: 0; - height: auto; - } - - &:hover > ul, &:active > ul, &:focus > ul { - display: block !important; - } - - &.home-nav-element a:hover { - border-bottom: 0; - padding-top: 0; - padding-bottom: 0; - background: transparent; - } - } - } - - .nav-divider { - width: 1px; - vertical-align: middle; - padding-left: 3px; - display: inline-block; - height: 32px; - margin-right: 1px; - border-right: 3px solid rgba(255, 255, 255, 0.15); - } -} - hr { color: rgba(0, 0, 0, 0.2); } @@ -387,15 +168,15 @@ hr { display: block; .title { - color: #393630; + color: $color_primary75; } } footer { text-align: center; height: 40px; - border-top: 1px solid $border_gray; - background: #ededed; + border-top: 1px solid $color_primary25; + background: $color_primary10; } html { @@ -443,7 +224,7 @@ noscript #noscript { } .time { - color: #555; + color: $color_primary75; } .toggle { @@ -466,18 +247,6 @@ noscript #noscript { margin: 0.3em 0 0.5em 0; } -#navicon { - display: none; -} - -#nav-placeholder { - height: 47px; - max-width: 107em; - background: white; - border-right: 1px solid $border_gray; - border-left: 1px solid $border_gray; -} - #contest-info { font-size: 1.25em; border: 5px solid $highlight_blue; @@ -506,36 +275,13 @@ noscript #noscript { display: inline-block; } -.spacer { - display: inline-block; - flex: 1 1 1px; -} - -#user-links { - height: 100%; - ul { - margin: 0; - - li { - display: block; - height: 100%; - - a { - display: block; - padding: 0; - height: 100%; - } - } - } -} - #page-container { min-height: 100%; position: relative; margin: 0 auto; - border-right: 1px solid $border_gray; - border-left: 1px solid $border_gray; - background: white; + border-right: 1px solid $color_primary25; + border-left: 1px solid $color_primary25; + background: $color_pageBg; max-width: 100em; } @@ -574,113 +320,8 @@ math { } @media (max-width: 760px) { - #navigation { - height: 36px; - } - - #nav-shadow { - height: 3px; - background: linear-gradient(rgba($widget_black, 0.5), transparent); - } - #navicon { - transition-duration: 0.25s; - display: block; - line-height: 26px; - font-size: 2em; - color: #FFF; - padding: 0 0.25em; - margin: 4px 0.25em; - white-space: nowrap; - float: left; - - &.hover { - color: #4db7fe; - text-shadow: 0 0 5px $highlight_blue; - transition-duration: 0.25s; - } - } - - #nav-list { - display: none; - padding: 0; - margin-left: 0; - border-left: 4px solid $highlight_blue; - position: fixed; - top: 36px; - background: $widget_black; - bottom: 0; - width: 8em; - left: 0; - box-shadow: none; - - li { - display: block; - - a { - display: block; - - .nav-expand { - float: right; - display: block; - height: inherit; - margin: (-13px) -7px; - padding: inherit; - } - } - - ul { - left: 8em; - top: auto; - bottom: auto; - margin-top: -36px; - } - - &.home-nav-element { - display: none; - } - } - } - - #user-links { - bottom: 6px; - right: 6px; - position: absolute; - - & > ul > li { - & > a > span { - padding-top: 4px; - height: 32px; - } - - & > ul { - left: 0 !important; - margin-top: 0 !important; - } - } - } - #content { width: auto; padding: 0 5px; } } - -@media not all and (max-width: 760px) { - #nav-list { - display: block !important; - - li { - &.home-menu-item { - display: none; - } - - &:not(:hover) > ul { - display: none !important; - } - - ul { - left: 0 !important; - } - } - } -} diff --git a/resources/blog.scss b/resources/blog.scss index 792663058..ce9c8848d 100644 --- a/resources/blog.scss +++ b/resources/blog.scss @@ -4,30 +4,45 @@ padding-right: 0em; flex: 73.5%; vertical-align: top; - margin-right: 0; .post { - padding-top: 0.5em; + margin: 0em 0.5em; + + &:first-child { + margin-top: 1.1em; + } + + &:last-child { + border-bottom: none; + } .title { font-weight: 600; font-size: 1.7em; a { - color: #5b80b9 !important; + color: $color_link50; &:hover { - color: #0645ad !important; + color: $color_link100; + } + + &:active { + color: $color_link200; } } } - &:last-child { - border-bottom: none; + .comment-count { + font-size: 12px; + } - .blog-body { - padding-bottom: none; - } + .comment-count-link { + color: $color_primary75; + } + + .comment-icon { + padding: 0 0.2em 0 0.5em; } .blog-body { @@ -51,11 +66,6 @@ } .blog-sidebox { - h3 { - padding-bottom: 0.25em; - padding-left: 0.5em; - } - ul { list-style: none; padding-left: 1em; @@ -69,24 +79,11 @@ .contest { padding: 1.25em 0 1.5em 0; text-align: center; - border-bottom: 1px solid $border_gray; + border-bottom: 1px solid $color_primary25; &:last-child { border-bottom: none; } - - .name { - font-size: 1.25em; - font-weight: 500; - - a { - color: #5b80b9 !important; - - &:hover { - color: #0645ad !important; - } - } - } } .sidebox-ongoing-contest { @@ -101,7 +98,7 @@ } .blog-content { - margin-right: 1em !important; + margin-right: 1em; } #mobile.tabs { @@ -114,13 +111,12 @@ } #mobile.tabs { - margin: 0; - margin-bottom: 1em; + margin: 0 0 1em; } .rssatom { text-align: right; - padding: 0.25em; + margin: 0.25em; display: block; span { @@ -142,3 +138,14 @@ } } } + +.open-tickets { + .object { + margin-left: 1em; + font-style: italic; + } + + .author { + margin-left: 1em; + } +} diff --git a/resources/comments.scss b/resources/comments.scss index ae2f0ccd7..31af4c3d1 100644 --- a/resources/comments.scss +++ b/resources/comments.scss @@ -7,45 +7,29 @@ a { &.upvote-link, &.downvote-link { - color: black; + color: $color_primary100; } &.voted { - text-shadow: 0 0 4px black, 0 0 9px blue; - } -} - -.comment-area { - margin-right: 30px; - - h2 { - margin-bottom: 20px; + text-shadow: 0 0 7px $color_primary100, 0 0 15px $color_voted_mark; } } .no-comments-message { - margin-top: -15px; - margin-left: 2.75em; + margin: 10px 0 15px 2.75em; } -.comment-author { - font-weight: bold; - color: #333; +.comment-header-space { + height: 20px; } -.comment-header { - color: rgba(1, 1, 1, 1); - background: rgba(0, 0, 0, 0.1); - padding: 5px 10px 5px 5px; - margin-left: 30px; - border: 1px solid $border_gray; - border-radius: $widget_border_radius; - display: flex; +.comment-lock { + margin: 0 0 5px; } -.comment-lock { - width: 100%; - padding-left: 14px; +.comments.top-level-comments { + padding: 0; + margin: 0 0 5px; } .comment-spacer { @@ -54,14 +38,14 @@ a { .comment-edits:not(:empty) { padding-right: 2px; - color: #444; + color: $color_primary75; } .comment-operation { flex: auto; .fa { - color: #444; + color: $color_primary75; } a + a { @@ -69,17 +53,15 @@ a { } } -.comments.top-level-comments { - margin-right: -26px; -} - -.comment-submit { +.form-area.comment-submit { + padding-left: 15px; + padding-right: 15px; width: 100%; + box-sizing: border-box; } .comment-post-wrapper { - padding-bottom: 2px; - padding-right: 10px; + padding-bottom: 5px; input, textarea { min-width: 100%; @@ -90,22 +72,10 @@ a { } } -.comment-box { - border-radius: $widget_border_radius; - padding: 5px 10px 10px 15px; - border: 1px solid $border_gray; - background: rgba(0, 0, 0, 0.01); -} - .comment { list-style: none none; border-radius: $widget_border_radius; - margin: 0px -4px 10px -40px; - - &:target > .comment-box { - border-left: 10px solid $highlight_blue; - padding-left: 5px; - } + margin: 0 0 5px; &:before { display: block; @@ -117,31 +87,103 @@ a { } .reply-comment { - margin: 0 23px 10px -40px; + margin: 0 0 5px; +} + +.comment-body { + word-wrap: break-word; + word-break: break-word; +} + +.previous-revision, .next-revision { + color: $color_primary75; } -@media (max-width: 760px) { - .comments { - padding-inline-start: 5%; +.new-comments { + .comment-display { + display: flex; + padding-left: 1em; + padding-top: 0.5em !important; + border: 1px solid $color_primary25; + background: $color_primary5; + border-radius: $widget_border_radius; } - .comment { - margin-left: -5%; + + .comment .detail { + margin: 0px 15px 0px; + width: 100%; + + .header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 2px 0px; + font-weight: normal; + border-bottom: 1px $color_primary50 solid; + color: $color_primary50; + text-align: right; + } + } + + .comment:target > .comment-display { + border: 1px solid $highlight_blue; + border-left: 10px solid $highlight_blue; + padding-left: 5px; + } + + .comment-edits { + padding-right: 0.75em; + } + + .header i { + color: $color_primary50 !important; + } + + .info { + padding-top: 0.4em; + display: flex; } -} -.comment-author { - margin-bottom: 1em; + .gravatar-mobile { + display: none; + } + + .gravatar-main { + display: unset; + } - img { - width: 1.25em; - height: 1.25em; - border-radius: 0.2em; - vertical-align: bottom; - margin-right: 0.3em; + .vote { + margin-right: 1em; + height: 75px; + padding-top: 0.4em; + } + + @media (max-width: 760px) { + img.user-gravatar { + display: inline-block; + border-radius: 2px; + } + + .gravatar-mobile { + display: unset; + } + + .gravatar-main { + display: none; + } + + .vote { + margin-right: 0em; + } } } -.comment-body { - word-wrap: break-word; - word-break: break-word; +.bad-comment { + opacity: 0.3; + + &:hover { + opacity: 1; + /* This is necessary to prevent random flickering */ + transform: translatez(0); + } } diff --git a/resources/common.js b/resources/common.js index 43b7883e0..4a3008c0d 100644 --- a/resources/common.js +++ b/resources/common.js @@ -1,15 +1,3 @@ -// IE 8 -if (!Array.prototype.indexOf) { - Array.prototype.indexOf = function (obj) { - for (var i = 0; i < this.length; i++) { - if (this[i] == obj) { - return i; - } - } - return -1; - } -} - if (!String.prototype.startsWith) { String.prototype.startsWith = function (searchString, position) { return this.substr(position || 0, searchString.length) === searchString; @@ -132,12 +120,11 @@ $(function () { var $nav_list = $('#nav-list'); $('#navicon').click(function (event) { event.stopPropagation(); - $nav_list.toggle(); + $nav_list.toggleClass('show-list'); if ($nav_list.is(':hidden')) $(this).blur().removeClass('hover'); else { $(this).addClass('hover'); - $nav_list.find('li ul').css('left', $('#nav-list').width()).hide(); } }).hover(function () { $(this).addClass('hover'); @@ -147,7 +134,7 @@ $(function () { $nav_list.find('li a .nav-expand').click(function (event) { event.preventDefault(); - $(this).parent().siblings('ul').css('display', 'block'); + $(this).parent().siblings('ul').toggleClass('show-list'); }); $nav_list.find('li a').each(function () { @@ -165,7 +152,7 @@ $(function () { }); $('html').click(function () { - $nav_list.hide(); + $nav_list.removeClass('show-list'); }); $.ajaxSetup({ @@ -213,23 +200,22 @@ function count_down(label) { } function register_time(elems, limit) { - limit = limit || 300; + limit = 60; elems.each(function () { var outdated = false; var $this = $(this); var time = moment($this.attr('data-iso')); var rel_format = $this.attr('data-format'); - var abs = $this.text(); function update() { if ($('body').hasClass('window-hidden')) return outdated = true; outdated = false; - if (moment().diff(time, 'days') > limit) { - $this.text(abs); - return; + if (moment().diff(time, 'seconds') < limit) { + $this.text(rel_format.replace('{time}', time.fromNow())); + } else { + $this.text(rel_format.replace('{time}', time.format("h:mm:ss a, DD/MM/YYYY"))); } - $this.text(rel_format.replace('{time}', time.fromNow())); setTimeout(update, 10000); } @@ -331,29 +317,6 @@ $(function () { }); }); -$.fn.textWidth = function () { - var html_org = $(this).html(); - var html_calc = '' + html_org + ''; - $(this).html(html_calc); - var width = $(this).find('span:first').width(); - $(this).html(html_org); - return width; -}; - -$(function () { - $('.tabs').each(function () { - var $this = $(this), $h2 = $(this).find('h2'), $ul = $(this).find('ul'); - var cutoff = ($h2.textWidth() || 0) + 20, handler; - $ul.children().each(function () { - cutoff += $(this).width(); - }); - $(window).resize(handler = function () { - $this.toggleClass('tabs-no-flex', $this.width() < cutoff); - }); - handler(); - }); -}); - $(function () { // Reveal spoiler $(document).on('click', 'blockquote.spoiler', function (e) { @@ -361,4 +324,3 @@ $(function () { e.stopPropagation(); } ); }); - diff --git a/resources/content-description.scss b/resources/content-description.scss index dd25893e1..6f8fca945 100644 --- a/resources/content-description.scss +++ b/resources/content-description.scss @@ -19,6 +19,7 @@ #content-left { flex: 5; + width: 100%; &.split-common-content { width: 70%; @@ -72,7 +73,7 @@ a.view-pdf { } .info-float .fa { - color: #000; + color: $color_primary100; padding-right: 0.2em; } diff --git a/resources/contest.scss b/resources/contest.scss index 777ced986..b2ec0655e 100644 --- a/resources/contest.scss +++ b/resources/contest.scss @@ -5,56 +5,40 @@ width: 100%; th { - border-bottom: 1px solid $border_gray; - - &.sun { - border-left: 1px solid $border_gray; - } - - &.sun, &.mon, &.tue, &.wed, &.thu, &.fri, &.sat { - font-size: 0.95em; - border-right: 1px solid $border_gray; - background: $background_light_gray; - } + border: 1px solid $color_primary25; + background: $color_primary5; } td { height: 110px; width: 170px; - color: #000; vertical-align: top; - text-align: right; - font-size: 0.75em; - border-right: 1px solid $border_gray; - border-bottom: 1px solid $border_gray; + border: 1px solid $color_primary25; transition-duration: 0.2s; .num { - font-size: 1.1em; + text-align: right; font-weight: bold; display: block; - border-bottom: 1px dashed $border_gray; + border-bottom: 1px dashed $color_primary25; padding-right: 0.2em; margin-bottom: 0.4em; } ul { - text-decoration: none; - text-align: left; + font-size: 0.75em; padding: 0; margin: 0; li { - margin-left: 17px; - margin-bottom: 0.2em; + margin: 0 0 0.2em 17px; i.fa { - color: orange; + color: $color_link200; } a { - text-decoration: none; - color: #222; + color: $color_primary75; &:hover { text-decoration: underline; @@ -64,36 +48,23 @@ } &:hover { - background: rgba(0, 0, 255, 0.3); - color: white; - - .num { - font-weight: bold; - } - - ul li a { - font-weight: normal; - } + background: rgba($color_link100, 0.2); } } .noday { - background: #f1f1f1; + background: $color_primary10; } .today { - background: rgba(255, 255, 100, 0.5); - } - - tr td:first-child { - border-left: 1px solid #aaa; + background: rgba($color_link200, 0.2); } } #banner { - border-bottom: 1px solid rgba(0, 0, 0, 0.2); + border-bottom: 1px solid $color_primary25; padding-bottom: 1em; - color: rgb(85, 85, 85); + color: $color_primary75; font-size: $base_font_size; a.date { @@ -103,13 +74,14 @@ line-height: 1.3; font-size: 2.3em; padding-bottom: 0.15em; + color: $color_link50; - &:link, &:visited { - color: #5B80B9; + &:hover { + color: $color_link100; } - &:hover { - color: #0645AD; + &:active { + color: $color_link200; } } @@ -127,6 +99,10 @@ } } +.top-pagination-bar { + justify-content: space-between; +} + .contest-list { td { vertical-align: middle !important; @@ -144,21 +120,10 @@ height: 4em; } - .floating-time-left { - position: absolute; - left: 0; - } - - .floating-time-right { - position: absolute; - right: 0; - line-height: 1.2em; - } - - .floating-time { - position: absolute; - right: 0; - bottom: 0; + .time-left { + text-align: left; + color: $color_primary50; + padding-top: 0.5em; } .contest-tags { @@ -177,7 +142,7 @@ } a.contest-sort-link { - color: white; + color: $color_primary0; } } @@ -214,9 +179,10 @@ form.contest-join-pseudotab { .contest-participation-operation { float: right; + display: flex; .fa { - color: #444; + color: $color_primary75; } a + a { @@ -225,3 +191,110 @@ form.contest-join-pseudotab { padding: 0 5px; } + +#ranking-table { + font-size: 14px; + + .user-name { + min-width: 20em; + position: relative; + } + + .userinfo a, .user-name a, .user-name form { + display: inline-block !important; + } + + .rating-column { + min-width: 3em; + } + + td { + height: 2.5em; + } + + td:not(.user-name, .personal-info) a:hover { + text-decoration: underline; + } + + a { + display: block; + } + + /* Make problem table headers green on hover */ + th a:hover { + color: #0F0; + } + + th a, th a:link, th a:visited { + color: $color_primary0; + } + + .rank { + min-width: 2.4em + } + + .points { + min-width: 2.4em; + } + + .disqualified { + background-color: $color_contest_ranking_disqualified !important; + } + + .point-denominator { + border-top: 1px solid $color_primary50; + font-size: 0.7em; + } + + .start-time { + display: none; + } + + .personal-info { + display: none; + } + + .virtual-participation { + color: grey; + float: right + } + + .user-points, .user-points a { + color: $color_primary100; + } +} + +#org-dropdown-check-list { + display: inline-block; +} + +#org-check-list-wrapper { + display: none; + background: $color_primary0; + border: 1px solid gray; + padding: 10px; + + right: auto; + position: absolute; + z-index: 1; + + button { + margin-bottom: 5px; + margin-left: 5px; + } + + #org-check-list { + border-bottom: 1px solid gray; + color: $color_primary100; + text-align: left; + font-weight: normal; + + li { + list-style: none; + } + } + + .select2-selection__rendered { + width: 260px !important; + } +} diff --git a/resources/dmmd-preview.css b/resources/dmmd-preview.scss similarity index 75% rename from resources/dmmd-preview.css rename to resources/dmmd-preview.scss index a3236b786..30285ef70 100644 --- a/resources/dmmd-preview.css +++ b/resources/dmmd-preview.scss @@ -1,10 +1,12 @@ +@import "vars"; + div.dmmd-preview { padding: 0; } div.dmmd-preview-update { - background: #ccc; - color: #333; + background: $color_primary25; + color: $color_primary75; text-align: center; cursor: pointer; border-radius: 4px; @@ -37,5 +39,5 @@ div.dmmd-no-button:not(.dmmd-preview-has-content) { } div.dmmd-preview-stale { - background: repeating-linear-gradient(-45deg, #fff, #fff 10px, #f8f8f8 10px, #f8f8f8 20px); + background: repeating-linear-gradient(-45deg, $color_primary0, $color_primary0 10px, $color_primary5 10px, $color_primary5 20px); } diff --git a/resources/featherlight.scss b/resources/featherlight.scss new file mode 100644 index 000000000..94d4fe51b --- /dev/null +++ b/resources/featherlight.scss @@ -0,0 +1,91 @@ +@import "vars"; + +// Based on Featherlight 1.7.14 + +.featherlight { + display: none; + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + z-index: 1000; + text-align: center; + white-space: nowrap; + cursor: pointer; + background: #333; + + &:last-of-type { + background: rgba(0, 0, 0, 0.8); + } + + &:before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + } + + .featherlight-content { + position: relative; + text-align: left; + vertical-align: middle; + display: inline-block; + overflow: auto; + padding: 10px 15px; + border: 1px solid $color_primary25; + border-radius: 10px; + margin-left: 5%; + margin-right: 5%; + max-height: 95%; + background: $color_pageBg; + cursor: auto; + white-space: normal; + + @media (max-width: 1024px) { + padding: 10px; + margin-left: 0; + margin-right: 0; + max-height: 98%; + } + } + + .featherlight-inner { + display: block; + } + + script.featherlight-inner, link.featherlight-inner, style.featherlight-inner { + display: none; + } + + .featherlight-close-icon { + position: absolute; + z-index: 9999; + top: 0; + right: 0; + line-height: 25px; + width: 25px; + cursor: pointer; + text-align: center; + background: rgba($color_pageBg, 0.5); + color: $color_primary100; + border: none; + padding: 0; + } + + .featherlight-close-icon::-moz-focus-inner { + border: 0; + padding: 0; + } + + .featherlight-image { + width: 100%; + } + + iframe { + border: none; + } +} + +.featherlight-iframe .featherlight-content { + border-bottom: 0; + padding: 0; + -webkit-overflow-scrolling: touch; +} diff --git a/resources/math.scss b/resources/math.scss index f3dae2d42..28ebca48f 100644 --- a/resources/math.scss +++ b/resources/math.scss @@ -1,3 +1,5 @@ +@import "vars"; + .mwe-math-mathml-inline { display: inline !important; } @@ -30,12 +32,12 @@ @font-face { font-family: 'Latin Modern Math'; - src: url('libs/latinmodernmath/latinmodern-math.eot'); /* IE9 Compat Modes */ + src: url($path_to_root + '/libs/latinmodernmath/latinmodern-math.eot'); /* IE9 Compat Modes */ src: local('Latin Modern Math'), local('LatinModernMath-Regular'), - url('libs/latinmodernmath/latinmodern-math.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('libs/latinmodernmath/latinmodern-math.woff2') format('woff2'), /* Modern Browsers */ - url('libs/latinmodernmath/latinmodern-math.woff') format('woff'), /* Modern Browsers */ - url('libs/latinmodernmath/latinmodern-math.ttf') format('truetype'); /* Safari, Android, iOS */ + url($path_to_root + '/libs/latinmodernmath/latinmodern-math.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url($path_to_root + '/libs/latinmodernmath/latinmodern-math.woff2') format('woff2'), /* Modern Browsers */ + url($path_to_root + '/libs/latinmodernmath/latinmodern-math.woff') format('woff'), /* Modern Browsers */ + url($path_to_root + '/libs/latinmodernmath/latinmodern-math.ttf') format('truetype'); /* Safari, Android, iOS */ font-weight: normal; font-style: normal; } diff --git a/resources/misc.scss b/resources/misc.scss index 5f8d5fa87..c80fe4a8d 100644 --- a/resources/misc.scss +++ b/resources/misc.scss @@ -1,18 +1,17 @@ @import "vars"; #judge-versions { - display: block; - .version { font-family: $monospace-fonts; } .version-blank { - background: #eee; + background: $color_primary10; } .version-latest { - background: #b3ff3fe6; + background: #af3c; + color: black; } .version-outdated { @@ -20,28 +19,9 @@ color: white; } - tbody { - display: block; - } - - tr { - display: flex; - flex-direction: row; - padding: 0; - - &:first-child { - position: sticky; - top: 42px; - line-height: 1.8em; - } - } - - td, th { - display: block; - flex: 1 0 110px; - overflow-x: hidden; - height: auto; - padding: 7px 5px; + tr:first-child { + position: sticky; + top: 43px; } } @@ -76,3 +56,7 @@ align-content: center; padding-top: 5%; } + +.grayed { + color: $color_primary75; +} diff --git a/resources/navbar.scss b/resources/navbar.scss new file mode 100644 index 000000000..d230ab2df --- /dev/null +++ b/resources/navbar.scss @@ -0,0 +1,299 @@ +// Navbar should look the same in every theme, so vars-common should be enough. +@import "vars-common"; + +#user-links { + top: 0; + right: 0; + position: absolute; + color: #5c5954; + padding-right: 10px; + display: inline-flex; + min-height: 100%; + align-items: center; + white-space: nowrap; + + a { + color: #FFF; + } + + li { + text-transform: none; + } + + & > ul { + display: block; + margin: 0; + + & > li { + & > a { + display: block; + padding: 0; + height: 100%; + + & > span { + font-size: 13px; + padding: 10px 10px; + display: block; + white-space: nowrap; + + & > img { + vertical-align: middle; + border-radius: $widget_border_radius; + margin-right: 6px; + } + + & > span { + vertical-align: middle; + color: #eee; + } + } + } + + & > ul { + left: 0; + } + } + } +} + +#nav-background { + position: absolute; + z-index: 3; + top: 0; + left: 0; + width: 100%; + height: 56px; + background: #231f20; +} + +#nav-shadow { + height: 3px; + background: linear-gradient(rgba(0, 0, 0, 0.5), transparent); +} + +#nav-container { + background: #231f20; + + // opacity: 0.77 + // filter: alpha(opacity=77) + height: 100%; +} + +nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 500; + + ul { + margin: 0 0 0 -5px; + padding: 0 0 0 1%; + display: block; + + li { + display: inline-block; + color: #FFF; + text-transform: uppercase; + position: relative; + font-family: 'Source Sans Pro', sans-serif; + &.home-nav-element a { + padding: 0; + height: 44px; + + &:hover { + border-bottom: none; + padding-top: 0; + padding-bottom: 0; + } + } + + a, button { + text-decoration: none; + vertical-align: middle; + color: #FFF; + padding: 14px 7px; + height: 20px; + display: inline-table; + + &:link { + color: #FFF; + } + + &:hover { + color: #FFF; + background: rgba(255, 255, 255, 0.25); + margin: 0; + } + + &.active { + color: #FFF; + + border: 5px solid transparent; + border-bottom: 5px solid $highlight_blue; + padding: 9px 2px; + } + + .nav-expand { + display: none; + } + } + + ul { + padding: 0; + position: absolute; + left: 5px; + display: none; + color: #fff; + background: #231f20; + margin: 0 !important; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4); + + li { + a.active { + border: none; + border-left: 5px solid $highlight_blue; + } + } + + li { + display: block; + + a, button { + padding: 8px 20px 8px 8px !important; + font-size: 0.8em; + line-height: 18px; + display: block; + border-left: 4px solid $highlight_blue; + white-space: nowrap; + } + } + } + + button { + background: none; + text-align: left; + border: none; + width: 100%; + border-radius: 0; + height: auto; + } + + &:hover > ul, &:active > ul, &:focus > ul { + display: block !important; + } + + &.home-nav-element a:hover { + border-bottom: 0; + padding-top: 0; + padding-bottom: 0; + background: transparent; + } + } + } + + .nav-divider { + width: 1px; + vertical-align: middle; + padding-left: 3px; + display: inline-block; + height: 32px; + margin-right: 1px; + border-right: 3px solid rgba(255, 255, 255, 0.15); + } +} + +#navicon { + display: none; +} + +@media (max-width: 760px) { + #navigation { + height: 36px; + } + + #navicon { + transition-duration: 0.25s; + display: block; + line-height: 26px; + font-size: 2em; + color: #FFF; + padding: 0 0.25em; + margin: 4px 0.25em; + white-space: nowrap; + float: left; + + &.hover { + color: #4db7fe; + text-shadow: 0 0 5px $highlight_blue; + transition-duration: 0.25s; + } + } + + #nav-list { + display: none; + padding: 0; + margin: 0; + position: fixed; + top: 36px; + background: #231f20; + bottom: 0; + width: 8em; + left: 0; + + &.show-list { + display: block; + } + + li { + display: block; + + a { + display: block; + + .nav-expand { + float: right; + display: block; + height: inherit; + margin: (-13px) -7px; + padding: inherit; + } + } + + ul { + left: 8em; + top: 0px; + + &.show-list { + display: block; + } + } + + &.home-nav-element { + display: none; + } + } + } + + #user-links > ul > li > a > span { + padding: 6px 8px; + } +} + +@media not all and (max-width: 760px) { + #nav-list { + li { + &.home-menu-item { + display: none; + } + + &:not(:hover) > ul { + display: none !important; + } + + ul { + left: 0 !important; + } + } + } +} diff --git a/resources/pagedown_widget.css b/resources/pagedown-widget.scss similarity index 81% rename from resources/pagedown_widget.css rename to resources/pagedown-widget.scss index 7ce1b7158..8e585c3fe 100644 --- a/resources/pagedown_widget.css +++ b/resources/pagedown-widget.scss @@ -1,3 +1,5 @@ +@import "vars"; + .wmd-panel { margin: 0; width: 100%; @@ -13,8 +15,8 @@ height: 300px; width: 100%; max-width: 100%; - background: #fff; - border: 1px solid DarkGray; + background: $color_primary0; + border: 1px solid $color_primary50; font-family: Consolas, "Liberation Mono", Monaco, "Courier New", monospace !important; } @@ -47,7 +49,8 @@ } .wmd-button > span { - background: url(pagedown/wmd-buttons.png) no-repeat 0 0; + @include vars-img; + background: url($path_to_root + '/pagedown/wmd-buttons.png') no-repeat 0 0; width: 20px; height: 20px; display: inline-block; @@ -58,8 +61,8 @@ } .wmd-prompt-dialog { - border: 1px solid #999999; - background-color: #F5F5F5; + border: 1px solid $color_primary25; + background-color: $color_primary5; } .wmd-prompt-dialog > div { @@ -68,12 +71,12 @@ } .wmd-prompt-dialog > form > input[type="text"] { - border: 1px solid #999999; - color: black; + border: 1px solid $color_primary25; + color: $color_primary100; } .wmd-prompt-dialog > form > input[type="button"] { - border: 1px solid #888888; + border: 1px solid $color_primary50; font-family: trebuchet MS, helvetica, sans-serif; font-size: 0.8em; font-weight: bold; @@ -86,10 +89,10 @@ .wmd-preview { margin-top: 15px; padding: 7px; - background: white; + background: $color_primary0; line-height: 1.5em; font-size: 1em; - border: 1px solid #a9a9a9; + border: 1px solid $color_primary50; border-radius: 5px; box-sizing: border-box; } diff --git a/resources/problem.scss b/resources/problem.scss index a9f43de53..d5c7a2c35 100644 --- a/resources/problem.scss +++ b/resources/problem.scss @@ -1,59 +1,77 @@ -#problem-table { - td { - &.solved { - width: 2.4em; - text-align: center; +@import "vars"; + +#tagproblem-table, #tag-table { + td, th { + &.problem-name { + text-align: left; + } + + &.problem-code, &.judge { + white-space: nowrap; } &.problem-code { - width: 5em; - text-align: left; - padding-left: 0.5em; - word-break: break-word; + width: 20%; } &.problem-name { - text-align: left; - padding-left: 0.5em; + width: 65%; } - &.category { - width: 5em; - text-align: left; - padding: 0 1em; + &.judge { + width: 15%; } + } - &.types { - width: 5em; - text-align: left; - padding: 0 1em; - word-break: break-word; + tr { + transition: background-color linear 0.2s; + + &:hover { + background: rgba($color_primary100, 0.05); + } + } +} + +#tagproblem-table { + .row-tag-list { + font-size: 0.75em; + padding-top: 1px; + text-align: right; + + &, a { + color: grey !important; } + } +} - &.p { - width: 3em; - text-align: center; - padding: 0 1em; +#problem-table { + td, th { + // Text columns are left-aligned + &.problem-code, &.problem-name, &.category, &.types { + text-align: left; + padding: 0 1rem; } - &.ac-rate { - width: 3em; + // Numeric columns are center-aligned + &.solved, &.points, &.ac-rate, &.editorial, &.users { + padding: 0 10px; white-space: nowrap; - padding: 0 1em; } - &.users { - width: 3em; - text-align: center; - padding: 0 1em; + &.problem-code { + word-break: break-all; } } + th a { + color: inherit; + } + tr { transition: background-color linear 0.2s; &:hover { - background: #eaeaea; + background: rgba($color_primary100, 0.05); } } } @@ -172,7 +190,7 @@ ul.problem-list { } .submissions-left { - color: black; + color: $color_primary100; font-weight: 600; text-align: center; margin-top: 0.5em; @@ -188,19 +206,19 @@ ul.problem-list { } .organization-tag { - box-shadow: inset 0 -0.1em 0 rgba(0, 0, 0, 0.12); + box-shadow: inset 0 -0.1em 0 rgba($color_primary100, 0.12); padding: 0.15em 0.3em; border-radius: 0.15em; font-weight: 600; margin-right: 0.45em; position: relative; - background-color: #ccc; + background-color: $color_primary25; transform: translateY(+35%); display: inline-block; } .organization-tag a { - color: #000; + color: $color_primary100; } .pdf-icon { @@ -228,7 +246,7 @@ ul.problem-list { font-size: 0.85em; a { - color: gray; + color: $color_primary50; text-decoration: none; } } @@ -241,6 +259,10 @@ ul.problem-list { width: 100%; box-sizing: border-box; + .hidden { + display: none; + } + .button { display: inline-block !important; padding: 6px 12px; @@ -249,6 +271,128 @@ ul.problem-list { .submit-bar { float: right; } + + #submit-wrapper { + margin-top: 0.7em; + + #editor, #language { + margin-top: 4px; + } + + #id_language { + width: 100%; + } + } + + #file_drag { + border: 2px dashed #999; + border-radius : 7px; + color: #999; + cursor: pointer; + display: block; + padding: 1.5em; + text-align: center; + transition: background 0.3s, color 0.3s; + + &:hover, &.hover { + background: #ddd; + border-style: solid; + box-shadow: inset 0 3px 4px #999; + } + } + + .helptext { + font-size: 13px; + color: #999; + border-width: 0 0 1px 0; + + &::before { + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + line-height: 1; + content: "\f05a"; + padding-right: 5px; + font-size: 14px; + } + } + + .errorlist { + background: rgba(255, 0, 0, 0.3); + border: 3px dashed red; + border-right: none; + border-left: solid red; + padding: 0 1em 0.1em 2em; + margin: 0.3em 0 0.5em 0; + } + + .errornote { + border: 3px dashed #ffc000; + border-left: solid #ffc000; + padding: 0.7em 2em; + margin: 0 0 8px; + font-size: 13px !important; + font-weight: 400; + } + + .required th::after { + content: "*"; + color: red; + } +} + +#language-select2 { + &.select2-dropdown--above { + display: flex; + flex-direction: column-reverse; + } + + .select2-results__message { + white-space: nowrap + } + + .select2-results__option { + color: $color_problem_submit_select2 !important; + background: $color_primary0 !important; + } + + .select2-results__option--highlighted { + text-decoration: underline; + } + + .select2-results__option[aria-selected=true] { + font-weight: bold; + color: $color_primary100 !important; + } + + .select2-results__option { + padding: 4px 0px; + } + + .select2-results__options { + overflow-y: visible !important; + } + + .select2-results__option { + break-inside: avoid-column; + } + + .select2-results { + -webkit-columns: 10 7em; + -moz-columns: 10 7em; + columns: 10 7em; + padding-left: 1.5em; + padding-top: 0.5em; + } + + #result-version-info { + border-bottom: 1px solid $color_primary50; + margin: 0px 1em; + color: $color_problem_submit_select2; + font-weight: 600; + padding: 0.2em 0; + text-align: right; + } } @media (max-width: 550px) { @@ -269,12 +413,6 @@ ul.problem-list { } } -#problem-table th a { - color: inherit; - display: block; - padding: 4px 10px; -} - #category, #types { visibility: hidden; } diff --git a/resources/problem_edit.css b/resources/problem_edit.css deleted file mode 100644 index 5fe128b5c..000000000 --- a/resources/problem_edit.css +++ /dev/null @@ -1,102 +0,0 @@ -.selector { - width: 840px; - float: left; -} - -.selector select { - width: 400px; - height: 17.2em; -} - -.selector-available, .selector-chosen { - float: left; - width: 400px; - text-align: center; - margin-bottom: 5px; -} - -.selector-chosen select { - border-top: none; -} - -.selector-available h2, .selector-chosen h2 { - border: 1px solid #ccc; - background: white bottom left repeat-x; - color: #666; -} - -.selector .selector-filter { - background: white; - border: 1px solid #ccc; - border-width: 0 1px; - padding: 3px; - color: #999; - font-size: 10px; - margin: 0; - text-align: left; -} - -.selector .selector-filter label, -.inline-group .aligned .selector .selector-filter label { - width: 16px; - padding: 2px; -} - -.selector .selector-available input { - width: 360px; -} - -.selector ul.selector-chooser { - float: left; - width: 22px; - height: 50px; - background-color: #eee; - margin: 10em 5px 0 5px; - padding: 0; -} - -.selector-chooser li { - margin: 0; - padding: 3px; - list-style-type: none; -} - -.selector select { - margin-bottom: 10px; - margin-top: 0; -} - -.selector-add, .selector-remove { - width: 16px; - height: 16px; - display: block; - text-indent: -3000px; - overflow: hidden; -} - -a.selector-chooseall, a.selector-clearall { - display: inline-block; - text-align: left; - margin-left: auto; - margin-right: auto; - font-weight: bold; - color: #666; -} - -a.selector-chooseall { - padding: 3px 18px 3px 0; -} - -a.selector-clearall { - padding: 3px 0 3px 18px; -} - -a.active.selector-chooseall:hover, a.active.selector-clearall:hover { - color: #036; -} - -.selector h2 { - font-size: 16px; - font-weight: bold; - margin: 1em 0 0 0; -} diff --git a/resources/pygment-github.css b/resources/pygment-github.css deleted file mode 100644 index 567fe1dea..000000000 --- a/resources/pygment-github.css +++ /dev/null @@ -1,61 +0,0 @@ -code .hll { background-color: #ffffcc } -code .c { color: #999988; font-style: italic } /* Comment */ -code .err { color: #a61717; background-color: #e3d2d2 } /* Error */ -code .k { color: #000000; font-weight: bold } /* Keyword */ -code .o { color: #000000; font-weight: bold } /* Operator */ -code .cm { color: #999988; font-style: italic } /* Comment.Multiline */ -code .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */ -code .c1 { color: #999988; font-style: italic } /* Comment.Single */ -code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ -code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ -code .ge { color: #000000; font-style: italic } /* Generic.Emph */ -code .gr { color: #aa0000 } /* Generic.Error */ -code .gh { color: #999999 } /* Generic.Heading */ -code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ -code .go { color: #888888 } /* Generic.Output */ -code .gp { color: #555555 } /* Generic.Prompt */ -code .gs { font-weight: bold } /* Generic.Strong */ -code .gu { color: #aaaaaa } /* Generic.Subheading */ -code .gt { color: #aa0000 } /* Generic.Traceback */ -code .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ -code .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ -code .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ -code .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ -code .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ -code .kt { color: #445588; font-weight: bold } /* Keyword.Type */ -code .m { color: #009999 } /* Literal.Number */ -code .s { color: #d01040 } /* Literal.String */ -code .na { color: #008080 } /* Name.Attribute */ -code .nb { color: #0086B3 } /* Name.Builtin */ -code .nc { color: #445588; font-weight: bold } /* Name.Class */ -code .no { color: #008080 } /* Name.Constant */ -code .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ -code .ni { color: #800080 } /* Name.Entity */ -code .ne { color: #990000; font-weight: bold } /* Name.Exception */ -code .nf { color: #990000; font-weight: bold } /* Name.Function */ -code .nl { color: #990000; font-weight: bold } /* Name.Label */ -code .nn { color: #555555 } /* Name.Namespace */ -code .nt { color: #000080 } /* Name.Tag */ -code .nv { color: #008080 } /* Name.Variable */ -code .ow { color: #000000; font-weight: bold } /* Operator.Word */ -code .w { color: #bbbbbb } /* Text.Whitespace */ -code .mf { color: #009999 } /* Literal.Number.Float */ -code .mh { color: #009999 } /* Literal.Number.Hex */ -code .mi { color: #009999 } /* Literal.Number.Integer */ -code .mo { color: #009999 } /* Literal.Number.Oct */ -code .sb { color: #d01040 } /* Literal.String.Backtick */ -code .sc { color: #d01040 } /* Literal.String.Char */ -code .sd { color: #d01040 } /* Literal.String.Doc */ -code .s2 { color: #d01040 } /* Literal.String.Double */ -code .se { color: #d01040 } /* Literal.String.Escape */ -code .sh { color: #d01040 } /* Literal.String.Heredoc */ -code .si { color: #d01040 } /* Literal.String.Interpol */ -code .sx { color: #d01040 } /* Literal.String.Other */ -code .sr { color: #009926 } /* Literal.String.Regex */ -code .s1 { color: #d01040 } /* Literal.String.Single */ -code .ss { color: #990073 } /* Literal.String.Symbol */ -code .bp { color: #999999 } /* Name.Builtin.Pseudo */ -code .vc { color: #008080 } /* Name.Variable.Class */ -code .vg { color: #008080 } /* Name.Variable.Global */ -code .vi { color: #008080 } /* Name.Variable.Instance */ -code .il { color: #009999 } /* Literal.Number.Integer.Long */ diff --git a/resources/pygment-github.scss b/resources/pygment-github.scss new file mode 100644 index 000000000..64f5d2b0c --- /dev/null +++ b/resources/pygment-github.scss @@ -0,0 +1,147 @@ +@import "vars"; + +code { + @if $is_light_theme { + .hll { background-color: #ffffcc } + .c { color: #999988; font-style: italic } /* Comment */ + .err { color: #a61717; background-color: #e3d2d2 } /* Error */ + .k { color: #000000; font-weight: bold } /* Keyword */ + .o { color: #000000; font-weight: bold } /* Operator */ + .cm { color: #999988; font-style: italic } /* Comment.Multiline */ + .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */ + .c1 { color: #999988; font-style: italic } /* Comment.Single */ + .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ + .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ + .ge { color: #000000; font-style: italic } /* Generic.Emph */ + .gr { color: #aa0000 } /* Generic.Error */ + .gh { color: #999999 } /* Generic.Heading */ + .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ + .go { color: #888888 } /* Generic.Output */ + .gp { color: #555555 } /* Generic.Prompt */ + .gs { font-weight: bold } /* Generic.Strong */ + .gu { color: #aaaaaa } /* Generic.Subheading */ + .gt { color: #aa0000 } /* Generic.Traceback */ + .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ + .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ + .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ + .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ + .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ + .kt { color: #445588; font-weight: bold } /* Keyword.Type */ + .m { color: #009999 } /* Literal.Number */ + .s { color: #d01040 } /* Literal.String */ + .na { color: #008080 } /* Name.Attribute */ + .nb { color: #0086B3 } /* Name.Builtin */ + .nc { color: #445588; font-weight: bold } /* Name.Class */ + .no { color: #008080 } /* Name.Constant */ + .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ + .ni { color: #800080 } /* Name.Entity */ + .ne { color: #990000; font-weight: bold } /* Name.Exception */ + .nf { color: #990000; font-weight: bold } /* Name.Function */ + .nl { color: #990000; font-weight: bold } /* Name.Label */ + .nn { color: #555555 } /* Name.Namespace */ + .nt { color: #000080 } /* Name.Tag */ + .nv { color: #008080 } /* Name.Variable */ + .ow { color: #000000; font-weight: bold } /* Operator.Word */ + .w { color: #bbbbbb } /* Text.Whitespace */ + .mf { color: #009999 } /* Literal.Number.Float */ + .mh { color: #009999 } /* Literal.Number.Hex */ + .mi { color: #009999 } /* Literal.Number.Integer */ + .mo { color: #009999 } /* Literal.Number.Oct */ + .sb { color: #d01040 } /* Literal.String.Backtick */ + .sc { color: #d01040 } /* Literal.String.Char */ + .sd { color: #d01040 } /* Literal.String.Doc */ + .s2 { color: #d01040 } /* Literal.String.Double */ + .se { color: #d01040 } /* Literal.String.Escape */ + .sh { color: #d01040 } /* Literal.String.Heredoc */ + .si { color: #d01040 } /* Literal.String.Interpol */ + .sx { color: #d01040 } /* Literal.String.Other */ + .sr { color: #009926 } /* Literal.String.Regex */ + .s1 { color: #d01040 } /* Literal.String.Single */ + .ss { color: #990073 } /* Literal.String.Symbol */ + .bp { color: #999999 } /* Name.Builtin.Pseudo */ + .vc { color: #008080 } /* Name.Variable.Class */ + .vg { color: #008080 } /* Name.Variable.Global */ + .vi { color: #008080 } /* Name.Variable.Instance */ + .il { color: #009999 } /* Literal.Number.Integer.Long */ + } @else { + .hll { background-color: #6e7681 } + .c { color: #8b949e; font-style: italic } /* Comment */ + .err { color: #f85149 } /* Error */ + .esc { color: #c9d1d9 } /* Escape */ + .g { color: #c9d1d9 } /* Generic */ + .k { color: #ff7b72 } /* Keyword */ + .l { color: #a5d6ff } /* Literal */ + .n { color: #c9d1d9 } /* Name */ + .o { color: #ff7b72; font-weight: bold } /* Operator */ + .x { color: #c9d1d9 } /* Other */ + .p { color: #c9d1d9 } /* Punctuation */ + .ch { color: #8b949e; font-style: italic } /* Comment.Hashbang */ + .cm { color: #8b949e; font-style: italic } /* Comment.Multiline */ + .cp { color: #8b949e; font-weight: bold; font-style: italic } /* Comment.Preproc */ + .cpf { color: #8b949e; font-style: italic } /* Comment.PreprocFile */ + .c1 { color: #8b949e; font-style: italic } /* Comment.Single */ + .cs { color: #8b949e; font-weight: bold; font-style: italic } /* Comment.Special */ + .gd { color: #ffa198; background-color: #490202 } /* Generic.Deleted */ + .ge { color: #c9d1d9; font-style: italic } /* Generic.Emph */ + .gr { color: #ffa198 } /* Generic.Error */ + .gh { color: #79c0ff; font-weight: bold } /* Generic.Heading */ + .gi { color: #56d364; background-color: #0f5323 } /* Generic.Inserted */ + .go { color: #8b949e } /* Generic.Output */ + .gp { color: #8b949e } /* Generic.Prompt */ + .gs { color: #c9d1d9; font-weight: bold } /* Generic.Strong */ + .gu { color: #79c0ff } /* Generic.Subheading */ + .gt { color: #ff7b72 } /* Generic.Traceback */ + .g-Underline { color: #c9d1d9; text-decoration: underline } /* Generic.Underline */ + .kc { color: #79c0ff } /* Keyword.Constant */ + .kd { color: #ff7b72 } /* Keyword.Declaration */ + .kn { color: #ff7b72 } /* Keyword.Namespace */ + .kp { color: #79c0ff } /* Keyword.Pseudo */ + .kr { color: #ff7b72 } /* Keyword.Reserved */ + .kt { color: #ff7b72 } /* Keyword.Type */ + .ld { color: #79c0ff } /* Literal.Date */ + .m { color: #a5d6ff } /* Literal.Number */ + .s { color: #a5d6ff } /* Literal.String */ + .na { color: #c9d1d9 } /* Name.Attribute */ + .nb { color: #c9d1d9 } /* Name.Builtin */ + .nc { color: #f0883e; font-weight: bold } /* Name.Class */ + .no { color: #79c0ff; font-weight: bold } /* Name.Constant */ + .nd { color: #d2a8ff; font-weight: bold } /* Name.Decorator */ + .ni { color: #ffa657 } /* Name.Entity */ + .ne { color: #f0883e; font-weight: bold } /* Name.Exception */ + .nf { color: #d2a8ff; font-weight: bold } /* Name.Function */ + .nl { color: #79c0ff; font-weight: bold } /* Name.Label */ + .nn { color: #ff7b72 } /* Name.Namespace */ + .nx { color: #c9d1d9 } /* Name.Other */ + .py { color: #79c0ff } /* Name.Property */ + .nt { color: #7ee787 } /* Name.Tag */ + .nv { color: #79c0ff } /* Name.Variable */ + .ow { color: #ff7b72; font-weight: bold } /* Operator.Word */ + .pm { color: #c9d1d9 } /* Punctuation.Marker */ + .w { color: #6e7681 } /* Text.Whitespace */ + .mb { color: #a5d6ff } /* Literal.Number.Bin */ + .mf { color: #a5d6ff } /* Literal.Number.Float */ + .mh { color: #a5d6ff } /* Literal.Number.Hex */ + .mi { color: #a5d6ff } /* Literal.Number.Integer */ + .mo { color: #a5d6ff } /* Literal.Number.Oct */ + .sa { color: #79c0ff } /* Literal.String.Affix */ + .sb { color: #a5d6ff } /* Literal.String.Backtick */ + .sc { color: #a5d6ff } /* Literal.String.Char */ + .dl { color: #79c0ff } /* Literal.String.Delimiter */ + .sd { color: #a5d6ff } /* Literal.String.Doc */ + .s2 { color: #a5d6ff } /* Literal.String.Double */ + .se { color: #79c0ff } /* Literal.String.Escape */ + .sh { color: #79c0ff } /* Literal.String.Heredoc */ + .si { color: #a5d6ff } /* Literal.String.Interpol */ + .sx { color: #a5d6ff } /* Literal.String.Other */ + .sr { color: #79c0ff } /* Literal.String.Regex */ + .s1 { color: #a5d6ff } /* Literal.String.Single */ + .ss { color: #a5d6ff } /* Literal.String.Symbol */ + .bp { color: #c9d1d9 } /* Name.Builtin.Pseudo */ + .fm { color: #d2a8ff; font-weight: bold } /* Name.Function.Magic */ + .vc { color: #79c0ff } /* Name.Variable.Class */ + .vg { color: #79c0ff } /* Name.Variable.Global */ + .vi { color: #79c0ff } /* Name.Variable.Instance */ + .vm { color: #79c0ff } /* Name.Variable.Magic */ + .il { color: #a5d6ff } /* Literal.Number.Integer.Long */ + } +} diff --git a/resources/ranks.scss b/resources/ranks.scss index aeefbb8cf..380fb2d10 100644 --- a/resources/ranks.scss +++ b/resources/ranks.scss @@ -1,5 +1,7 @@ +@import "vars"; + .admin a, .admin { - color: black !important; + color: $color_primary100 !important; font-weight: bold !important; } @@ -35,6 +37,12 @@ content: "🧑‍🏫"; } +.deleted-user, .deleted-user a { + color: #999; + font-weight: normal; + text-decoration: line-through $color_primary100; +} + @mixin rate-svg-color($color) { circle { stroke: $color; @@ -56,88 +64,81 @@ svg.rate-box { visibility: hidden; } + &.rate-primary0 { + @include rate-svg-color($color_primary0); + } + &.rate-newbie { - @include rate-svg-color(#808080); + @include rate-svg-color($color_rating_newbie); } &.rate-pupil { - @include rate-svg-color(#008000); + @include rate-svg-color($color_rating_pupil); } &.rate-specialist { - @include rate-svg-color(#03A89E); + @include rate-svg-color($color_rating_specialist); } &.rate-expert { - @include rate-svg-color(#0000FF); + @include rate-svg-color($color_rating_expert); } &.rate-candidate-master { - @include rate-svg-color(#AA00AA); + @include rate-svg-color($color_rating_candidate_master); } &.rate-master, &.rate-international-master { - @include rate-svg-color(#FF8C00); + @include rate-svg-color($color_rating_master); } &.rate-grandmaster, &.rate-international-grandmaster, &.rate-legendary-grandmaster { - @include rate-svg-color(#FF0000); - } - - &.rate-target { - circle:last-child { - stroke: none; - fill: #FF0000; - } + @include rate-svg-color($color_rating_grandmaster); } } .rating { font-weight: bold; - - a { - display: inline-block; - } } .rate-none, .rate-none a { - color: #999; + color: $color_rating_none; font-weight: normal; } .rate-newbie, .rate-newbie a { - color: #808080; + color: $color_rating_newbie; } .rate-pupil, .rate-pupil a { - color: #008000; + color: $color_rating_pupil; } .rate-specialist, .rate-specialist a { - color: #03A89E; + color: $color_rating_specialist; } .rate-expert, .rate-expert a { - color: #0000FF; + color: $color_rating_expert; } .rate-candidate-master, .rate-candidate-master a { - color: #AA00AA; + color: $color_rating_candidate_master; } .rate-master, .rate-master a, .rate-international-master, .rate-international-master a { - color: #FF8C00; + color: $color_rating_master; } .rate-grandmaster, .rate-grandmaster a, .rate-international-grandmaster, .rate-international-grandmaster a, .rate-legendary-grandmaster, .rate-legendary-grandmaster a { - color: #FF0000; + color: $color_rating_grandmaster; } .rate-legendary-grandmaster a::first-letter { - color: black; + color: $color_primary100; } .rate-group { @@ -147,10 +148,11 @@ svg.rate-box { .rating { display: inline-block; + vertical-align: middle; } .rate-box { margin-right: 0.2em; - vertical-align: bottom; + vertical-align: middle; } } diff --git a/resources/select2-dmoj.scss b/resources/select2-dmoj.scss new file mode 100644 index 000000000..79348cf0a --- /dev/null +++ b/resources/select2-dmoj.scss @@ -0,0 +1,110 @@ +@import "vars"; + +/* Hack to make dropdown background follow theming */ +.select2-dropdown { + background-color: $color_primary0 !important; + border: 1px solid $color_primary33 !important; + color: $color_primary100 !important; +} + +.select2-container--dmoj { + @import "select2/single"; + @import "select2/multiple"; + + &.select2-container--open.select2-container--above { + .select2-selection--single, .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + + &.select2-container--open.select2-container--below { + .select2-selection--single, .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + .select2-search--dropdown { + .select2-search__field { + border: 1px solid $color_primary33; + background-color: $color_select2_search_dropdown; + color: $color_primary100; + } + } + + .select2-search--inline { + .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + color: $color_primary100; + -webkit-appearance: textfield; + } + } + + .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: $color_primary100; + } + + .select2-results__option { + &[role=group] { + padding: 0; + } + + &[aria-disabled=true] { + color: $color_primary50; + } + + &[aria-selected=true] { + background-color: $color_primary25; + } + + .select2-results__option { + padding-left: 1em; + + .select2-results__group { + padding-left: 0; + } + + .select2-results__option { + margin-left: -1em; + padding-left: 2em; + + .select2-results__option { + margin-left: -2em; + padding-left: 3em; + + .select2-results__option { + margin-left: -3em; + padding-left: 4em; + + .select2-results__option { + margin-left: -4em; + padding-left: 5em; + + .select2-results__option { + margin-left: -5em; + padding-left: 6em; + } + } + } + } + } + } + } + + .select2-results__option--highlighted[aria-selected] { + background-color: $highlight_blue; + color: white; + } + + .select2-results__group { + cursor: default; + display: block; + padding: 6px; + } +} diff --git a/resources/select2/_multiple.scss b/resources/select2/_multiple.scss new file mode 100644 index 000000000..c3f98cf41 --- /dev/null +++ b/resources/select2/_multiple.scss @@ -0,0 +1,99 @@ +.select2-selection--multiple { + background-color: $color_primary0; + border: 1px solid $color_primary33; + border-radius: 4px; + cursor: text; + + .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; + + li { + list-style: none; + } + } + + .select2-selection__placeholder { + color: $color_primary50; + + margin-top: 5px; + + float: left; + } + + .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + } + + .select2-selection__choice { + background-color: $color_primary10; + color: $color_primary100; + + border: 1px solid $color_primary33; + border-radius: 4px; + cursor: default; + + float: left; + + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; + } + + .select2-selection__choice__remove { + color: $color_primary50; + cursor: pointer; + + display: inline-block; + font-weight: bold; + + margin-right: 2px; + + &:hover { + color: $color_primary75; + } + } +} + +&[dir="rtl"] { + .select2-selection--multiple { + .select2-selection__choice, .select2-selection__placeholder, .select2-search--inline { + float: right; + } + + .select2-selection__choice { + margin-left: 5px; + margin-right: auto; + } + + .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; + } + } +} + +&.select2-container--focus { + .select2-selection--multiple { + border: solid $color_primary100 1px; + outline: 0; + } +} + +&.select2-container--disabled { + .select2-selection--multiple { + background-color: $color_primary10; + cursor: default; + } + + .select2-selection__choice__remove { + display: none; + } +} diff --git a/resources/select2/_single.scss b/resources/select2/_single.scss new file mode 100644 index 000000000..d2359d7f7 --- /dev/null +++ b/resources/select2/_single.scss @@ -0,0 +1,83 @@ +.select2-selection--single { + background-color: $color_primary0; + border: 1px solid $color_primary33; + border-radius: 4px; + + .select2-selection__rendered { + color: $color_primary75; + line-height: 28px; + } + + .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + } + + .select2-selection__placeholder { + color: $color_primary50; + } + + .select2-selection__arrow { + height: 26px; + + position: absolute; + + top: 1px; + right: 1px; + + width: 20px; + + b { + border-color: $color_primary50 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + + height: 0; + left: 50%; + + margin-left: -4px; + margin-top: -2px; + + position: absolute; + + top: 50%; + width: 0; + } + } +} + +&[dir="rtl"] { + .select2-selection--single { + .select2-selection__clear { + float: left; + } + + .select2-selection__arrow { + left: 1px; + right: auto; + } + } +} + +&.select2-container--disabled { + .select2-selection--single { + background-color: $color_primary10; + cursor: default; + + .select2-selection__clear { + display: none; + } + } +} + +&.select2-container--open { + .select2-selection--single { + .select2-selection__arrow { + b { + border-color: transparent transparent $color_primary50 transparent; + border-width: 0 4px 5px 4px; + } + } + } +} diff --git a/resources/style.scss b/resources/style.scss index 9d52085d9..a634523be 100644 --- a/resources/style.scss +++ b/resources/style.scss @@ -1,14 +1,22 @@ @import "base"; +@import "navbar"; +@import "pygment-github"; @import "table"; @import "math"; @import "status"; @import "blog"; @import "problem"; +@import "ticket"; @import "ranks"; @import "users"; @import "content-description"; @import "widgets"; +@import "featherlight"; @import "comments"; +@import "pagedown-widget"; +@import "dmmd-preview"; @import "submission"; @import "contest"; @import "misc"; +@import "accordion"; +@import "select2-dmoj"; diff --git a/resources/submission.scss b/resources/submission.scss index 05b4a33d4..18a59abe4 100644 --- a/resources/submission.scss +++ b/resources/submission.scss @@ -1,3 +1,5 @@ +@import "vars"; + .info-float { position: sticky; top: 60px; @@ -5,7 +7,7 @@ } #submissions-table { - background: rgba(0, 0, 0, .01); + background: rgba($color_primary100, .01); } .submissions-status-table { @@ -14,13 +16,13 @@ .submission-row { display: flex; - border-top: #ccc 1px solid; - border-left: #ccc 1px solid; - border-right: #ccc 1px solid; + border-top: $color_primary25 1px solid; + border-left: $color_primary25 1px solid; + border-right: $color_primary25 1px solid; transition: background-color linear 0.2s; &:hover { - background: #F2F2F2; + background: $color_primary10; } &:not(:empty) ~ & { @@ -30,7 +32,7 @@ > div { padding: 7px 5px; vertical-align: middle; - border-bottom: #ccc 1px solid; + border-bottom: $color_primary25 1px solid; display: flex; flex-direction: column; justify-content: center; @@ -40,8 +42,8 @@ min-width: 80px; width: 80px; text-align: center; - border-bottom-color: white; - border-right: #ccc 1px solid; + border-bottom-color: $color_primary0; + border-right: $color_primary25 1px solid; .state { font-size: 0.7em; @@ -52,6 +54,10 @@ .score { // font-size: 1.3em; color: #000; + + .grading-spinner { + color: $color_primary100; + } } } @@ -74,7 +80,7 @@ } .sub-testcase { - color: #555; + color: $color_primary50; white-space: nowrap; padding-right: 5px; } @@ -91,7 +97,7 @@ width: 70px; white-space: nowrap; text-align: center; - border-left: #ccc 1px solid; + border-left: $color_primary25 1px solid; .time { font-weight: bold; @@ -104,12 +110,12 @@ } .sub-prop .grey-label { - color: grey; + color: $color_primary50; font-style: italic; } .sub-prop .grey-icon { - color: grey; + color: $color_primary50; } label[for="language"], label[for="status"], label[for="organization"] { @@ -166,17 +172,17 @@ label[for="language"], label[for="status"], label[for="organization"] { } .submission-contest { - color: #555; + color: $color_primary75; } .source-ln { - color: gray; - border-right: 1px solid gray; + color: $color_primary50; + border-right: 1px solid $color_primary50; padding-right: 5px; text-align: right; a { - color: gray; + color: $color_primary50; display: block; &:hover { @@ -256,7 +262,7 @@ label[for="language"], label[for="status"], label[for="organization"] { border: 1px solid #2980b9; border-left-width: .5em; border-radius: 4px; - color: #222; + color: $color_primary75; } .case-output { @@ -293,7 +299,7 @@ label[for="language"], label[for="status"], label[for="organization"] { } .case-_AC { - color: #b8a204; + color: #BBCC00; font-weight: bold; } @@ -345,6 +351,6 @@ label[for="language"], label[for="status"], label[for="organization"] { float: right; .submission-date { - color: gray; + color: $color_primary50; } } diff --git a/resources/table.scss b/resources/table.scss index 802fde87f..4d69aa825 100644 --- a/resources/table.scss +++ b/resources/table.scss @@ -10,44 +10,19 @@ margin-left: auto; margin-right: auto; margin-bottom: 0.5em; - background: rgba(0, 0, 0, 0.01); + background: rgba($color_primary100, 0.01); + backdrop-filter: blur(3px); - &.striped tr:nth-child(even) { - background: #f7f7f7; - } - - td:first-child { - border-color: $border_gray; - border-width: 1px 1px 0 1px; - } - - tr:last-child td { - &:first-child { - border: 1px solid $border_gray; - } - - border-color: $border_gray; - border-width: 1px 1px 1px 0; - } - - thead th { - vertical-align: middle; - - &:first-child { - border-top-left-radius: $table_header_rounding; - } - - &:last-child { - border-top-right-radius: $table_header_rounding; - } + &.striped tr:nth-child(2n) { + background: rgba($color_primary100, 0.03); } th { height: 2em; - color: #FFF; - background-color: $widget_black; - border-color: #555; - border-width: 1px 1px 0 0; + color: $color_primary0; + background-color: $color_primary75; + border-color: $color_primary50; + border-width: 0 1px 1px 0; border-style: solid; padding: 4px 10px; vertical-align: middle; @@ -55,35 +30,36 @@ white-space: nowrap; font-weight: 600; font-size: 1.1em; - - &:first-child { - border-top-left-radius: $table_header_rounding; - } - - &:last-child { - border-top-right-radius: $table_header_rounding; - } } td { - border-color: $border_gray; - border-width: 1px 1px 0 0; + border-color: $color_primary25; + border-width: 0 1px 1px 0; border-style: solid; padding: 7px 5px; vertical-align: middle; text-align: center; } - // Monkey-patches for awkward table rounding - tr:not(:first-child) th { - border-radius: 0; + // Border fixes + th:first-child, td:first-child { + border-left-width: 1px; } - tr:last-child th { - border-bottom-left-radius: $table_header_rounding; + tr:first-child th, tr:first-child td { + border-top-width: 1px; } - thead tr th { - border-bottom-left-radius: 0 !important; + // Rounded corners for + tr:first-child th:first-child { + border-top-left-radius: $table_header_rounding; + } + + tr:first-child th:last-child { + border-top-right-radius: $table_header_rounding; + } + + tbody tr:last-child th:first-child { + border-bottom-left-radius: $table_header_rounding; } } diff --git a/resources/ticket.scss b/resources/ticket.scss new file mode 100644 index 000000000..31c89f1c6 --- /dev/null +++ b/resources/ticket.scss @@ -0,0 +1,219 @@ +@import "vars"; + +form#ticket-form { + display: block; + margin: 0 auto; + max-width: 750px; + padding-top: 1em; + + #id_title, #id_issue_url { + width: 100%; + } + + .submit { + margin: 10px 0 0 auto; + } +} + +#ticket-list { + .fa-check-circle-o, .fa-arrow-up { + color: #44AD41; + } + + .fa-exclamation-circle { + color: #DE2121; + } +} + +@media (min-width: 600px) { + #ticket-list-container { + display: flex; + flex-direction: row-reverse; + + & > main { + flex: 1; + } + + & > aside { + flex: 1; + max-width: 200px; + margin-left: 1em; + + & > div { + position: sticky; + top: 60px; + } + } + } +} + +div.ticket-title { + display: flex; + align-items: center; + column-gap: 0.3em; + + .fa-check-circle-o, .fa-arrow-up { + color: #00a900; + } + + .fa-exclamation-circle { + color: #ff130f; + } + + small { + color: $color_primary50; + font-size: 0.9em; + } +} + +.ticket-container { + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: row; + flex-wrap: wrap-reverse; + max-width: 1000px; +} + +.ticket-sidebar { + flex: 1; + padding: 10px 0 0 10px; + min-width: 150px; + max-width: 200px; +} + +.ticket-info { + position: sticky; + top: 60px; +} + +.ticket-messages { + flex: 1; +} + +.info-box { + margin: 5px 0 10px; + border: 1px $color_primary50 solid; + border-radius: 5px; + + a.edit-notes { + float: right; + } + + .fa { + color: $color_primary50; + } +} + +.info-title { + padding: 2px 5px; + font-weight: 600; + border-bottom: 1px $color_primary50 solid; + background: $color_primary10; + border-radius: 5px 5px 0 0; +} + +.info-data { + padding: 2px 5px; + word-break: break-all; +} + +.info-empty { + color: $color_primary50; + font-style: italic; +} + +.vote-good, .vote-norm { + margin: 5px 0 10px; +} + +.close-ticket, .vote-good { + display: block; + width: 100%; + background: linear-gradient(to bottom, #4bad00 0%, #278811 100%); + border-color: #24710e; + font-weight: 600; + + &:hover { + background: #24710e; + } +} + +.open-ticket, .vote-norm { + display: block; + width: 100%; + background: linear-gradient(to bottom, #ff130f, #b03d17); + border-color: #853011; + font-weight: 600; + + &:hover { + background: #853011; + } +} + +#ticket-notes .info-real :first-child { + margin-top: 0; +} + +#ticket-notes .info-real :last-child { + margin-bottom: 0; +} + +.ticket-message { + display: flex; + padding-top: 15px; + + .info { + width: 130px; + } + + img.user-gravatar { + margin: 0 auto; + } + + .detail { + border: 1px $color_primary50 solid; + border-radius: 5px; + flex: 1; + min-width: 300px; + } + + .header { + background: $color_primary10; + border-bottom: 1px solid $color_primary50; + border-radius: 5px 5px 0 0; + padding: 2px 5px; + text-align: right; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + } + + .operation { + flex: auto; + + .fa { + color: $color_primary50; + } + } + + .content { + padding: 7px; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } +} + +.new-message .detail { + padding: 8px 10px; +} + +.new-message .button, #edit-notes .submit { + margin: 10px 0 0 auto; +} diff --git a/resources/ui_form.css b/resources/ui_form.css index ccd14fa75..eff44d03a 100644 --- a/resources/ui_form.css +++ b/resources/ui_form.css @@ -23,14 +23,10 @@ width: 10em; } -.django-as-table input { +.django-as-table input:not([type='checkbox']) { width: 30em; } -.django-as-table input[type=checkbox] { - width: fit-content; -} - .errorlist { background: rgba(255, 0, 0, 0.3); border: 3px dashed red; diff --git a/resources/user_profile.js b/resources/user_profile.js new file mode 100644 index 000000000..e5cd035ba --- /dev/null +++ b/resources/user_profile.js @@ -0,0 +1,121 @@ +var active_tooltip = null; + +function display_tooltip(where) { + if (active_tooltip !== null) { + active_tooltip.removeClass(['tooltipped', 'tooltipped-e', 'tooltipped-w']).removeAttr('aria-label'); + } + if (where !== null) { + var day_num = parseInt(where.attr('data-day')); + var tooltip_direction = day_num < 183 ? 'tooltipped-e' : 'tooltipped-w'; + where.addClass(['tooltipped', tooltip_direction]) + .attr('aria-label', where.attr('data-submission-activity')); + } + active_tooltip = where; +} + +function install_tooltips($) { + display_tooltip(null); + $('.activity-label').each(function () { + var link = $(this); + link.hover( + function (e) { + display_tooltip(link); + }, + function (e) { + display_tooltip(null); + } + ); + }); +} + +function init_submission_table($, submission_activity, metadata, language_code) { + var activity_levels = 5; // 5 levels of activity + var current_year = new Date().getFullYear(); + var $div = $('#submission-activity'); + + function draw_contribution(year) { + $div.find('#submission-activity-table td').remove(); + $div.find('#year').attr('data-year', year); + $div.find('#prev-year-action').css('display', year > (metadata.min_year || current_year) ? '' : 'none'); + $div.find('#next-year-action').css('display', year < current_year ? '' : 'none'); + + var start_day = new Date(year, 0, 1); + var end_day = new Date(year + 1, 0, 0); + if (year == current_year) { + end_day = new Date(); + start_day = new Date(end_day.getFullYear() - 1, end_day.getMonth(), end_day.getDate() + 1); + $div.find('#year').text(gettext('past year')); + } else { + $div.find('#year').text(year); + } + var days = []; + for (var day = start_day, day_num = 1; day <= end_day; day.setDate(day.getDate() + 1), day_num++) { + var isodate = day.toISOString().split('T')[0]; + days.push({ + date: new Date(day), + weekday: day.getDay(), + day_num: day_num, + activity: submission_activity[isodate] || 0, + }); + } + + var sum_activity = days.reduce(function (sum, obj) { return sum + obj.activity; }, 0); + $div.find('#submission-total-count').text( + ngettext('%(cnt)d total submission', '%(cnt)d total submissions', sum_activity) + .replace('%(cnt)d', sum_activity) + ); + if (year == current_year) { + $('#submission-activity-header').text( + ngettext('%(cnt)d submission in the last year', '%(cnt)d submissions in the last year', sum_activity) + .replace('%(cnt)d', sum_activity) + ); + } else { + $('#submission-activity-header').text( + ngettext('%(cnt)d submission in %(year)d', '%(cnt)d submissions in %(year)d', sum_activity) + .replace('%(cnt)d', sum_activity) + .replace('%(year)d', year) + ); + } + + for (var current_weekday = 0; current_weekday < days[0].weekday; current_weekday++) { + $div.find('#submission-' + current_weekday) + .append($('').addClass('activity-blank').append('
')); + } + + var max_activity = Math.max(1, Math.max.apply(null, days.map(function (obj) { return obj.activity; }))); + days.forEach(function (obj) { + var level = Math.ceil((obj.activity / max_activity) * (activity_levels - 1)); + var text = ngettext('%(cnt)d submission on %(date)s', '%(cnt)d submissions on %(date)s', obj.activity) + .replace('%(cnt)d', obj.activity) + .replace( + '%(date)s', + obj.date.toLocaleDateString( + language_code, + { month: 'short', year: 'numeric', day: 'numeric' } + ) + ); + + $div.find('#submission-' + obj.weekday) + .append( + $('').addClass(['activity-label', 'activity-' + level]) + .attr('data-submission-activity', text) + .attr('data-day', obj.day_num) + .append('
') + ); + }); + + install_tooltips($); + } + + $('#prev-year-action').click(function () { + draw_contribution(parseInt($div.find('#year').attr('data-year')) - 1); + }); + $('#next-year-action').click(function () { + draw_contribution(parseInt($div.find('#year').attr('data-year')) + 1); + }); + + draw_contribution(current_year); + $('#submission-activity').css('display', ''); +} + +window.init_submission_table = init_submission_table; diff --git a/resources/users.scss b/resources/users.scss index 887cea9e4..813968f52 100644 --- a/resources/users.scss +++ b/resources/users.scss @@ -6,13 +6,21 @@ td.user-name, td.personal-info, td.organization-name { } tr { - padding-bottom: 96px; - &:target { - background: #fff897; + background: rgba($color_link200, 0.2); + } + + td.full-score, td.failed-score, td.partial-score { + a { + font-size: 12px; + } } } +span.organization { + font-size: 12px; +} + #search-handle { width: 100%; height: 2.3em; @@ -26,32 +34,47 @@ tr { padding-left: 0.5em; } -#users-table, #organization-table { - th a { - color: white; +.users-table { + th.rank, th.points, th.problems, th.username { + white-space: nowrap; } - .username { - width: 100%; + th.rank { + padding-left: 5px; + padding-right: 5px; } - .header { - vertical-align: middle; + th.username { + width: 100%; } - .rank, .points, .problems, .username { - white-space: nowrap; + .personal-info { + a, span { + color: $color_primary50; + font-weight: 600; + } } +} +.users-table, .organization-table { tr { transition: background-color linear .2s; &:hover { - background: #EAEAEA; + background: rgba($color_primary100, 0.05); } &.highlight { - background: #fff897; + background: rgba($color_link200, 0.2); + } + } + + th a { + color: $color_primary0; + + &:link, + &:visited { + color: $color_primary0; } } } @@ -68,71 +91,14 @@ tr { .select2-selection__rendered { cursor: text; } - - .select2-results__option { - position: relative; - } - - .select2-results__option--highlighted { - background-color: #DEDEDE !important; - } - - li.select2-results__option--highlighted a.user-redirect { - display: inline-block; - } -} - -a.user-redirect { - color: #2980b9; - vertical-align: middle; - font-size: 1.2em; - position: absolute; - right: 0.8em; - display: none; - - &:hover { - text-shadow: 0 0 2px blue; - } } -a.edit-profile { - float: right; - padding-top: 1em; -} - -.user-problem-group { - h3 { - font-weight: 600; - font-size: 1.25em; - margin-bottom: -10px; - max-height: 20%; - line-height: 2.5em; - } - ul { - -webkit-columns: 300px 4; - -moz-columns: 300px 4; - columns: 300px 4; - list-style-type: none; - margin-top: 0; - margin-left: -20px; - margin-bottom: 0; - } - a img { - max-width: 1em; - margin-right: 3px; - padding-bottom: 1px; - vertical-align: middle; - } -} - -.user-info-cell { - padding-left: 15px; - border-left: 1px solid #CCC; -} - -.contest-history-cell { - border-left: 1px solid #CCC; - padding: 0 0.5em; +.user-problem-group h3 { + font-weight: 600; + font-size: 1.25em; + margin-bottom: -10px; + max-height: 20%; + line-height: 2.5em; } .hide-solved-problems { @@ -151,20 +117,24 @@ a.edit-profile { top: 50%; width: 100000px; height: 1px; - background: rgba(0, 0, 0, 0.2); + background: $color_primary25; right: 100%; margin-right: 5px; } .user-info-page { display: flex; - max-width: 100%; - min-height: 0; } .user-sidebar { - flex: 0 0 150px; - padding-left: 1em; + width: 150px; + padding-left: 15px; +} + +img.user-gravatar { + display: block; + border-radius: 6px; + background-color: white; } .user-content { @@ -179,7 +149,6 @@ a.edit-profile { } .user-sidebar { - width: 150px; margin: 0 auto; } @@ -194,12 +163,8 @@ a.edit-profile { font-size: 1.4em; } - .pp-scaled { - font-size: 0.8em; - } - .pp-weighted { - color: #777; + color: $color_primary50; } div.sub-pp { @@ -208,24 +173,11 @@ a.edit-profile { width: unset; border-left: none; } - - td.problem-name { - text-align: left; - padding-left: 1em; - } - - td.problem-score { - width: 80px; - } - - td.problem-category { - width: 100px; - } } #pp-load-link-wrapper { text-align: center; - border: 1px solid #ccc; + border: 1px solid $color_primary25; } #pp-load-more-link { @@ -268,12 +220,12 @@ a.edit-profile { } #year { font-size: 1.25em; - color: #444; + color: $color_primary75; } } #submission-activity-display { - border: 1px solid $border_gray; + border: 1px solid $color_primary25; border-radius: $table_header_rounding; .info-bar { @@ -294,17 +246,15 @@ a.edit-profile { font-size: 0.75em; line-height: 1; font-weight: 100; - color: #444; + color: $color_primary75; } #submission-total-count { align-self: center; padding-left: 8%; font-size: 0.85em; - } - @media(max-width: 1000px) { - #submission-total-count { + @media (max-width: 1000px) { padding-left: 5px; } } @@ -315,13 +265,12 @@ a.edit-profile { th.submission-date-col { width: 8%; - } - @media (max-width: 1000px) { - th.submission-date-col { + @media (max-width: 1000px) { display: none; } } + td { border-radius: 20%; @@ -335,22 +284,22 @@ a.edit-profile { } &.activity-blank { - background-color: white; + background-color: $color_pageBg; } &.activity-0 { - background-color: #ddd; + background-color: $color_user_submission_activity0; } &.activity-1 { - background-color: #9be9a8; + background-color: $color_user_submission_activity1; } &.activity-2 { - background-color: #40c463; + background-color: $color_user_submission_activity2; } &.activity-3 { - background-color: #2f9c4c; + background-color: $color_user_submission_activity3; } &.activity-4 { - background-color: #216e39; + background-color: $color_user_submission_activity4; } } } diff --git a/resources/vars.scss b/resources/vars-common.scss similarity index 78% rename from resources/vars.scss rename to resources/vars-common.scss index 39da4d359..7ece72311 100644 --- a/resources/vars.scss +++ b/resources/vars-common.scss @@ -1,8 +1,4 @@ $highlight_blue: #1ba94c; -$widget_black: #231f20; -$border_gray: #ccc; -$background_gray: #ededed; -$background_light_gray: #fafafa; $announcement_red: #ae0000; $base_font_size: 15px; diff --git a/resources/vars-dark.scss b/resources/vars-dark.scss new file mode 100644 index 000000000..c665540ca --- /dev/null +++ b/resources/vars-dark.scss @@ -0,0 +1,51 @@ +@import "vars-common"; + +$is_light_theme: false; + +$color_primary0: #0f0f0f; +$color_primary5: #111; // light background +$color_primary10: #181818; // background +$color_primary25: #3b3b3b; // border +$color_primary33: #555; // widget +$color_primary50: #808080; +$color_primary66: #aaa; // tabs +$color_primary75: #ccc; // widget +$color_primary90: #eee; +$color_primary100: #f8f8f8; + +$color_link50: #06d; // default (title) +$color_link75: #6bf; // default +$color_link100: #49e; // hover +$color_link200: #d83; // active + +$color_pageBg: #222; // #222 because some elements should be darker than pageBg + +// custom one-off colours +$color_contest_ranking_disqualified: #622; + +$color_rating_none: #aaa; +$color_rating_newbie: #988f81; +$color_rating_pupil: #72ff72; +$color_rating_specialist: #57fcf2; +$color_rating_expert: #337dff; +$color_rating_candidate_master: #ff55ff; +$color_rating_master: #ff981a; +$color_rating_grandmaster: #ff1a1a; + +$color_select2_search_dropdown: $color_primary10; + +$color_user_submission_activity0: #3b3b3b; +$color_user_submission_activity1: #0e4429; +$color_user_submission_activity2: #006d32; +$color_user_submission_activity3: #26a641; +$color_user_submission_activity4: #39d353; + +$color_problem_submit_select2: #8a8a8a; + +$color_voted_mark: #ffd300; + +$path_to_root: '..'; // relative path from style.css to STATIC_ROOT + +@mixin vars-img { + filter: invert(1) hue-rotate(180deg); +} diff --git a/resources/vars-default.scss b/resources/vars-default.scss new file mode 100644 index 000000000..415d97259 --- /dev/null +++ b/resources/vars-default.scss @@ -0,0 +1,50 @@ +@use 'sass:color'; +@import "vars-common"; + +$is_light_theme: true; + +$color_primary0: #fff; +$color_primary5: #f8f8f8; // light background +$color_primary10: #eee; // background +$color_primary25: #ccc; // border +$color_primary33: #aaa; // widget +$color_primary50: #808080; +$color_primary66: #555; // tabs +$color_primary75: #231f20; // widget +$color_primary90: #111; +$color_primary100: #000; + +$color_link50: #5b80b9; // default (title) +$color_link75: #1958c1; // default +$color_link100: #0645ad; // hover +$color_link200: #faa700; // active + +$color_pageBg: color.adjust($color_primary5, $lightness: 10%); + +// custom one-off colours +$color_contest_ranking_disqualified: #ffa8a8; + +$color_rating_none: #999; +$color_rating_newbie: #808080; +$color_rating_pupil: #008000; +$color_rating_specialist: #03a89e; +$color_rating_expert: #0000ff; +$color_rating_candidate_master: #aa00aa; +$color_rating_master: #ff8c00; +$color_rating_grandmaster: #ff0000; + +$color_select2_search_dropdown: $color_primary0; + +$color_user_submission_activity0: #ddd; +$color_user_submission_activity1: #9be9a8; +$color_user_submission_activity2: #40c463; +$color_user_submission_activity3: #2f9c4c; +$color_user_submission_activity4: #216e39; + +$color_problem_submit_select2: #757575; + +$color_voted_mark: #0000ff; + +$path_to_root: '.'; // relative path from style.css to STATIC_ROOT + +@mixin vars-img {} diff --git a/resources/widgets.scss b/resources/widgets.scss index eff0b1ba9..da3304f1b 100755 --- a/resources/widgets.scss +++ b/resources/widgets.scss @@ -13,7 +13,7 @@ color: #55ACEE; } -.facebook-this it { +.facebook-this i { color: #133783; } @@ -47,6 +47,15 @@ background: #265a88; } + &:active { + border-color: #245580; + background: #265a88; + } + + &.full { + padding: 6px 0; + } + &.disabled { background: linear-gradient(to bottom, darkgray 0, gray 100%) repeat-x !important; border-color: grey !important; @@ -54,19 +63,6 @@ } } -.button.full, input[type=submit].full, button.full { - padding: 6px 0; -} - -button:hover, button:hover, input[type=submit]:hover { - background: #265a88; -} - -.button:active, button:active, input[type=submit]:hover { - border-color: #245580; - background: #265a88; -} - .inline-button { display: inline; vertical-align: top; @@ -76,17 +72,28 @@ button:hover, button:hover, input[type=submit]:hover { input { &[type=text], &[type=password], &[type=email], &[type=number], &[type=url] { padding: 4px 8px; - color: #555; - background: #FFF none; - border: 1px solid $border_gray; + color: $color_primary75; + background: $color_primary0 none; + border: 1px solid $color_primary25; border-radius: $widget_border_radius; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset; + box-shadow: 0 1px 1px rgba($color_primary100, 0.075) inset; transition: border-color 0.15s ease-in-out 0s, box-shadow 0.15s ease-in-out 0s; box-sizing: border-box; // Need this explicitly because UA stylesheet for Chrome on 4k makes // everything look bad otherwise (forces it to 9.3px) font-size: $base_font_size; + + &:hover { + border-color: rgba(82, 168, 236, 0.8); + box-shadow: inset 0 1px 1px rgba($color_primary100, 0.075), 0 0 4px rgba(82, 168, 236, 0.6); + } + + &:focus { + border-color: rgba(82, 168, 236, 0.8); + box-shadow: inset 0 1px 1px rgba($color_primary100, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + outline: 0; + } } &[type=number] { @@ -94,47 +101,44 @@ input { } &[type=checkbox] { + appearance: none; vertical-align: middle; + border: 1px solid $color_primary50; + background: $color_primary0; + border-radius: 2px; + width: 0.95em; + height: 0.95em; + cursor: pointer; + + &:checked { + appearance: auto; + accent-color: $highlight_blue; + } } } textarea { padding: 4px 8px; - color: #555; - background: #FFF none; - border: 1px solid $border_gray; + color: $color_primary75; + background: $color_primary0 none; + border: 1px solid $color_primary25; border-radius: $widget_border_radius; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset; + box-shadow: 0 1px 1px rgba($color_primary100, 0.075) inset; transition: border-color 0.15s ease-in-out 0s, box-shadow 0.15s ease-in-out 0s; box-sizing: border-box; } textarea:hover { border-color: rgba(82, 168, 236, 0.8); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 4px rgba(82, 168, 236, 0.6); -} - -input { - &[type="text"]:hover, &[type="password"]:hover { - border-color: rgba(82, 168, 236, 0.8); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 4px rgba(82, 168, 236, 0.6); - } + box-shadow: inset 0 1px 1px rgba($color_primary100, 0.075), 0 0 4px rgba(82, 168, 236, 0.6); } textarea:focus { border-color: rgba(82, 168, 236, 0.8); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + box-shadow: inset 0 1px 1px rgba($color_primary100, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); outline: 0; } -input { - &[type="text"]:focus, &[type="password"]:focus { - border-color: rgba(82, 168, 236, 0.8); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); - outline: 0; - } -} - // Bootstrap-y copy button .btn-clipboard { top: 0; @@ -142,10 +146,10 @@ input { display: block; font-size: 12px; font-weight: bold; - color: #767676; + color: $color_primary50; cursor: pointer; - background-color: #FFF; - border: 1px solid #E1E1E8; + background-color: $color_pageBg; + border: 1px solid $color_primary25; border-radius: 0 $widget_border_radius; position: absolute; padding: 5px 8px; @@ -158,28 +162,27 @@ input { // Bootstrap-y tabs .ul_tab_a_active { - color: black; + color: $color_primary100; + // Cover up the bottom border of active tabs with this color + background: $color_pageBg; cursor: default; - background-color: #fff; - border: 1px solid $border_gray; - border-bottom-color: transparent; + border: 1px solid $color_primary25; + border-bottom-color: $color_pageBg; border-image: none; } .tabs { - border-bottom: 1px solid $border_gray; + border-bottom: 1px solid $color_primary25; margin: 0 0 8px; width: 100%; display: flex; - - &.tabs-no-flex { - display: block; - } + justify-content: space-between; + flex-wrap: wrap; .tab { .tab-icon { padding-right: 0.3em; - color: gray; + color: $color_primary50; &.fa-plus { color: green !important; @@ -196,17 +199,18 @@ input { } .tab-icon { - color: black; + color: $color_primary100; } } } h2 { - color: #393630; + color: $color_primary75; } > ul { margin: 0; + margin-bottom: -1px; padding: 0; list-style: outside none none; display: flex; @@ -214,7 +218,6 @@ input { overflow-y: hidden; > li { - margin-bottom: -1px; position: relative; display: block; @@ -238,7 +241,7 @@ input { position: relative; display: block; padding: 10px 15px; - color: #555; + color: $color_primary66; text-decoration: none; white-space: nowrap; } @@ -248,15 +251,13 @@ input { // Bootstrap-y pagination ul.pagination a:hover { - color: #FFF; - background: rgba(0, 0, 0, 0.55); + background: rgba($color_primary100, 0.55); } ul.pagination { display: inline-block; padding-left: 0; margin: 0; - border-radius: $widget_border_radius; > { li { @@ -272,7 +273,6 @@ ul.pagination { &:last-child > { a, span { - margin-left: 0; border-top-right-radius: $widget_border_radius; border-bottom-right-radius: $widget_border_radius; } @@ -285,38 +285,22 @@ ul.pagination { padding: 4px 12px; line-height: 1.42857; text-decoration: none; - color: #FFF; - background-color: $widget_black; - border: 1px solid #505050; + color: $color_primary0; + background-color: $color_primary75; + border: 1px solid $color_primary50; margin-left: -1px; } } } .disabled-page > { - a { - color: #888; - background-color: $widget_black; - border-color: #282828; - } - - span { - color: #888; - background-color: $widget_black; - border-color: #505050; + a, span { + color: $color_primary50; } } .active-page > { - a { - z-index: 2; - color: #FFF; - background-color: $highlight_blue; - border-color: transparent; - cursor: default; - } - - span { + a, span { z-index: 2; color: #FFF; background-color: $highlight_blue; @@ -328,9 +312,10 @@ ul.pagination { } .top-pagination-bar { - margin-top: 11px; - margin-bottom: 7px; + margin: 11px 0 7px; display: flex; + justify-content: space-between; + flex-wrap: wrap; } .bottom-pagination-bar { @@ -378,8 +363,7 @@ ul.pagination { font-size: 21px; font-weight: 700; line-height: 1; - color: #000; - text-shadow: 0 1px 0 #fff; + color: $color_primary100; filter: alpha(opacity=20); opacity: 0.2; } @@ -390,7 +374,7 @@ a.close { line-height: 1; &:hover { - color: black !important; + color: $color_primary100; } } @@ -420,21 +404,17 @@ a.close { } .form-submit-group { - border-top: 1px solid #EEE; + border-top: 1px solid rgba($color_primary100, 0.1); margin-top: 0.8em; padding-top: 0.5em; text-align: right; } -ul.select2-selection__rendered { - padding: 0 5px !important; -} - .sidebox h3 { margin: 0 -5px; - background: $widget_black; + background: $color_primary75; border-radius: $widget_border_radius $widget_border_radius 0 0; - color: white; + color: $color_primary0; padding-top: 5px; padding-bottom: 5px; padding-left: 7px; @@ -442,13 +422,13 @@ ul.select2-selection__rendered { } .sidebox h3 .fa { - color: white; + color: $color_primary0; float: right; margin: 0.2em 0.4em 0 0; } .sidebox-content { - border: 1px solid $border_gray; + border: 1px solid $color_primary25; border-top: none; margin: 0 -5px; padding: 1px 0.5em 3px; @@ -533,8 +513,8 @@ ul.select2-selection__rendered { } details { - border: 1px solid $border_gray; - background: $background_light_gray; + border: 1px solid $color_primary25; + background: $color_primary5; padding: 5px 10px; border-radius: 4px; } diff --git a/templates/admin/judge/judge/change_form.html b/templates/admin/judge/judge/change_form.html index f47776849..738d8a5c3 100644 --- a/templates/admin/judge/judge/change_form.html +++ b/templates/admin/judge/judge/change_form.html @@ -6,6 +6,7 @@ django.jQuery(function ($) { $('.disconnect-link').appendTo('div#bottombar').show(); $('.terminate-link').appendTo('div#bottombar').show(); + $('.disable-link').appendTo('div#bottombar').show(); }); {% endblock extrahead %} @@ -22,5 +23,18 @@ {% trans "Terminate" %} + {% if not original.is_disabled %} + + {% else %} + + {% endif %} {% endif %} {% endblock %} diff --git a/templates/base.html b/templates/base.html index 458f99fb4..73257331a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -39,22 +39,24 @@ - {% block meta %}{% endblock %} {% if not INLINE_FONTAWESOME %} {% endif %} {% compress css %} - - {% if PYGMENT_THEME %} - - {% endif %}{% if INLINE_FONTAWESOME %} - {% endif %} + {# TODO: once dark mode support is better, enable for all users by deleting the else branch #} + {% if perms.judge.test_site %} + {% if PREFERRED_STYLE_CSS is not none %} + + {% else %} + + + {% endif %} + {% else %} + + {% endif %} + {% if INLINE_FONTAWESOME %}{% endif %} - {% endcompress %} @@ -163,7 +165,7 @@ {% endif %} - {% if request.user.is_authenticated %} + {% if request.user.is_authenticated and not tfa_in_progress %} {% endif %} + + diff --git a/templates/comments/edit-ajax.html b/templates/comments/edit-ajax.html index a7091e70b..2bdbd1418 100644 --- a/templates/comments/edit-ajax.html +++ b/templates/comments/edit-ajax.html @@ -13,6 +13,6 @@
{{ form.body }}

- +
diff --git a/templates/comments/list.html b/templates/comments/list.html index 9ea51be83..bd311014f 100644 --- a/templates/comments/list.html +++ b/templates/comments/list.html @@ -2,11 +2,22 @@

{{ _('Comments') }}

{% if not comment_lock %} -
+
{{ (_('Please read the [guidelines][0] before commenting.') + '\n\n [0]: /about/comment/')|markdown('blog', strip_paragraphs=True) }}


+ {% else %} +
+ {{ _('Comments are disabled on this page.') }} +
+ {% endif %} + + {% if not comment_lock and not has_comments %} +

{{ _('There are no comments at the moment.') }}

+ {% else %} +
{% endif %} + {% if has_comments %}
    {% set logged_in = request.user.is_authenticated %} @@ -37,7 +48,7 @@

    {{ _('Comments')

{% with author=node.author, user=node.author.user %} - + {% endwith %}
@@ -46,7 +57,7 @@

{{ _('Comments') {% with author=node.author, user=node.author.user %} - + {% endwith %} {{ link_user(node.author) }}  @@ -73,32 +84,15 @@

{{ _('Comments') {% else %} {% endif %} - - - + {% if logged_in and not comment_lock %} - {% set can_edit = node.author.id == profile.id and not profile.mute %} - {% if can_edit %} - - - - {% else %} - - - - {% endif %} {% if perms.judge.change_comment %} - {% if can_edit %} - - {% else %} - - {% endif %} + + @@ -106,6 +100,16 @@

{{ _('Comments') class="hide-comment"> + {% else %} + {% if node.time > reply_cutoff %} + + {% endif %} + {% if node.author.id == profile.id and not profile.mute %} + + {% endif %} {% endif %} {% endif %} @@ -134,8 +138,6 @@

{{ _('Comments') {% endwith %} {% endfor %} - {% elif not comment_lock %} -

{{ _('There are no comments at the moment.') }}

{% endif %} {% if request.user.is_authenticated and comment_form and not comment_lock %} @@ -146,7 +148,7 @@

{{ _('New comment') }}

{% endblock %} {% if is_new_user %}
- {{ _('You need to have solved at least one problem before your voice can be heard.') }} + {{ interact_min_problem_count_msg }}
{% else %}
@@ -168,11 +170,5 @@

{{ _('New comment') }}

{% endif %} {% endif %} - - {% if comment_lock %} -
- {{ _('Comments are disabled on this page.') }} -
- {% endif %} {% endif %} diff --git a/templates/comments/media-css.html b/templates/comments/media-css.html index 3b66135b5..f3732d735 100644 --- a/templates/comments/media-css.html +++ b/templates/comments/media-css.html @@ -1,131 +1,3 @@ {% compress css %} {{ comment_form.media.css }} - {% endcompress %} diff --git a/templates/comments/media-js.html b/templates/comments/media-js.html index 9077d330e..f2998e3f4 100644 --- a/templates/comments/media-js.html +++ b/templates/comments/media-js.html @@ -1,223 +1,4 @@ - {% compress js %} {{ comment_form.media.js }} - + {% include "comments/base-media-js.html" %} {% endcompress %} diff --git a/templates/comments/votes.html b/templates/comments/votes.html index 70397242a..5306eb01d 100644 --- a/templates/comments/votes.html +++ b/templates/comments/votes.html @@ -1,5 +1,5 @@

{{ _('Votes') }}

- +
diff --git a/templates/common-content.html b/templates/common-content.html index 3aab4ddf1..c28800cce 100644 --- a/templates/common-content.html +++ b/templates/common-content.html @@ -13,9 +13,7 @@ var info_float = $('.info-float'); if (info_float.length) { var container = $('#content-right'); - if (window.bad_browser) { - container.css('float', 'right'); - } else if (!featureTest('position', 'sticky')) { + if (!featureTest('position', 'sticky')) { fix_div(info_float, 55); $(window).resize(function () { info_float.width(container.width()); @@ -31,8 +29,8 @@ .append(copyButton = $('', { 'class': 'btn-clipboard', 'data-clipboard-text': $(this).text(), - 'title': 'Click to copy' - }).text('Copy'))); + 'title': '{{ _('Click to copy') }}' + }).text('{{ _('Copy') }}'))); $(copyButton.get(0)).mouseleave(function () { $(this).attr('class', 'btn-clipboard'); @@ -43,14 +41,14 @@ curClipboard.on('success', function (e) { e.clearSelection(); - showTooltip(e.trigger, 'Copied!'); + showTooltip(e.trigger, '{{ _('Copied!') }}'); }); curClipboard.on('error', function (e) { showTooltip(e.trigger, fallbackMessage(e.action)); }); }); - } + }; window.add_code_copy_buttons($(document)); }); diff --git a/templates/contest/calendar.html b/templates/contest/calendar.html index f79b3e237..5725092bd 100644 --- a/templates/contest/calendar.html +++ b/templates/contest/calendar.html @@ -19,7 +19,7 @@ {% for week in calendar %} {% for day in week %} -
{{ _('Voter') }}
+ {{ day.date.day }}
    {% for contest in day.starts %} diff --git a/templates/contest/contest-all-problems.html b/templates/contest/contest-all-problems.html new file mode 100644 index 000000000..f46785bb5 --- /dev/null +++ b/templates/contest/contest-all-problems.html @@ -0,0 +1,57 @@ +{% extends "common-content.html" %} + +{% block title_ruler %}{% endblock %} + +{% block title_row %} + {% set tab = '' %} + {% include "contest/contest-tabs.html" %} + {% if contest.is_organization_private %} + + {% for org in contest.organizations.all() %} + + + {{ org.name }} + + + {% endfor %} + + {% endif %} +{% endblock %} + +{% block body %} + {% set in_contest = contest.is_in_contest(request.user) %} + {% if in_contest or contest.ended or request.user.is_superuser or is_editor or is_tester %} + {% block description %} + {% for problem in contest_problems %} +
    +
    +
    +
    +

    {{ problem.i18n_name }}

    + + {{ _('Submit') }} + +
    + +
    + Time limit: {{ problem.time_limit }} / + Memory limit: {{ problem.memory_limit|kbsimpleformat }} +

    Point: {{ problem.points }}

    +
    +
    + {% include "problem/problem-detail.html" %} +
    +
    +
    + {% endfor %} + {% endblock %} + {% endif %} + + +{% endblock %} + +{% block description_end %}{% endblock %} + +{% block bodyend %} + {{ super() }} +{% endblock %} diff --git a/templates/contest/contest-list-tabs.html b/templates/contest/contest-list-tabs.html index a302d08f5..3a1717300 100644 --- a/templates/contest/contest-list-tabs.html +++ b/templates/contest/contest-list-tabs.html @@ -15,7 +15,6 @@ {% if prev_month or next_month %}|{% endif %} {{ _("Export") }} - {% endif %} {% endblock %} diff --git a/templates/contest/contest-tabs.html b/templates/contest/contest-tabs.html index 456e39733..273fcb9a4 100644 --- a/templates/contest/contest-tabs.html +++ b/templates/contest/contest-tabs.html @@ -31,7 +31,7 @@ {% endif %} {% if request.user.is_authenticated %} - {% if contest.can_join or is_editor or is_tester %} + {% if contest.can_join or contest.require_registration or is_editor or is_tester %} {% set in_contest = contest.is_in_contest(request.user) %} {% if contest.ended %} {# Allow users to leave the virtual contest #} @@ -69,17 +69,27 @@ {% else %} -
    - {% csrf_token %} - -
    + {% if contest.can_join and (has_joined or not contest.require_registration or contest.can_register) %} +
    + {% csrf_token %} + +
    + {% elif contest.can_register and not has_joined %} +
    + {% csrf_token %} + +
    + {% endif %} {% endif %} {% endif %} {% endif %} - {% elif contest.can_join %} + {% elif (contest.can_join and not contest.require_registration) or contest.can_register %}
    diff --git a/templates/contest/contest.html b/templates/contest/contest.html index c60e55951..9a08d9e59 100644 --- a/templates/contest/contest.html +++ b/templates/contest/contest.html @@ -44,26 +44,26 @@ {{- contest.start_time|utc|date('Y-m-d\TH:i:s') }}" class="date"> {%- if contest.is_in_contest(request.user) and not request.participation.live -%} {% if request.participation.spectate %} - {%- trans countdown=contest.time_before_end|as_countdown -%} Spectating, contest ends in {{countdown}}. {%- endtrans -%} + {{- _('Spectating, contest ends in %(countdown)s.', countdown=as_countdown(contest.time_before_end)) -}} {% elif request.participation.end_time %} - {%- trans countdown=request.participation.time_remaining|as_countdown -%} Participating virtually, {{countdown}} remaining. {%- endtrans -%} + {{- _('Participating virtually, %(countdown)s remaining.', countdown=as_countdown(request.participation.time_remaining)) -}} {% else %} {{- _('Participating virtually.') -}} {% endif %} {%- else -%} {% if contest.start_time > now %} - {%- trans countdown=contest.time_before_start|as_countdown -%} Starting in {{countdown}}. {%- endtrans -%} + {{- _('Starting in %(countdown)s.', countdown=as_countdown(contest.time_before_start)) -}} {% elif contest.end_time < now %} {{- _('Contest is over.') -}} {% else %} {%- if has_joined -%} {% if live_participation.ended %} - {%- trans countdown=contest.time_before_end|as_countdown -%} Your time is up! Contest ends in {{countdown}}. {%- endtrans -%} + {{- _('Your time is up! Contest ends in %(countdown)s.', countdown=as_countdown(contest.time_before_end)) -}} {% else %} - {%- trans countdown=live_participation.time_remaining|as_countdown -%} You have {{countdown}} remaining. {%- endtrans -%} + {{- _('You have %(countdown)s remaining.', countdown=as_countdown(live_participation.time_remaining)) -}} {% endif %} {%- else -%} - {%- trans countdown=contest.time_before_end|as_countdown -%} Contest ends in {{countdown}} {%- endtrans -%} + {{ _('Contest ends in %(countdown)s.', countdown=as_countdown(contest.time_before_end)) }} {%- endif -%} {% endif %} {%- endif -%} @@ -191,7 +191,7 @@

    {{ _('Download data') }} {% endif %} - +
    @@ -263,6 +263,8 @@

    {{ _('All problems') }} {% if has_announcements or can_edit %}
    diff --git a/templates/contest/list.html b/templates/contest/list.html index d87e6f70a..5343ca29b 100644 --- a/templates/contest/list.html +++ b/templates/contest/list.html @@ -3,24 +3,6 @@ {% endblock %} -{% block media %} - -{% endblock %} - {% block js_media %} diff --git a/templates/contest/moss.html b/templates/contest/moss.html index 637fa87a7..90b5f462d 100644 --- a/templates/contest/moss.html +++ b/templates/contest/moss.html @@ -24,10 +24,8 @@ - {% endif %} {% if not contest.ended %} -{% endblock %} +{% block js_media %}{{ form.media.js }}{% endblock %} {% block media %} {{ form.media.css }} - - - @@ -43,4 +43,7 @@ {% endblock %} {% block title_ruler %}{% endblock %} -{% block users_table %}{% include "organization/users-table.html" %}{% endblock %} +{% block users_table %} + {% set table_id='organization-users-table' %} + {% include "organization/users-table.html" %} +{% endblock %} diff --git a/templates/problem/data.html b/templates/problem/data.html index d8e5f0271..1fc3188e0 100644 --- a/templates/problem/data.html +++ b/templates/problem/data.html @@ -22,6 +22,7 @@ }); var select2_settings = { + theme: '{{ DMOJ_SELECT2_THEME }}', ajax: { transport: function (params, success) { success({results: valid_files_options}); @@ -93,7 +94,7 @@ var $precision = $('', { type: 'number', value: try_parse_json($args.val()).precision || 6, - title: 'precision (decimal digits)', + title: '{{ _('precision (decimal digits)') }}', style: 'width: 4em' }).change(function () { if ($checker.val().startsWith('floats')) @@ -112,12 +113,13 @@ if (!($file_name == '')) { $file_name = $file_name.split('/').pop() $file_ext = $file_name.split('.').pop() - if (!(['cpp', 'py', 'pas'].includes($file_ext))) { - alert("{{ _('Expected checker\'s extension must be in [cpp, py, pas], found ') }}'" + $file_ext + "'"); + if (!(['cpp', 'pas', 'java'].includes($file_ext))) { + alert("{{ _('Expected checker\'s extension must be in [cpp, pas, java], found ') }}'" + $file_ext + "'"); } else { $lang = $file_ext.toUpperCase(); if ($lang == "CPP") $lang = "CPP17"; + if ($lang == "JAVA") $lang = "JAVA8"; $args.val(JSON.stringify({files: $file_name, lang: $lang, type: $custom_checker_type.find(":selected").val()})); if ($lang == "PY") $args.val(''); @@ -521,6 +523,8 @@ }).catch(function(err) { console.log(err); console.error("Failed to open as ZIP file"); + alert("{{ _('Test file must be a ZIP file') }}"); + event.target.value = ""; }) }; reader.readAsArrayBuffer(fileInput); diff --git a/templates/problem/list.html b/templates/problem/list.html index 5f1efe798..c52270925 100644 --- a/templates/problem/list.html +++ b/templates/problem/list.html @@ -12,23 +12,6 @@ #problem-table th { padding: 0; } - - a.hot-problem-link:hover > .hot-problem-count { - visibility: visible; - } - - span.hot-problem-count { - color: #555; - font-size: 0.75em; - vertical-align: super; - visibility: hidden; - padding-left: 0.25em; - position: relative; - } - - ul.problem-list { - padding: 0 !important; - } {% endblock %} @@ -59,9 +42,13 @@ $form.submit(); } - $category.select2().css({'visibility': 'visible'}).change(clean_submit); - $('#types').select2({multiple: 1, placeholder: '{{ _('Filter by type...') }}'}) - .css({'visibility': 'visible'}); + $category.select2({ + theme: '{{ DMOJ_SELECT2_THEME }}' + }).css({'visibility': 'visible'}).change(clean_submit); + $('#types').select2({ + theme: '{{ DMOJ_SELECT2_THEME }}', + multiple: 1, placeholder: '{{ _('Filter by type...') }}' + }).css({'visibility': 'visible'}); // This is incredibly nasty to do but it's needed because otherwise the select2 steals the focus $search.keypress(function (e) { @@ -88,9 +75,7 @@ var info_float = $('.info-float'); var container = $('#content-right'); - if (window.bad_browser) { - container.css('float', 'right'); - } else if (!featureTest('position', 'sticky')) { + if (!featureTest('position', 'sticky')) { fix_div(info_float, 55); $(window).resize(function () { info_float.width(container.width()); @@ -180,7 +165,7 @@

    {{ _('Hot problems') }}

    {{ _('Category') }}{{ sort_order.group }} {% if show_types %} -

    {% endif %} diff --git a/templates/problem/manage_submission.html b/templates/problem/manage_submission.html index 2aa773bfd..032795d8d 100644 --- a/templates/problem/manage_submission.html +++ b/templates/problem/manage_submission.html @@ -53,11 +53,13 @@ - {% else %} - - {% endif %} - - -{% endif %} + + + diff --git a/templates/problem/search-form.html b/templates/problem/search-form.html index a086944a2..3325003d9 100644 --- a/templates/problem/search-form.html +++ b/templates/problem/search-form.html @@ -51,7 +51,7 @@

    {{ _('Problem search') }} diff --git a/templates/problem/submission-diff.html b/templates/problem/submission-diff.html index 80d1e272c..cac6ef352 100644 --- a/templates/problem/submission-diff.html +++ b/templates/problem/submission-diff.html @@ -106,13 +106,13 @@

    # + {{ _('Types') }}{{ sort_order.type }}
    - - - - - - - + + + + + + + {% for case in range(num_cases) %} {% endfor %} diff --git a/templates/problem/submit.html b/templates/problem/submit.html index a7c62c5c2..bbd0b0fae 100755 --- a/templates/problem/submit.html +++ b/templates/problem/submit.html @@ -69,6 +69,7 @@ var customAdapter = $.fn.select2.amd.require('select2/data/customAdapter'); $("#id_language").select2({ + theme: '{{ DMOJ_SELECT2_THEME }}', templateResult: format, templateSelection: formatSelection, resultsAdapter: customAdapter @@ -229,134 +230,17 @@ ); } } + + // https://stackoverflow.com/questions/43043113/how-to-force-reloading-a-page-when-using-browser-back-button#comment105570384_43043658 + if (window.performance.getEntriesByType("navigation")[0].type === "back_forward") { + location.reload(true); + } {% endcompress %} {% endblock %} {% block media %} {{ form.media.css }} - {% compress css %} - - {% endcompress %} {% endblock %} {% block body %} diff --git a/templates/registration/profile_creation.html b/templates/registration/profile_creation.html index 7641b652d..2f2b76a3f 100644 --- a/templates/registration/profile_creation.html +++ b/templates/registration/profile_creation.html @@ -4,30 +4,32 @@ {{ form.media.css }} {% endblock %} -{% block js_media %}{{ form.media.js }}{% endblock %} +{% block js_media %} + {{ form.media.js }} + +{% endblock %} {% block body %} @@ -43,16 +45,3 @@ {% endblock %} - -{% block bodyend %} - - -{% endblock %} \ No newline at end of file diff --git a/templates/registration/registration_form.html b/templates/registration/registration_form.html index 332d065ae..c7aacab4a 100644 --- a/templates/registration/registration_form.html +++ b/templates/registration/registration_form.html @@ -33,10 +33,6 @@ height: 70px } - .grayed { - color: #666; - } - .inline-header { float: left; font-size: 1.1em; @@ -122,6 +118,12 @@ $('.pass-req').toggle('fast'); return false; }); + try { + var tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (typeof tz === 'string' && $('#id_timezone option[value="' + tz + '"]').length) { + $('#id_timezone').val(tz).change(); + } + } catch (e) {} }); {% if form.captcha %} @@ -190,6 +192,9 @@
    {{ _('Affiliated organizations') }}({{ _('can be blank') }})
    {{ form.organizations }} + {% if form.organizations.errors %} +
    {{ form.organizations.errors }}
    + {% endif %} {% if form.newsletter %}
    {{ form.newsletter }} @@ -208,8 +213,7 @@
    {% if tos_url %} - {{ _('By registering, you agree to our') }} - {{ _('Terms & Conditions') }}. + {{ (_('By registering, you agree to our [Terms & Conditions][0].') + '\n\n [0]: ' + tos_url)|markdown('default', strip_paragraphs=True) }} {% endif %} diff --git a/templates/registration/totp_disable.html b/templates/registration/totp_disable.html index 52fd2f5b4..d5f7f2d2d 100644 --- a/templates/registration/totp_disable.html +++ b/templates/registration/totp_disable.html @@ -35,7 +35,7 @@
    {% endif %}
    - + {{ form.totp_or_scratch_code }}
    diff --git a/templates/registration/totp_enable.html b/templates/registration/totp_enable.html index be9d8b0a4..ee23d0b6a 100644 --- a/templates/registration/totp_enable.html +++ b/templates/registration/totp_enable.html @@ -105,7 +105,7 @@ {% if is_hardcore %}
    - {{ _('Important') }}: + {{ _('Important:') }} {% trans trimmed site_name=SITE_NAME %} If you lose your authentication device and are unable to use your scratch codes, {{ site_name }} admins will NOT be able to recover your account. diff --git a/templates/status/judge-status-table.html b/templates/status/judge-status-table.html index 7020974dd..11686e8b6 100644 --- a/templates/status/judge-status-table.html +++ b/templates/status/judge-status-table.html @@ -1,3 +1,4 @@ +
    {% if see_all_judges %} @@ -8,7 +9,8 @@ - + + {% for judge in judges %} {% endfor %} + diff --git a/templates/status/judge-status.html b/templates/status/judge-status.html index e6fd73c4d..5333c89f3 100644 --- a/templates/status/judge-status.html +++ b/templates/status/judge-status.html @@ -18,7 +18,7 @@ {% block body %}
    -
    1st2ndIDUsernameResultLanguageDate{{ _('1st') }}{{ _('2nd') }}{{ _('ID') }}{{ _('Username') }}{{ _('Result') }}{{ _('Language') }}{{ _('Date') }}{{ loop.index }}
    {{ _('Judge') }}{{ _('Load') }} {{ _('Runtimes') }}
    @@ -29,7 +31,7 @@ {% endif %} {% if judge.online %} - {{ judge.uptime|timedelta("simple") }} + {{ judge.uptime|timedelta('localized') }} {% else %} {{ _('N/A') }} {% endif %} @@ -65,3 +67,4 @@
    +
    {% include "status/judge-status-table.html" %}
    diff --git a/templates/status/language-list.html b/templates/status/language-list.html index 0d051e0d5..64e06b549 100644 --- a/templates/status/language-list.html +++ b/templates/status/language-list.html @@ -28,7 +28,7 @@ {% block body %}
    - +
    diff --git a/templates/submission/info-base.html b/templates/submission/info-base.html index 59a9346b5..35e75fbcb 100644 --- a/templates/submission/info-base.html +++ b/templates/submission/info-base.html @@ -2,10 +2,12 @@ {% block header %} - {{ submission.date|date(_("N j, Y, g:i a")) }} - {% with can_edit=submission.problem.is_editable_by(request.user) %} - {%- if can_edit and submission.judged_on %} - {%- trans name=submission.judged_on.name-%} on judge {{ name }} {%- endtrans -%} + + {% with date=submission.date|date(_('N j, Y, g:i a')), can_edit=submission.problem.is_editable_by(request.user) %} + {% if can_edit and submission.judged_on %} + {{ _('%(date)s on judge %(judge)s', date=date, judge=submission.judged_on.name) }} + {% else %} + {{ date }} {% endif %}
    {{ submission.language }} diff --git a/templates/submission/list.html b/templates/submission/list.html index 13114cb37..469ddf6a8 100644 --- a/templates/submission/list.html +++ b/templates/submission/list.html @@ -31,7 +31,7 @@ + {% endcompress %}
    {% csrf_token %} - {{ _('Rejudge') }} + {{ _('Rejudge') }} diff --git a/templates/tabs-base.html b/templates/tabs-base.html index b8b17fd27..d3cab2d0d 100644 --- a/templates/tabs-base.html +++ b/templates/tabs-base.html @@ -6,15 +6,12 @@ {% endmacro %} -
    -
    - {% if not left_align_tabs %} -

    {{ content_title or title }}

    - - {% endif %} - {% block post_tab_spacer %}{% endblock %} -
      - {% block tabs %}{% endblock %} -
    -
    -
    \ No newline at end of file +
    + {% if not left_align_tabs %} +

    {{ content_title or title }}

    + {% endif %} + {% block post_tab_spacer %}{% endblock %} +
      + {% block tabs %}{% endblock %} +
    +
    diff --git a/templates/tag/assign.html b/templates/tag/assign.html index 3b1552e5e..15ae5bd13 100644 --- a/templates/tag/assign.html +++ b/templates/tag/assign.html @@ -4,6 +4,7 @@ + {% if selected_tag %} -{% endcompress %} \ No newline at end of file +{% endcompress %} diff --git a/templates/user/base-users-table.html b/templates/user/base-users-table.html index 065ab0ac7..c236c174f 100644 --- a/templates/user/base-users-table.html +++ b/templates/user/base-users-table.html @@ -1,58 +1,64 @@ -
    - - - {% block after_rank_head %}{% endblock %} - - {% block before_point_head %}{% endblock %} +{% spaceless %} +
    {{ _('ID') }}
    {{ rank_header or _("Rank") }}{{ _('Username') }}
    + + + + {% block after_rank_head %}{% endblock %} + + {% block before_point_head %}{% endblock %} + + {% block after_point_head %}{% endblock %} + + - - {% block after_point_head %}{% endblock %} - - - - -{% for rank, user in users %} - - - {% block after_rank scoped %}{% endblock %} - + {% for rank, user in users %} + + + {% block after_rank scoped %}{% endblock %} + - {% endblock %} - {% block after_point scoped %}{% endblock %} - -{% endfor %} -{% block after_user_list scoped %}{% endblock %} - + {% block before_point scoped %}{% endblock %} + {% block point scoped %} + + {% endblock %} + {% block after_point scoped %}{% endblock %} + + {% endfor %} + {% block after_user_list scoped %}{% endblock %} + +
    {{ rank_header or _("Rank") }}{{ _('Username') }} + {% block point_head %} + {% if sort_links %}{% endif %} + {{ _('Points') }} + {%- if sort_links %}{{ sort_order.performance_points }}{% endif %} + {% endblock %} +
    - {% block point_head %} - {% if sort_links %}{% endif %} - {{ _('Points') }} - {%- if sort_links %}{{ sort_order.performance_points }}{% endif %} - {% endblock %} -
    {{ rank }} -
    -
    - {% block user_name_display scoped %} {{ link_user(user) }} {% endblock %} -
    - {% block personal_info_display scoped %} - {{ user.user.first_name }} - {% endblock %} +
    + {{ rank }} + +
    +
    + {% block user_name_display scoped %} {{ link_user(user) }} {% endblock %} +
    + {% block personal_info_display scoped %} + {{ user.user.first_name }} + {% endblock %} +
    -
    -
    - {% block user_name_data scoped %}{% endblock %} -
    - {% block personal_info_data scoped %} - - {# DON'T USE FILTER HERE. Unlisted orgs have already filtered out via prefetch_related #} - {% for organization in user.organizations.all() %} - {{ organization.name }}{% if not loop.last %} | {% endif %} - {% endfor %} - - {% endblock %} +
    + {% block user_name_data scoped %}{% endblock %} + {% block admin_operations scoped %}{% endblock %} +
    + {% block personal_info_data scoped %} + + {# DON'T USE FILTER HERE. Unlisted orgs have already filtered out via prefetch_related #} + {% for organization in user.organizations.all() %} + {{ organization.name }}{% if not loop.last %} | {% endif %} + {% endfor %} + + {% endblock %} +
    -
    - {% block before_point scoped %}{% endblock %} - {% block point scoped %} -
    - {{ user.performance_points|floatformat(2) }} -
    + {{ user.performance_points|floatformat(2) }} +
    +{% endspaceless %} diff --git a/templates/user/base-users.html b/templates/user/base-users.html index e83fb45c7..f857ec5ed 100644 --- a/templates/user/base-users.html +++ b/templates/user/base-users.html @@ -11,6 +11,7 @@ })); var in_user_redirect = false; $('#search-handle').select2({ + theme: '{{ DMOJ_SELECT2_THEME }}', placeholder: '{{ _('Search by handle...') }}', ajax: { url: '{{ url('user_search_select2_ajax') }}' @@ -22,14 +23,7 @@ 'class': 'user-search-image', src: data.gravatar_url, width: 24, height: 24 })) - .append($('', {'class': data.display_rank + ' user-search-name'}).text(data.text)) - .append($('', {href: '/user/' + data.text, 'class': 'user-redirect'}) - .append($('', {'class': 'fa fa-mail-forward'})) - .mouseover(function () { - in_user_redirect = true; - }).mouseout(function () { - in_user_redirect = false; - })); + .append($('', {'class': data.display_rank + ' user-search-name'}).text(data.text)); } }).on('select2:selecting', function () { return !in_user_redirect; @@ -53,23 +47,18 @@ {% block media %} {% block users_media %}{% endblock %} - {% endblock %} {% block body %} {% if page_obj and page_obj.has_other_pages() %} -
    +
    {% include "list-pages.html" %} -
    - -
    + {% if not organization %} +
    + +
    + {% endif %}
    {% endif %} @@ -78,9 +67,7 @@ {% block before_users_table %}{% endblock %}
    - - {% block users_table %}{% endblock %} -
    + {% block users_table %}{% endblock %}
    diff --git a/templates/user/comment.html b/templates/user/comment.html new file mode 100644 index 000000000..a23124809 --- /dev/null +++ b/templates/user/comment.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block js_media %} + {% include "comments/base-media-js.html" %} +{% endblock %} + +{% block title_row %} + {% set tab = 'comments' %} + {% include "user/user-tabs.html" %} +{% endblock %} + +{% block title_ruler %}{% endblock %} + +{% block body %} + {% block before_comments %}{% endblock %} +
    + {% csrf_token %} + +
    +
      + {% set logged_in = request.user.is_authenticated %} + {% for comment in comments %} +
    • +
      +
      +
      + {% if logged_in %} + + {% else %} + + {% endif %} +
      +
      {{ comment.score }}
      + {% if logged_in %} + + {% else %} + + {% endif %} +
      + {% with author=comment.author, user=comment.author.user %} + + + + {% endwith %} +
      +
      +
      + + {% with author=comment.author, user=comment.author.user %} + + + + {% endwith %} + {{ link_user(comment.author) }}  + + {% with abs=_('commented on {time}'), rel=_('commented {time}') %} + {{ relative_time(comment.time, abs=abs, rel=rel) }} + → + + {{ comment.page_title }} + + {% endwith %} + + + {% if comment.revisions > 1 %} + + + + {% if comment.revisions > 2 %} + {% trans edits=comment.revisions - 1 %}edit {{ edits }}{% endtrans %} + {% else %} + {{ _('edited') }} + {% endif %} + + + + {% else %} + + {% endif %} + + + + +
      +
      + + {% if comment.score <= vote_hide_threshold %} +
      +

      + {{ _('This comment is hidden due to too much negative feedback.') }} + {{ _('Show it anyway.') }} +

      +
      + {% endif %} +
      +
      +
      +
    • + {% endfor %} +
    + + {% if page_obj.has_other_pages() %} +
    {% include "list-pages.html" %}
    + {% endif %} + {% block after_comments %}{% endblock %} +{% endblock %} + +{% block bodyend %} + {{ super() }} + {% if REQUIRE_JAX %} + {% include "mathjax-load.html" %} + {% endif %} + {% include "comments/math.html" %} +{% endblock %} diff --git a/templates/user/contrib-list.html b/templates/user/contrib-list.html index e05a723c6..c7fcea054 100644 --- a/templates/user/contrib-list.html +++ b/templates/user/contrib-list.html @@ -11,6 +11,7 @@ })); var in_user_redirect = false; $('#search-handle').select2({ + theme: '{{ DMOJ_SELECT2_THEME }}', placeholder: '{{ _('Search by handle...') }}', ajax: { url: '{{ url('user_search_select2_ajax') }}' diff --git a/templates/user/edit-profile.html b/templates/user/edit-profile.html index 113cc4e0c..c59f8bb13 100644 --- a/templates/user/edit-profile.html +++ b/templates/user/edit-profile.html @@ -19,10 +19,6 @@ height: 70px } - .grayed { - color: #666; - } - .inline-header { float: left; font-size: 1.1em; @@ -31,7 +27,6 @@ } .block-header { - color: #666; font-size: 1.1em; } @@ -58,7 +53,7 @@ .pane { display: block; - width: 337px; + width: 340px; } .api-token { @@ -91,7 +86,6 @@ padding-top: 5px; font-size: 12px; } - {% endblock %} @@ -105,7 +99,7 @@ }); $('#disable-2fa-button').click(function () { - alert("The administrators for this site require all the staff to have Two-factor Authentication enabled, so it may not be disabled at this time."); + alert("{{ _('The administrators for this site require all the staff to have Two-factor Authentication enabled, so it may not be disabled at this time.') }}"); }); $('#generate-api-token-button').click(function (event) { @@ -113,7 +107,7 @@ if (confirm("{{ _('Are you sure you want to generate or regenerate your API token?') }}\n" + "{{ _('This will invalidate any previous API tokens.') }} " + "{{ _('It also allows access to your account without Two-factor Authentication.') }}\n\n" - + "{{ _('You will not be able to view your api token after you leave this page!') }}")) { + + "{{ _('You will not be able to view your API token after you leave this page!') }}")) { $('#api-token').text("{{ _('Generating...') }}"); $.ajax({ @@ -205,20 +199,20 @@ $(function () { $('#generate-scratch-codes-button').click(function(event) { event.preventDefault(); - if (confirm("{{ _('Are you sure you want to generate or regenerate a new set of scratch codes? \ -This will invalidate any previous scratch codes you have. \ -You will not be able to view your scratch codes after you leave this page!') }}")) { + if (confirm("{{ _('Are you sure you want to generate or regenerate a new set of scratch codes?') }}\n" + + "{{ _('This will invalidate any previous scratch codes you have.') }}\n\n" + + "{{ _('You will not be able to view your scratch codes after you leave this page!') }}")) { $('#scratch-codes').text("{{ _('Generating...') }}"); - var copyButton; $('pre code').each(function () { - $(this).parent().before($('
    ', {'class': 'copy-clipboard', 'id': {}}) + var copyButton; + $(this).parent().before($('
    ', {'class': 'copy-clipboard'}) .append(copyButton = $('', { 'class': 'btn-clipboard', 'id': 'scratch-codes-copy-button', 'data-clipboard-text': '', - 'title': 'Click to copy' - }).text('Copy'))); + 'title': '{{ _('Click to copy') }}' + }).text('{{ _('Copy') }}'))); $(copyButton.get(0)).mouseleave(function () { $(this).attr('class', 'btn-clipboard'); @@ -229,7 +223,7 @@ curClipboard.on('success', function(e) { e.clearSelection(); - showTooltip(e.trigger, _('Copied!')); + showTooltip(e.trigger, '{{ _('Copied!') }}'); }); curClipboard.on('error', function(e) { @@ -245,13 +239,8 @@ $('#scratch-codes').text(data.data.codes.join('\n')); $('#scratch-codes-copy-button').attr('data-clipboard-text', data.data.codes.join('\n')); $('#generate-scratch-codes-button').text("{{ _('Regenerate') }}"); - $('.hidden-word').empty(); - $('#scratch-codes-text').text("{{ _('Below is a list of one-time use scratch codes. \ - These codes can only be used once and are for emergency use. \ - You can use these codes to login to your account or disable two-factor authentication. \ - If you ever need more scratch codes, you can regenerate them on the edit profile tab. \ - Please write these down and keep them in a secure location. \ - These codes will never be shown again after you enable two-factor authentication.') }}"); + $('#hidden-word').hide(); + $('#scratch-codes-regen').show(); }, }); } @@ -283,9 +272,9 @@ {% if form_user.first_name %} - + {% if form.display_badge %} - + {% endif %} @@ -299,7 +288,7 @@ {% endif %} {% if form.about %} -
    {{ _('Self-description') }}:
    +
    {{ _('Self-description:') }}
    {{ form.about }}
    {% endif %} @@ -308,24 +297,41 @@
    {{ _('Full name') }}:
    {{ _('Full name') }}:
    {{ _('Display badge') }}:
    {{ _('Display badge') }}:
    - + - + + + + + - + {% if has_math_config %} - + {% endif %}
    {{ form.timezone }}
    {{ form.language }}
    {{ form.site_theme }}
    {{ form.ace_theme }}
    {{ form.math_engine }}
    +
    + {% if form.organizations %} +
    +
    + {{ _('Affiliated organizations') }}: +
    + {{ form.organizations }} +
    + {% endif %} +
    +
    +
    +
    {% if form.newsletter %} @@ -360,9 +365,11 @@ {% endif %}
    @@ -342,8 +348,7 @@
    - + {{ _('Change your avatar') }}
    +
    +
    {% if profile.is_totp_enabled %} -
    {{ _('Two-factor Authentication is enabled') }}:
    +
    {{ _('Two-factor authentication is enabled:') }}
    {% if require_staff_2fa and request.user.is_staff and not profile.is_webauthn_enabled %} {{ _('Disable') }} @@ -373,7 +380,7 @@ {{ _('Refresh') }}
    - {{ _('Scratch Codes:') }} + {{ _('Scratch codes:') }} {% if profile.scratch_codes %} {{ _('Regenerate') }} @@ -382,33 +389,28 @@ {{ _('Generate') }} {% endif %} -
    -
    +
    {% else %} - {{ _('Two-factor Authentication is disabled') }}: + {{ _('Two-factor Authentication is disabled:') }} {{ _('Enable') }} {% endif %}
    -
    - {% if form.organizations %} -
    -
    - {{ _('Affiliated organizations') }}: -
    - {{ form.organizations }} -
    - {% endif %} -
    -
    - {% if HAS_WEBAUTHN %} -
    -
    - -
    - {{ _('Security keys') }}: + + {% if HAS_WEBAUTHN %} +
    + {{- _('Security keys:') -}}
    {% if profile.is_webauthn_enabled %} @@ -429,11 +431,11 @@
    + {% endif %}

    - {% endif %} diff --git a/templates/user/link.html b/templates/user/link.html deleted file mode 100644 index d768d13fb..000000000 --- a/templates/user/link.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{ profile.display_name }} - {% if profile.display_badge %} - - {% endif %} - - diff --git a/templates/user/pp-row.html b/templates/user/pp-row.html index 1eb09c74c..02eeb0494 100644 --- a/templates/user/pp-row.html +++ b/templates/user/pp-row.html @@ -21,7 +21,8 @@ {{ breakdown.points|floatformat(2) }}pp
    - {{ _('weighted %(percent)s', percent='%s%%'|safe|format(breakdown.weight|floatformat(0))) }} - ({{ _('%(pp).2fpp', pp=breakdown.scaled_points) }}) + {% with percent='%s%%'|safe|format(breakdown.weight|floatformat(0)), pp=breakdown.scaled_points|floatformat(2) %} + {{ _('weighted %(percent)s (%(pp)spp)', percent=percent, pp=pp) }} + {% endwith %}
    diff --git a/templates/user/prepare-data.html b/templates/user/prepare-data.html index d9661834a..bb754077a 100644 --- a/templates/user/prepare-data.html +++ b/templates/user/prepare-data.html @@ -81,9 +81,8 @@ {{ _('Track progress') }} {% elif can_prepare_data %}
    - {%- trans ratelimit=ratelimit|timedelta('localized-no-seconds') -%} - You may only prepare a new data download for this contest once every {{ ratelimit }}. - {%- endtrans -%} + {% set duration=ratelimit|timedelta %} + {{ _('You may only prepare a new data download once every %(duration)s.', duration=duration) }}
    {{ _('Once your data is ready, you will find a download link on this page.') }}
    @@ -133,9 +132,7 @@

    {{ _('Submissions') }}

    {{ _('Your data is ready!') }}
    - {%- trans countdown=time_until_can_prepare|as_countdown -%} - You will need to wait {{ countdown }} to prepare a new data download. - {%- endtrans -%} + {{ _('You will need to wait %(countdown)s to prepare a new data download.', countdown=as_countdown(time_until_can_prepare)) }}
    {{ _('Download data') }} {% endif %} diff --git a/templates/user/rating.html b/templates/user/rating.html index 34f4214aa..7c921641f 100644 --- a/templates/user/rating.html +++ b/templates/user/rating.html @@ -1,17 +1,9 @@ {% spaceless %} - {% if rating_class(rating) == 'rate-target' %} - - - - - {% else %} - - - - - {% endif %} + + + + {{ rating.rating|default(rating) }} -{% endspaceless %} \ No newline at end of file +{% endspaceless %} diff --git a/templates/user/user-about.html b/templates/user/user-about.html index ff3837948..03d5bf79f 100644 --- a/templates/user/user-about.html +++ b/templates/user/user-about.html @@ -23,12 +23,11 @@ {% if perms.judge.change_profile %} {% with notes=user.notes %} {% if notes %} -

    {{ _('Admin Notes') }}: -

    {{ notes }}
    -

    + {{ _('Admin notes:') }} +
    {{ notes }}
    {% endif %} {% endwith %} - {% endif%} + {% endif %} {% if user.about %}

    {{ _('About') }}

    @@ -141,132 +140,10 @@

    {{ _('Rating history') }}

    {% include "mathjax-load.html" %} {% endif %} + diff --git a/templates/user/user-base.html b/templates/user/user-base.html index 8cbdafc7f..e6324c56b 100644 --- a/templates/user/user-base.html +++ b/templates/user/user-base.html @@ -2,28 +2,6 @@ {% block media %} {% block user_media %}{% endblock %} - - {% endblock %} {% block js_media %} @@ -33,11 +11,14 @@ {% block body %}