diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7280439160..2f25ba506e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,24 +5,46 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: 3.8 - name: Install flake8 run: pip install flake8 flake8-import-order flake8-future-import flake8-commas flake8-logging-format flake8-quotes - name: Lint with flake8 run: | flake8 --version flake8 + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: pip-${{ runner.os }}-${{ secrets.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-mypy.txt') }} + - name: Install mypy dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-mypy.txt + pip install mysqlclient + cp .ci.settings.py dmoj/local_settings.py + - name: Run typecheck with mypy + run: | + PYTHONPATH=. mypy judge unit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v4 with: - python-version: '3.7' + python-version: 3.8 - name: Cache pip uses: actions/cache@v2 with: diff --git a/.gitignore b/.gitignore index e6a45e255d..1c22fd52fd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ resources/martor-description.css resources/style.css resources/vars.scss sass_processed +.mypy_cache diff --git a/dmoj/celery.py b/dmoj/celery.py index e1da640642..a8c6c56201 100644 --- a/dmoj/celery.py +++ b/dmoj/celery.py @@ -10,9 +10,9 @@ app.config_from_object(settings, namespace='CELERY') if hasattr(settings, 'CELERY_BROKER_URL_SECRET'): - app.conf.broker_url = settings.CELERY_BROKER_URL_SECRET + app.conf.broker_url = settings.CELERY_BROKER_URL_SECRET # type: ignore[misc] if hasattr(settings, 'CELERY_RESULT_BACKEND_SECRET'): - app.conf.result_backend = settings.CELERY_RESULT_BACKEND_SECRET + app.conf.result_backend = settings.CELERY_RESULT_BACKEND_SECRET # type: ignore[misc] # Load task modules from all registered Django app configs. app.autodiscover_tasks() diff --git a/dmoj/settings.py b/dmoj/settings.py index 612b63e56c..4f496316d6 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -11,152 +11,159 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import datetime import os +from typing import Any, Dict, List, Literal, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union from django.utils.translation import gettext_lazy as _ from django_jinja.builtins import DEFAULT_EXTENSIONS from jinja2 import select_autoescape +import django_stubs_ext # noqa: I100, I102, I202 +django_stubs_ext.monkeypatch() + +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise + BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production # 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' +SECRET_KEY: str = '5*9f5q57mqmlz2#f$x1h76&jxy#yortjl1v+l*6hd18$d*yx#0' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG: bool = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS: List[str] = [] -SITE_ID = 1 -SITE_NAME = 'DMOJ' -SITE_LONG_NAME = 'DMOJ: Modern Online Judge' -SITE_ADMIN_EMAIL = '' +SITE_ID: int = 1 +SITE_NAME: str = 'DMOJ' +SITE_LONG_NAME: str = 'DMOJ: Modern Online Judge' +SITE_ADMIN_EMAIL: str = '' -DMOJ_REQUIRE_STAFF_2FA = True +DMOJ_REQUIRE_STAFF_2FA: bool = True # Display warnings that admins will not perform 2FA recovery. -DMOJ_2FA_HARDCORE = False +DMOJ_2FA_HARDCORE: bool = False # Set to 1 to use HTTPS if request was made to https:// # Set to 2 to always use HTTPS for links # Set to 0 to always use HTTP for links -DMOJ_SSL = 0 +DMOJ_SSL: int = 0 # Refer to https://dmoj.ca/post/103-point-system-rework -DMOJ_PP_STEP = 0.95 -DMOJ_PP_ENTRIES = 100 +DMOJ_PP_STEP: float = 0.95 +DMOJ_PP_ENTRIES: int = 100 DMOJ_PP_BONUS_FUNCTION = lambda n: 300 * (1 - 0.997 ** n) # noqa: E731 -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' -SELECT2_CSS_URL = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css' +ACE_URL: str = '//cdnjs.cloudflare.com/ajax/libs/ace/1.1.3' +SELECT2_JS_URL: str = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js' +SELECT2_CSS_URL: str = '//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_CAMO_URL: Optional[str] = None +DMOJ_CAMO_KEY: Optional[str] = None +DMOJ_CAMO_HTTPS: bool = False +DMOJ_CAMO_EXCLUDE: Tuple[str, ...] = () -DMOJ_PROBLEM_DATA_ROOT = None +DMOJ_PROBLEM_DATA_ROOT: Optional[str] = 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_MIN_USER_POINTS_VOTE = 1 # when voting on problem, minimum point value user can select -DMOJ_PROBLEM_MAX_USER_POINTS_VOTE = 50 # when voting on problem, maximum point value user can select -DMOJ_PROBLEM_HOT_PROBLEM_COUNT = 7 +DMOJ_PROBLEM_MIN_TIME_LIMIT: int = 0 # seconds +DMOJ_PROBLEM_MAX_TIME_LIMIT: int = 60 # seconds +DMOJ_PROBLEM_MIN_MEMORY_LIMIT: int = 0 # kilobytes +DMOJ_PROBLEM_MAX_MEMORY_LIMIT: int = 1048576 # kilobytes +DMOJ_PROBLEM_MIN_PROBLEM_POINTS: int = 0 +DMOJ_PROBLEM_MIN_USER_POINTS_VOTE: int = 1 # when voting on problem, minimum point value user can select +DMOJ_PROBLEM_MAX_USER_POINTS_VOTE: int = 50 # when voting on problem, maximum point value user can select +DMOJ_PROBLEM_HOT_PROBLEM_COUNT: int = 7 -DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS = {'“', '”', '‘', '’', '−', 'ff', 'fi', 'fl', 'ffi', 'ffl'} -DMOJ_RATING_COLORS = True -DMOJ_EMAIL_THROTTLING = (10, 60) +DMOJ_PROBLEM_STATEMENT_DISALLOWED_CHARACTERS: Set[str] = {'“', '”', '‘', '’', '−', 'ff', 'fi', 'fl', 'ffi', 'ffl'} +DMOJ_RATING_COLORS: bool = True +DMOJ_EMAIL_THROTTLING: Tuple[int, int] = (10, 60) # Maximum number of submissions a single user can queue without the `spam_submission` permission -DMOJ_SUBMISSION_LIMIT = 2 -DMOJ_SUBMISSIONS_REJUDGE_LIMIT = 10 +DMOJ_SUBMISSION_LIMIT: int = 2 +DMOJ_SUBMISSIONS_REJUDGE_LIMIT: int = 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_TOTP_TOLERANCE_HALF_MINUTES = 1 -DMOJ_SCRATCH_CODES_COUNT = 5 -DMOJ_USER_MAX_ORGANIZATION_COUNT = 3 +DMOJ_SUBMISSION_SOURCE_VISIBILITY: Union[Literal['all'], Literal['all-solved'], Literal['only-own']] = 'all-solved' +DMOJ_BLOG_NEW_PROBLEM_COUNT: int = 7 +DMOJ_TOTP_TOLERANCE_HALF_MINUTES: int = 1 +DMOJ_SCRATCH_CODES_COUNT: int = 5 +DMOJ_USER_MAX_ORGANIZATION_COUNT: int = 3 # Whether to allow users to download their data -DMOJ_USER_DATA_DOWNLOAD = False -DMOJ_USER_DATA_CACHE = '' +DMOJ_USER_DATA_DOWNLOAD: bool = False +DMOJ_USER_DATA_CACHE: str = '' DMOJ_USER_DATA_DOWNLOAD_RATELIMIT = datetime.timedelta(days=1) -DMOJ_COMMENT_VOTE_HIDE_THRESHOLD = -5 +DMOJ_COMMENT_VOTE_HIDE_THRESHOLD: int = -5 DMOJ_COMMENT_REPLY_TIMEFRAME = datetime.timedelta(days=365) -DMOJ_PDF_PDFOID_URL = None +DMOJ_PDF_PDFOID_URL: Optional[str] = None # Optional but recommended to save resources, path on disk to cache PDFs -DMOJ_PDF_PROBLEM_CACHE = None +DMOJ_PDF_PROBLEM_CACHE: Optional[str] = None # Optional, URL serving DMOJ_PDF_PROBLEM_CACHE with X-Accel-Redirect -DMOJ_PDF_PROBLEM_INTERNAL = None +DMOJ_PDF_PROBLEM_INTERNAL: Optional[str] = None -DMOJ_STATS_LANGUAGE_THRESHOLD = 10 -DMOJ_STATS_SUBMISSION_RESULT_COLORS = { +DMOJ_STATS_LANGUAGE_THRESHOLD: int = 10 +DMOJ_STATS_SUBMISSION_RESULT_COLORS: Dict[str, str] = { 'TLE': '#a3bcbd', 'AC': '#00a92a', 'WA': '#ed4420', 'CE': '#42586d', 'ERR': '#ffa71c', } -DMOJ_API_PAGE_SIZE = 1000 +DMOJ_API_PAGE_SIZE: int = 1000 -DMOJ_PASSWORD_RESET_LIMIT_WINDOW = 3600 -DMOJ_PASSWORD_RESET_LIMIT_COUNT = 10 +DMOJ_PASSWORD_RESET_LIMIT_WINDOW: int = 3600 +DMOJ_PASSWORD_RESET_LIMIT_COUNT: int = 10 # At the bare minimum, dark and light theme CSS file locations must be declared -DMOJ_THEME_CSS = { +DMOJ_THEME_CSS: Dict[str, str] = { 'light': 'style.css', 'dark': 'dark/style.css', } # At the bare minimum, dark and light ace themes must be declared -DMOJ_THEME_DEFAULT_ACE_THEME = { +DMOJ_THEME_DEFAULT_ACE_THEME: Dict[str, str] = { 'light': 'github', 'dark': 'twilight', } -DMOJ_SELECT2_THEME = 'dmoj' - -MARKDOWN_STYLES = {} -MARKDOWN_DEFAULT_STYLE = {} - -MATHOID_URL = False -MATHOID_GZIP = False -MATHOID_MML_CACHE = None -MATHOID_CSS_CACHE = 'default' -MATHOID_DEFAULT_TYPE = 'auto' -MATHOID_MML_CACHE_TTL = 86400 -MATHOID_CACHE_ROOT = '' -MATHOID_CACHE_URL = False - -TEXOID_GZIP = False -TEXOID_META_CACHE = 'default' -TEXOID_META_CACHE_TTL = 86400 -DMOJ_NEWSLETTER_ID_ON_REGISTER = None - -BAD_MAIL_PROVIDERS = () +DMOJ_SELECT2_THEME: str = 'dmoj' + +MARKDOWN_STYLES: Dict[str, Dict[str, Any]] = {} +MARKDOWN_DEFAULT_STYLE: Dict[str, Any] = {} + +MATHOID_URL: Union[Literal[False], str] = False +MATHOID_GZIP: bool = False +MATHOID_MML_CACHE: Optional[str] = None +MATHOID_CSS_CACHE: str = 'default' +MATHOID_DEFAULT_TYPE: str = 'auto' +MATHOID_MML_CACHE_TTL: int = 86400 +MATHOID_CACHE_ROOT: str = '' +MATHOID_CACHE_URL: bool = False + +TEXOID_GZIP: bool = False +TEXOID_META_CACHE: str = 'default' +TEXOID_META_CACHE_TTL: int = 86400 +DMOJ_NEWSLETTER_ID_ON_REGISTER: Optional[int] = None + +BAD_MAIL_PROVIDERS: Tuple[str, ...] = () BAD_MAIL_PROVIDER_REGEX = () -NOFOLLOW_EXCLUDED = set() +NOFOLLOW_EXCLUDED: Set[str] = set() -TIMEZONE_MAP = 'https://static.dmoj.ca/assets/earth.jpg' +TIMEZONE_MAP: str = 'https://static.dmoj.ca/assets/earth.jpg' -TERMS_OF_SERVICE_URL = None -DEFAULT_USER_LANGUAGE = 'PY3' +TERMS_OF_SERVICE_URL: Optional[str] = None +DEFAULT_USER_LANGUAGE: str = 'PY3' -INLINE_JQUERY = True -INLINE_FONTAWESOME = True -JQUERY_JS = '//ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js' -FONTAWESOME_CSS = '//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css' -DMOJ_CANONICAL = '' +INLINE_JQUERY: bool = True +INLINE_FONTAWESOME: bool = True +JQUERY_JS: str = '//ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js' +FONTAWESOME_CSS: str = '//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css' +DMOJ_CANONICAL: str = '' # Application definition -INSTALLED_APPS = () +INSTALLED_APPS: Tuple[str, ...] = () try: import wpadmin @@ -271,7 +278,7 @@ 'adminsortable2', ) -MIDDLEWARE = ( +MIDDLEWARE: Tuple[str, ...] = ( 'judge.middleware.ShortCircuitMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -293,12 +300,12 @@ 'django.contrib.redirects.middleware.RedirectFallbackMiddleware', ) -IMPERSONATE_REQUIRE_SUPERUSER = True -IMPERSONATE_DISABLE_LOGGING = True +IMPERSONATE_REQUIRE_SUPERUSER: bool = True +IMPERSONATE_DISABLE_LOGGING: bool = True -ACCOUNT_ACTIVATION_DAYS = 7 +ACCOUNT_ACTIVATION_DAYS: int = 7 -AUTH_PASSWORD_VALIDATORS = [ +AUTH_PASSWORD_VALIDATORS: List[Dict[str, str]] = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, @@ -313,14 +320,14 @@ }, ] -SILENCED_SYSTEM_CHECKS = ['urls.W002', 'fields.W342'] +SILENCED_SYSTEM_CHECKS: List[str] = ['urls.W002', 'fields.W342'] -ROOT_URLCONF = 'dmoj.urls' -LOGIN_REDIRECT_URL = '/user' -WSGI_APPLICATION = 'dmoj.wsgi.application' -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +ROOT_URLCONF: str = 'dmoj.urls' +LOGIN_REDIRECT_URL: str = '/user' +WSGI_APPLICATION: str = 'dmoj.wsgi.application' +DEFAULT_AUTO_FIELD: str = 'django.db.models.AutoField' -TEMPLATES = [ +TEMPLATES: List[Dict[str, Any]] = [ { 'BACKEND': 'django_jinja.backend.Jinja2', 'DIRS': [ @@ -377,11 +384,11 @@ }, ] -LOCALE_PATHS = [ +LOCALE_PATHS: List[str] = [ os.path.join(BASE_DIR, 'locale'), ] -LANGUAGES = [ +LANGUAGES: List[Tuple[str, 'StrOrPromise']] = [ ('ca', _('Catalan')), ('de', _('German')), ('el', _('Greek')), @@ -402,7 +409,7 @@ ('zh-hant', _('Traditional Chinese')), ] -BLEACH_USER_SAFE_TAGS = [ +BLEACH_USER_SAFE_TAGS: List[str] = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'b', 'i', 'strong', 'em', 'tt', 'del', 'kbd', 's', 'abbr', 'cite', 'mark', 'q', 'samp', 'small', 'u', 'var', 'wbr', 'dfn', 'ruby', 'rb', 'rp', 'rt', 'rtc', 'sub', 'sup', 'time', 'data', @@ -414,7 +421,7 @@ 'style', 'noscript', 'center', ] -BLEACH_USER_SAFE_ATTRS = { +BLEACH_USER_SAFE_ATTRS: Dict[str, List[str]] = { '*': ['id', 'class', 'style'], 'img': ['src', 'alt', 'title', 'width', 'height', 'data-src', 'align'], 'a': ['href', 'alt', 'title'], @@ -482,7 +489,7 @@ 'ticket': MARKDOWN_USER_LARGE_STYLE, } -MARTOR_ENABLE_CONFIGS = { +MARTOR_ENABLE_CONFIGS: Dict[str, Union[Literal['false'], Literal['true']]] = { 'imgur': 'true', 'mention': 'true', 'jquery': 'false', @@ -490,81 +497,81 @@ 'spellcheck': 'false', 'hljs': 'false', } -MARTOR_MARKDOWNIFY_URL = '/widgets/preview/default' -MARTOR_SEARCH_USERS_URL = '/widgets/martor/search-user' -MARTOR_UPLOAD_URL = '/widgets/martor/upload-image' -MARTOR_MARKDOWN_BASE_MENTION_URL = '/user/' +MARTOR_MARKDOWNIFY_URL: str = '/widgets/preview/default' +MARTOR_SEARCH_USERS_URL: str = '/widgets/martor/search-user' +MARTOR_UPLOAD_URL: str = '/widgets/martor/upload-image' +MARTOR_MARKDOWN_BASE_MENTION_URL: str = '/user/' # Directory under MEDIA_ROOT to use to store image uploaded through martor. -MARTOR_UPLOAD_MEDIA_DIR = 'martor' -MARTOR_UPLOAD_SAFE_EXTS = {'.jpg', '.png', '.gif'} +MARTOR_UPLOAD_MEDIA_DIR: str = 'martor' +MARTOR_UPLOAD_SAFE_EXTS: Set[str] = {'.jpg', '.png', '.gif'} # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases -DATABASES = { +DATABASES: Dict[str, Dict[str, Any]] = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), }, } -ENABLE_FTS = False +ENABLE_FTS: bool = False # Bridged configuration -BRIDGED_JUDGE_ADDRESS = [('localhost', 9999)] -BRIDGED_JUDGE_PROXIES = None -BRIDGED_DJANGO_ADDRESS = [('localhost', 9998)] -BRIDGED_DJANGO_CONNECT = None +BRIDGED_JUDGE_ADDRESS: List[Tuple[str, int]] = [('localhost', 9999)] +BRIDGED_JUDGE_PROXIES: Optional[List[Tuple[str, int]]] = None +BRIDGED_DJANGO_ADDRESS: List[Tuple[str, int]] = [('localhost', 9998)] +BRIDGED_DJANGO_CONNECT: Optional[List[Tuple[str, int]]] = None # Event Server configuration -EVENT_DAEMON_USE = False -EVENT_DAEMON_POST = 'ws://localhost:9997/' -EVENT_DAEMON_GET = 'ws://localhost:9996/' -EVENT_DAEMON_POLL = '/channels/' -EVENT_DAEMON_KEY = None -EVENT_DAEMON_AMQP_EXCHANGE = 'dmoj-events' -EVENT_DAEMON_SUBMISSION_KEY = '6Sdmkx^%pk@GsifDfXcwX*Y7LRF%RGT8vmFpSxFBT$fwS7trc8raWfN#CSfQuKApx&$B#Gh2L7p%W!Ww' +EVENT_DAEMON_USE: bool = False +EVENT_DAEMON_POST: str = 'ws://localhost:9997/' +EVENT_DAEMON_GET: str = 'ws://localhost:9996/' +EVENT_DAEMON_POLL: str = '/channels/' +EVENT_DAEMON_KEY: Optional[str] = None +EVENT_DAEMON_AMQP_EXCHANGE: str = 'dmoj-events' +EVENT_DAEMON_SUBMISSION_KEY: str = '6Sdmkx^%pk@GsifDfXcwX*Y7LRF%RGT8vmFpSxFBT$fwS7trc8raWfN#CSfQuKApx&$B#Gh2L7p%W!Ww' # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ # Whatever you do, this better be one of the entries in `LANGUAGES`. -LANGUAGE_CODE = 'en' -TIME_ZONE = 'UTC' -DEFAULT_USER_TIME_ZONE = 'America/Toronto' -USE_I18N = True -USE_L10N = True -USE_TZ = True +LANGUAGE_CODE: str = 'en' +TIME_ZONE: str = 'UTC' +DEFAULT_USER_TIME_ZONE: str = 'America/Toronto' +USE_I18N: bool = True +USE_L10N: bool = True +USE_TZ: bool = True # Cookies -SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' +SESSION_ENGINE: str = 'django.contrib.sessions.backends.cached_db' # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ DMOJ_RESOURCES = os.path.join(BASE_DIR, 'resources') -STATICFILES_FINDERS = ( +STATICFILES_FINDERS: Tuple[str, ...] = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ) -STATICFILES_DIRS = [ +STATICFILES_DIRS: List[str] = [ os.path.join(BASE_DIR, 'resources'), ] -STATIC_URL = '/static/' +STATIC_URL: str = '/static/' # Define a cache -CACHES = {} +CACHES: Dict[str, Dict[str, Any]] = {} # Authentication -AUTHENTICATION_BACKENDS = ( +AUTHENTICATION_BACKENDS: Sequence[str] = ( 'social_core.backends.google.GoogleOAuth2', 'social_core.backends.facebook.FacebookOAuth2', 'judge.social_auth.GitHubSecureEmailOAuth2', 'django.contrib.auth.backends.ModelBackend', ) -SOCIAL_AUTH_PIPELINE = ( +SOCIAL_AUTH_PIPELINE: Tuple[str, ...] = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.auth_allowed', @@ -580,14 +587,14 @@ 'social_core.pipeline.user.user_details', ) -SOCIAL_AUTH_GITHUB_SECURE_SCOPE = ['user:email'] -SOCIAL_AUTH_FACEBOOK_SCOPE = ['email'] -SOCIAL_AUTH_SLUGIFY_USERNAMES = True -SOCIAL_AUTH_SLUGIFY_FUNCTION = 'judge.social_auth.slugify_username' +SOCIAL_AUTH_GITHUB_SECURE_SCOPE: List[str] = ['user:email'] +SOCIAL_AUTH_FACEBOOK_SCOPE: List[str] = ['email'] +SOCIAL_AUTH_SLUGIFY_USERNAMES: bool = True +SOCIAL_AUTH_SLUGIFY_FUNCTION: str = 'judge.social_auth.slugify_username' -MOSS_API_KEY = None +MOSS_API_KEY: Optional[str] = None -CELERY_WORKER_HIJACK_ROOT_LOGGER = False +CELERY_WORKER_HIJACK_ROOT_LOGGER: bool = False WEBAUTHN_RP_ID = None diff --git a/judge/admin/comments.py b/judge/admin/comments.py index d69c1c6df5..d6019f7b08 100644 --- a/judge/admin/comments.py +++ b/judge/admin/comments.py @@ -37,14 +37,14 @@ class CommentAdmin(VersionAdmin): def get_queryset(self, request): return Comment.objects.order_by('-time') - @admin.display(description=_('Hide comments')) + @admin.action(description=_('Hide comments')) def hide_comment(self, request, queryset): count = queryset.update(hidden=True) self.message_user(request, ngettext('%d comment successfully hidden.', '%d comments successfully hidden.', count) % count) - @admin.display(description=_('Unhide comments')) + @admin.action(description=_('Unhide comments')) def unhide_comment(self, request, queryset): count = queryset.update(hidden=False) self.message_user(request, ngettext('%d comment successfully unhidden.', diff --git a/judge/admin/contest.py b/judge/admin/contest.py index 59c25d103a..034d5d38f3 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -16,11 +16,11 @@ from judge.models import Class, Contest, ContestProblem, ContestSubmission, Profile, Rating, Submission from judge.ratings import rate_contest from judge.utils.views import NoBatchDeleteMixin -from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminMartorWidget, \ - AdminSelect2MultipleWidget, AdminSelect2Widget +from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget as OldAdminHeavySelect2Widget, \ + AdminMartorWidget, AdminSelect2MultipleWidget, AdminSelect2Widget -class AdminHeavySelect2Widget(AdminHeavySelect2Widget): +class AdminHeavySelect2Widget(OldAdminHeavySelect2Widget): @property def is_hidden(self): return False @@ -222,7 +222,7 @@ def _rescore(self, contest_key): from judge.tasks import rescore_contest transaction.on_commit(rescore_contest.s(contest_key).delay) - @admin.display(description=_('Mark contests as visible')) + @admin.action(description=_('Mark contests as visible')) def make_visible(self, request, queryset): if not request.user.has_perm('judge.change_contest_visibility'): queryset = queryset.filter(Q(is_private=True) | Q(is_organization_private=True)) @@ -231,7 +231,7 @@ def make_visible(self, request, queryset): '%d contests successfully marked as visible.', count) % count) - @admin.display(description=_('Mark contests as hidden')) + @admin.action(description=_('Mark contests as hidden')) def make_hidden(self, request, queryset): if not request.user.has_perm('judge.change_contest_visibility'): queryset = queryset.filter(Q(is_private=True) | Q(is_organization_private=True)) @@ -240,7 +240,7 @@ def make_hidden(self, request, queryset): '%d contests successfully marked as hidden.', count) % count) - @admin.display(description=_('Lock contest submissions')) + @admin.action(description=_('Lock contest submissions')) def set_locked(self, request, queryset): for row in queryset: self.set_locked_after(row, timezone.now()) @@ -249,7 +249,7 @@ def set_locked(self, request, queryset): '%d contests successfully locked.', count) % count) - @admin.display(description=_('Unlock contest submissions')) + @admin.action(description=_('Unlock contest submissions')) def set_unlocked(self, request, queryset): for row in queryset: self.set_locked_after(row, None) @@ -350,7 +350,7 @@ def save_model(self, request, obj, form, change): if form.changed_data and 'is_disqualified' in form.changed_data: obj.set_disqualified(obj.is_disqualified) - @admin.display(description=_('Recalculate results')) + @admin.action(description=_('Recalculate results')) def recalculate_results(self, request, queryset): count = 0 for participation in queryset: diff --git a/judge/admin/problem.py b/judge/admin/problem.py index 9a2150a194..a178c7c36f 100644 --- a/judge/admin/problem.py +++ b/judge/admin/problem.py @@ -186,14 +186,14 @@ def _rescore(self, request, problem_id): from judge.tasks import rescore_problem transaction.on_commit(rescore_problem.s(problem_id).delay) - @admin.display(description=_('Set publish date to now')) + @admin.action(description=_('Set publish date to now')) 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) - @admin.display(description=_('Mark problems as public')) + @admin.action(description=_('Mark problems as public')) def make_public(self, request, queryset): count = queryset.update(is_public=True) for problem_id in queryset.values_list('id', flat=True): @@ -202,7 +202,7 @@ def make_public(self, request, queryset): '%d problems successfully marked as public.', count) % count) - @admin.display(description=_('Mark problems as private')) + @admin.action(description=_('Mark problems as private')) def make_private(self, request, queryset): count = queryset.update(is_public=False) for problem_id in queryset.values_list('id', flat=True): diff --git a/judge/admin/profile.py b/judge/admin/profile.py index 67466e5c1c..e5bd987f93 100644 --- a/judge/admin/profile.py +++ b/judge/admin/profile.py @@ -108,7 +108,7 @@ def timezone_full(self, obj): def date_joined(self, obj): return obj.user.date_joined - @admin.display(description=_('Recalculate scores')) + @admin.action(description=_('Recalculate scores')) def recalculate_points(self, request, queryset): count = 0 for profile in queryset: diff --git a/judge/admin/submission.py b/judge/admin/submission.py index fb05d31123..3589619f1b 100644 --- a/judge/admin/submission.py +++ b/judge/admin/submission.py @@ -80,7 +80,7 @@ def formfield_for_dbfield(self, db_field, **kwargs): contest__problems=submission.problem) \ .only('id', 'contest__name', 'virtual') - def label(obj): + def label(obj): # noqa: F811, label is used, erroneous error if obj.spectate: return gettext('%s (spectating)') % obj.contest.name if obj.virtual: @@ -156,7 +156,7 @@ def has_change_permission(self, request, obj=None): def lookup_allowed(self, key, value): return super(SubmissionAdmin, self).lookup_allowed(key, value) or key in ('problem__code',) - @admin.display(description=_('Rejudge the selected submissions')) + @admin.action(description=_('Rejudge the selected submissions')) def judge(self, request, queryset): if not request.user.has_perm('judge.rejudge_submission') or not request.user.has_perm('judge.edit_own_problem'): self.message_user(request, gettext('You do not have the permission to rejudge submissions.'), @@ -178,7 +178,7 @@ def judge(self, request, queryset): '%d submissions were successfully scheduled for rejudging.', judged) % judged) - @admin.display(description=_('Rescore the selected submissions')) + @admin.action(description=_('Rescore the selected submissions')) def recalculate_score(self, request, queryset): if not request.user.has_perm('judge.rejudge_submission'): self.message_user(request, gettext('You do not have the permission to rejudge submissions.'), diff --git a/judge/bridge/base_handler.py b/judge/bridge/base_handler.py index e3ed9c574a..95a1c703c7 100644 --- a/judge/bridge/base_handler.py +++ b/judge/bridge/base_handler.py @@ -3,6 +3,7 @@ import struct import zlib from itertools import chain +from typing import List, Tuple from netaddr import IPGlob, IPSet @@ -50,7 +51,7 @@ def __call__(cls, *args, **kwargs): class ZlibPacketHandler(metaclass=RequestHandlerMeta): - proxies = [] + proxies: List[Tuple[str, int]] = [] def __init__(self, request, client_address, server): self.request = request diff --git a/judge/event_poster_ws.py b/judge/event_poster_ws.py index fba4052631..58e0b182d4 100644 --- a/judge/event_poster_ws.py +++ b/judge/event_poster_ws.py @@ -3,7 +3,7 @@ import threading from django.conf import settings -from websocket import WebSocketException, create_connection +from websocket import WebSocketException, create_connection # type: ignore[attr-defined] __all__ = ['EventPostingError', 'EventPoster', 'post', 'last'] _local = threading.local() diff --git a/judge/jinja2/markdown/__init__.py b/judge/jinja2/markdown/__init__.py index 6bb8d689b3..3a2b38219c 100644 --- a/judge/jinja2/markdown/__init__.py +++ b/judge/jinja2/markdown/__init__.py @@ -1,6 +1,7 @@ import logging import re from html import unescape +from typing import Dict from urllib.parse import urlparse import mistune @@ -111,7 +112,7 @@ def header(self, text, level, *args, **kwargs): return super(AwesomeRenderer, self).header(text, level + 2, *args, **kwargs) -cleaner_cache = {} +cleaner_cache: Dict[str, Cleaner] = {} def get_cleaner(name, params): diff --git a/judge/jinja2/registry.py b/judge/jinja2/registry.py index da121666f9..d931c75541 100644 --- a/judge/jinja2/registry.py +++ b/judge/jinja2/registry.py @@ -1,9 +1,11 @@ +from typing import Callable, Dict, List + from django_jinja.library import render_with -globals = {} -tests = {} -filters = {} -extensions = [] +globals: Dict[str, Callable] = {} +tests: Dict[str, Callable] = {} +filters: Dict[str, Callable] = {} +extensions: List[type] = [] __all__ = ['render_with', 'function', 'filter', 'test', 'extension'] diff --git a/judge/jinja2/render.py b/judge/jinja2/render.py index 778e26ad6c..c595f47372 100644 --- a/judge/jinja2/render.py +++ b/judge/jinja2/render.py @@ -1,10 +1,12 @@ +from typing import Dict, Union + from django.template import (Context, Template as DjangoTemplate, TemplateSyntaxError as DjangoTemplateSyntaxError, VariableDoesNotExist) from . import registry MAX_CACHE = 100 -django_cache = {} +django_cache: Dict[Union[DjangoTemplate, str], DjangoTemplate] = {} def compile_template(code): diff --git a/judge/management/commands/generate_sitemap.py b/judge/management/commands/generate_sitemap.py index d71912b184..a0f3be9aa5 100644 --- a/judge/management/commands/generate_sitemap.py +++ b/judge/management/commands/generate_sitemap.py @@ -10,7 +10,7 @@ class Command(BaseCommand): - requires_system_checks = False + requires_system_checks = () def add_arguments(self, parser): parser.add_argument('directory', help='directory to generate the sitemap in') diff --git a/judge/management/commands/runmoss.py b/judge/management/commands/runmoss.py index 2404984f08..55744bedf6 100644 --- a/judge/management/commands/runmoss.py +++ b/judge/management/commands/runmoss.py @@ -1,6 +1,6 @@ from django.conf import settings from django.core.management.base import BaseCommand -from moss import * +from moss import MOSS, MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON from judge.models import Contest, ContestParticipation, Submission diff --git a/judge/models/contest.py b/judge/models/contest.py index 37f2010a47..21c8d08547 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -318,7 +318,7 @@ def update_user_count(self): self.user_count = self.users.filter(virtual=0).count() self.save() - update_user_count.alters_data = True + update_user_count.alters_data = True # type: ignore[attr-defined] class Inaccessible(Exception): pass @@ -516,7 +516,7 @@ def recompute_results(self): self.cumtime = 0 self.tiebreaker = 0 self.save(update_fields=['score', 'cumtime', 'tiebreaker']) - recompute_results.alters_data = True + recompute_results.alters_data = True # type: ignore[attr-defined] def set_disqualified(self, disqualified): self.is_disqualified = disqualified @@ -529,7 +529,7 @@ def set_disqualified(self, disqualified): self.contest.banned_users.add(self.user) else: self.contest.banned_users.remove(self.user) - set_disqualified.alters_data = True + set_disqualified.alters_data = True # type: ignore[attr-defined] @property def live(self): diff --git a/judge/models/problem.py b/judge/models/problem.py index aa436724fb..29d46c9793 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -404,7 +404,7 @@ def update_stats(self): self.ac_rate = 0 self.save() - update_stats.alters_data = True + update_stats.alters_data = True # type: ignore[attr-defined] def _get_limits(self, key): global_limit = getattr(self, key) @@ -462,7 +462,7 @@ def save(self, *args, **kwargs): else: problem_data._update_code(self.__original_code, self.code) - save.alters_data = True + save.alters_data = True # type: ignore[attr-defined] def is_solved_by(self, user): # Return true if a full AC submission to the problem from the user exists. diff --git a/judge/models/problem_data.py b/judge/models/problem_data.py index a00016a510..c3caae5bd8 100644 --- a/judge/models/problem_data.py +++ b/judge/models/problem_data.py @@ -72,7 +72,7 @@ def _update_code(self, original, new): if self.generator: self.generator.name = _problem_directory_file(new, self.generator.name) self.save() - _update_code.alters_data = True + _update_code.alters_data = True # type: ignore[attr-defined] class ProblemTestCase(models.Model): diff --git a/judge/models/profile.py b/judge/models/profile.py index 859c30d47f..657bb96ee1 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -144,7 +144,7 @@ class Meta: constraints = [UniqueConstraint(fields=['name'], condition=Q(is_active=True), name='unique_active_name')] -class Profile(models.Model): +class Profile(models.Model): # type: ignore[django-manager-missing] 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=_('time zone'), choices=TIMEZONE, @@ -263,7 +263,7 @@ def calculate_points(self, table=_pp_table): self.save(update_fields=['points', 'problem_count', 'performance_points']) return points - calculate_points.alters_data = True + calculate_points.alters_data = True # type: ignore[attr-defined] def generate_api_token(self): secret = secrets.token_bytes(32) @@ -272,7 +272,7 @@ def generate_api_token(self): token = base64.urlsafe_b64encode(struct.pack('>I32s', self.user.id, secret)) return token.decode('utf-8') - generate_api_token.alters_data = True + generate_api_token.alters_data = True # type: ignore[attr-defined] def generate_scratch_codes(self): def generate_scratch_code(): @@ -282,20 +282,20 @@ def generate_scratch_code(): self.save(update_fields=['scratch_codes']) return codes - generate_scratch_codes.alters_data = True + generate_scratch_codes.alters_data = True # type: ignore[attr-defined] def remove_contest(self): self.current_contest = None self.save() - remove_contest.alters_data = True + remove_contest.alters_data = True # type: ignore[attr-defined] def update_contest(self): contest = self.current_contest if contest is not None and (contest.ended or not contest.contest.is_accessible_by(self.user)): self.remove_contest() - update_contest.alters_data = True + update_contest.alters_data = True # type: ignore[attr-defined] def check_totp_code(self, code): totp = pyotp.TOTP(self.totp_key) @@ -308,7 +308,7 @@ def check_totp_code(self, code): return True return False - check_totp_code.alters_data = True + check_totp_code.alters_data = True # type: ignore[attr-defined] def get_absolute_url(self): return reverse('user_page', args=(self.user.username,)) diff --git a/judge/models/runtime.py b/judge/models/runtime.py index 313cbe79ea..2a9747cbb6 100644 --- a/judge/models/runtime.py +++ b/judge/models/runtime.py @@ -147,14 +147,14 @@ def __str__(self): def disconnect(self, force=False): disconnect_judge(self, force=force) - disconnect.alters_data = True + disconnect.alters_data = True # type: ignore[attr-defined] 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 + toggle_disabled.alters_data = True # type: ignore[attr-defined] @classmethod def runtime_versions(cls): diff --git a/judge/models/submission.py b/judge/models/submission.py index 5aa6c67659..5f63d24f92 100644 --- a/judge/models/submission.py +++ b/judge/models/submission.py @@ -130,12 +130,12 @@ def judge(self, *args, rejudge=False, force_judge=False, rejudge_user=None, **kw revisions.add_to_revision(self) judge_submission(self, *args, rejudge=rejudge, **kwargs) - judge.alters_data = True + judge.alters_data = True # type: ignore[attr-defined] def abort(self): abort_submission(self) - abort.alters_data = True + abort.alters_data = True # type: ignore[attr-defined] def can_see_detail(self, user): if not user.is_authenticated: @@ -183,7 +183,7 @@ def update_contest(self): contest.save() contest.participation.recompute_results() - update_contest.alters_data = True + update_contest.alters_data = True # type: ignore[attr-defined] @property def is_graded(self): diff --git a/judge/models/tests/util.py b/judge/models/tests/util.py index 953308d7c3..5cc27cc3fd 100644 --- a/judge/models/tests/util.py +++ b/judge/models/tests/util.py @@ -1,14 +1,20 @@ +from typing import Dict, Generic, Tuple, Type, TypeVar + from django.contrib.auth.models import AnonymousUser, Permission, User +from django.db import models from django.utils import timezone from judge.models import BlogPost, Contest, ContestParticipation, ContestProblem, ContestTag, Language, Organization, \ Problem, ProblemGroup, ProblemType, Profile, Solution -class CreateModel: - model = None - m2m_fields = {} - required_fields = () +_M = TypeVar('_M', bound=models.Model) + + +class CreateModel(Generic[_M]): + model: Type[_M] + m2m_fields: Dict[str, Tuple[Type[models.Model], str]] = {} + required_fields: Tuple[str, ...] = () def get_defaults(self, required_kwargs, kwargs): return {} @@ -22,7 +28,7 @@ def process_related_objects(self, required_kwargs, defaults): def on_created_object(self, obj): pass - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> _M: # in case the required fields are passed as arguments instead of keyword arguments if len(args) == len(self.required_fields): for field, arg in zip(self.required_fields, args): diff --git a/judge/utils/camo.py b/judge/utils/camo.py index a3c90a591a..94853f4bdd 100644 --- a/judge/utils/camo.py +++ b/judge/utils/camo.py @@ -1,5 +1,6 @@ import hmac from hashlib import sha1 +from typing import Optional from django.conf import settings @@ -40,6 +41,8 @@ def update_tree(self, doc): obj.set('data', self.rewrite_url(obj.get('data'))) +client: Optional[CamoClient] + if settings.DMOJ_CAMO_URL and settings.DMOJ_CAMO_KEY: client = CamoClient(settings.DMOJ_CAMO_URL, key=settings.DMOJ_CAMO_KEY, excluded=settings.DMOJ_CAMO_EXCLUDE, diff --git a/judge/utils/pdfoid.py b/judge/utils/pdfoid.py index 9fd6fc3c0d..229807a68a 100644 --- a/judge/utils/pdfoid.py +++ b/judge/utils/pdfoid.py @@ -25,7 +25,7 @@ def render_pdf(*, title: str, html: str, footer: bool = False) -> bytes: footer_template = None response = requests.post( - PDFOID_URL, + PDFOID_URL, # type: ignore[arg-type] data={ 'html': html, 'title': title, diff --git a/judge/utils/problem_data.py b/judge/utils/problem_data.py index ce59d29adb..4e48ca9747 100644 --- a/judge/utils/problem_data.py +++ b/judge/utils/problem_data.py @@ -10,7 +10,9 @@ from django.utils.translation import gettext as _ if os.altsep: - def split_path_first(path, repath=re.compile('[%s]' % re.escape(os.sep + os.altsep))): + repath = re.compile('[%s]' % re.escape(os.sep + os.altsep)) + + def split_path_first(path): return repath.split(path, 1) else: def split_path_first(path): diff --git a/judge/utils/views.py b/judge/utils/views.py index 6abe796c0e..eb4d301e48 100644 --- a/judge/utils/views.py +++ b/judge/utils/views.py @@ -1,9 +1,14 @@ +from typing import FrozenSet, Optional, TYPE_CHECKING + from django.shortcuts import render from django.views.generic import FormView from django.views.generic.detail import SingleObjectMixin from judge.utils.diggpaginator import DiggPaginator +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise + def generic_message(request, title, message, status=None): return render(request, 'generic-message.html', { @@ -45,8 +50,8 @@ def get_actions(self, request): class TitleMixin(object): - title = '(untitled)' - content_title = None + title: 'StrOrPromise' = '(untitled)' + content_title: Optional['StrOrPromise'] = None def get_context_data(self, **kwargs): context = super(TitleMixin, self).get_context_data(**kwargs) @@ -71,9 +76,9 @@ def get_paginator(self, queryset, per_page, orphans=0, class QueryStringSortMixin(object): - all_sorts = None - default_sort = None - default_desc = () + all_sorts: Optional[FrozenSet[str]] = None + default_sort: Optional[str] = None + default_desc: FrozenSet[str] = frozenset() def get_default_sort_order(self, request): return self.default_sort diff --git a/judge/views/api/api_v2.py b/judge/views/api/api_v2.py index 771647f995..aadf82779b 100644 --- a/judge/views/api/api_v2.py +++ b/judge/views/api/api_v2.py @@ -1,4 +1,5 @@ from operator import attrgetter +from typing import Tuple, Union from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError @@ -129,8 +130,8 @@ def dispatch(self, request, *args, **kwargs): class APIListView(APIMixin, InfinitePaginationMixin, BaseListView): paginate_by = settings.DMOJ_API_PAGE_SIZE - basic_filters = () - list_filters = () + basic_filters: Tuple[Tuple[str, Union[str, BaseSimpleFilter]], ...] = () + list_filters: Tuple[Tuple[str, Union[str, BaseListFilter]], ...] = () @property def use_infinite_pagination(self): diff --git a/judge/views/comment.py b/judge/views/comment.py index 7b2e086a7e..a632376ab7 100644 --- a/judge/views/comment.py +++ b/judge/views/comment.py @@ -1,3 +1,5 @@ +from typing import Optional + from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import PermissionDenied @@ -87,9 +89,9 @@ def downvote_comment(request): class CommentMixin(object): - model = Comment + model: type = Comment pk_url_kwarg = 'id' - context_object_name = 'comment' + context_object_name: Optional[str] = 'comment' def get_object(self, queryset=None): comment = super().get_object(queryset) diff --git a/judge/views/contests.py b/judge/views/contests.py index 5ae7428fe8..88094d862f 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -5,6 +5,7 @@ from functools import partial from itertools import chain from operator import attrgetter, itemgetter +from typing import Optional from django import forms from django.conf import settings @@ -70,7 +71,7 @@ class ContestList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ContestL paginate_by = 20 template_name = 'contest/list.html' title = gettext_lazy('Contests') - context_object_name = 'past_contests' + context_object_name: Optional[str] = 'past_contests' all_sorts = frozenset(('name', 'user_count', 'start_time')) default_desc = frozenset(('name', 'user_count')) default_sort = '-start_time' @@ -157,8 +158,8 @@ def __init__(self, name, is_private, is_organization_private, orgs, classes): class ContestMixin(object): - context_object_name = 'contest' - model = Contest + context_object_name: Optional[str] = 'contest' + model: type = Contest slug_field = 'key' slug_url_kwarg = 'contest' @@ -715,7 +716,7 @@ def contest_ranking_ajax(request, contest, participation=None): class ContestRankingBase(ContestMixin, TitleMixin, DetailView): template_name = 'contest/ranking.html' - tab = None + tab: Optional[str] = None def get_title(self): raise NotImplementedError() @@ -876,7 +877,7 @@ def post(self, request, *args, **kwargs): class ContestTagDetailAjax(DetailView): model = ContestTag slug_field = slug_url_kwarg = 'name' - context_object_name = 'tag' + context_object_name: Optional[str] = 'tag' template_name = 'contest/tag-ajax.html' diff --git a/judge/views/organization.py b/judge/views/organization.py index dcf0a50746..1e187a8bc7 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -1,4 +1,5 @@ from operator import attrgetter +from typing import Optional, Type from django import forms from django.conf import settings @@ -36,8 +37,8 @@ def users_for_template(users, order): class OrganizationMixin(object): - context_object_name = 'organization' - model = Organization + context_object_name: Optional[str] = 'organization' + model: type = Organization def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -66,8 +67,8 @@ def can_edit_organization(self, org=None): class BaseOrganizationListView(OrganizationMixin, ListView): - model = None - context_object_name = None + model: Optional[type] = None # type: ignore[assignment] + context_object_name: Optional[str] = None slug_url_kwarg = 'slug' def get_object(self): @@ -125,6 +126,7 @@ def get_context_data(self, **kwargs): class OrganizationUsers(QueryStringSortMixin, DiggPaginatorMixin, BaseOrganizationListView): + model: Optional[type] template_name = 'organization/users.html' all_sorts = frozenset(('problem_count', 'rating', 'performance_points')) default_desc = all_sorts @@ -197,9 +199,10 @@ class OrganizationRequestForm(Form): def __init__(self, *args, class_required: bool, class_queryset, **kwargs) -> None: super().__init__(*args, **kwargs) + assert isinstance(self.fields['class_'], forms.ModelChoiceField) self.fields['class_'].required = class_required self.fields['class_'].queryset = class_queryset - self.fields['class_'].label_from_instance = attrgetter('name') + self.fields['class_'].label_from_instance = attrgetter('name') # type: ignore[method-assign] self.show_classes = class_required or bool(class_queryset) @@ -258,14 +261,15 @@ def get_object(self, queryset=None): return object -OrganizationRequestFormSet = modelformset_factory(OrganizationRequest, extra=0, fields=('state',), can_delete=True) +OrganizationRequestFormSet: Type[forms.BaseModelFormSet[OrganizationRequest, forms.ModelForm]] = \ + modelformset_factory(OrganizationRequest, extra=0, fields=('state',), can_delete=True) class OrganizationRequestBaseView(LoginRequiredMixin, SingleObjectTemplateResponseMixin, SingleObjectMixin, View): model = Organization slug_field = 'key' slug_url_kwarg = 'key' - tab = None + tab: Optional[str] = None def get_object(self, queryset=None): organization = super(OrganizationRequestBaseView, self).get_object(queryset) diff --git a/judge/views/problem.py b/judge/views/problem.py index 3fb2365f47..0525584b2a 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -56,7 +56,7 @@ def get_contest_submission_count(problem, profile, virtual): class ProblemMixin(object): - model = Problem + model: type = Problem slug_url_kwarg = 'problem' slug_field = 'code' diff --git a/judge/views/problem_data.py b/judge/views/problem_data.py index c3ef2b42f5..e8783207ff 100644 --- a/judge/views/problem_data.py +++ b/judge/views/problem_data.py @@ -74,8 +74,8 @@ class Meta: } -class ProblemCaseFormSet(formset_factory(ProblemCaseForm, formset=BaseModelFormSet, extra=1, max_num=1, - can_delete=True)): +class ProblemCaseFormSet(formset_factory(ProblemCaseForm, formset=BaseModelFormSet, extra=1, # type: ignore[misc] + max_num=1, can_delete=True)): model = ProblemTestCase def __init__(self, *args, **kwargs): diff --git a/judge/views/submission.py b/judge/views/submission.py index d62243788a..45d8d4ccfc 100644 --- a/judge/views/submission.py +++ b/judge/views/submission.py @@ -2,6 +2,7 @@ from collections import namedtuple from itertools import groupby from operator import attrgetter +from typing import Optional from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin @@ -45,8 +46,8 @@ def __init__(self, submission): class SubmissionMixin(object): - model = Submission - context_object_name = 'submission' + model: type = Submission + context_object_name: Optional[str] = 'submission' pk_url_kwarg = 'submission' @@ -576,11 +577,11 @@ def _get_result_data(self, queryset=None): class ForceContestMixin(object): - @property + @cached_property def in_contest(self): return True - @property + @cached_property def contest(self): return self._contest diff --git a/judge/views/tasks.py b/judge/views/tasks.py index 42e123fbc3..c629d581d3 100644 --- a/judge/views/tasks.py +++ b/judge/views/tasks.py @@ -7,7 +7,7 @@ from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import render from django.urls import reverse -from django.utils.http import is_safe_url +from django.utils.http import url_has_allowed_host_and_scheme from judge.tasks import failure, progress, success from judge.utils.celery import redirect_to_task_status @@ -34,7 +34,7 @@ def task_status(request, task_id): raise Http404() redirect = request.GET.get('redirect') - if not is_safe_url(redirect, allowed_hosts={request.get_host()}): + if not url_has_allowed_host_and_scheme(redirect, allowed_hosts={request.get_host()}): redirect = None status = get_task_status(task_id) diff --git a/judge/views/ticket.py b/judge/views/ticket.py index eec3d45b43..84a5aedbac 100644 --- a/judge/views/ticket.py +++ b/judge/views/ticket.py @@ -24,9 +24,11 @@ from judge.views.problem import ProblemMixin from judge.widgets import HeavyPreviewPageDownWidget -ticket_widget = (forms.Textarea() if HeavyPreviewPageDownWidget is None else - HeavyPreviewPageDownWidget(preview=reverse_lazy('ticket_preview'), - preview_timeout=1000, hide_preview_button=True)) +if HeavyPreviewPageDownWidget is None: + ticket_widget = forms.Textarea() +else: + ticket_widget = HeavyPreviewPageDownWidget(preview=reverse_lazy('ticket_preview'), + preview_timeout=1000, hide_preview_button=True) class TicketForm(forms.Form): @@ -106,7 +108,7 @@ class TicketCommentForm(forms.Form): class TicketMixin(LoginRequiredMixin): - model = Ticket + model: type = Ticket def get_object(self, queryset=None): ticket = super(TicketMixin, self).get_object(queryset) diff --git a/judge/views/two_factor.py b/judge/views/two_factor.py index 5c6235401b..9f2571fb28 100644 --- a/judge/views/two_factor.py +++ b/judge/views/two_factor.py @@ -11,7 +11,7 @@ from django.contrib.auth.views import SuccessURLAllowedHostsMixin from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.urls import reverse -from django.utils.http import is_safe_url +from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext as _, gettext_lazy from django.views.generic import FormView, View from django.views.generic.base import ContextMixin @@ -244,7 +244,7 @@ def check_skip(self): def next_page(self): redirect_to = self.request.GET.get('next', '') - url_is_safe = is_safe_url( + url_is_safe = url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts=self.get_success_url_allowed_hosts(), require_https=self.request.is_secure(), diff --git a/judge/views/user.py b/judge/views/user.py index 6a3791b183..c968b36f75 100644 --- a/judge/views/user.py +++ b/judge/views/user.py @@ -3,6 +3,7 @@ import os from datetime import datetime from operator import attrgetter, itemgetter +from typing import Optional from django.conf import settings from django.contrib.auth import logout as auth_logout @@ -51,10 +52,10 @@ def remap_keys(iterable, mapping): class UserMixin(object): - model = Profile + model: type = Profile slug_field = 'user__username' slug_url_kwarg = 'user' - context_object_name = 'user' + context_object_name: Optional[str] = 'user' def render_to_response(self, context, **response_kwargs): return super(UserMixin, self).render_to_response(context, **response_kwargs) diff --git a/judge/widgets/pagedown.py b/judge/widgets/pagedown.py index dc07359215..cf1fe03943 100644 --- a/judge/widgets/pagedown.py +++ b/judge/widgets/pagedown.py @@ -1,3 +1,5 @@ +from typing import Optional + from django.forms.utils import flatatt from django.template.loader import get_template from django.utils.encoding import force_str @@ -7,6 +9,10 @@ __all__ = ['PagedownWidget', 'MathJaxPagedownWidget', 'HeavyPreviewPageDownWidget'] +PagedownWidget: Optional[type] +MathJaxPagedownWidget: Optional[type] +HeavyPreviewPageDownWidget: Optional[type] + try: from pagedown.widgets import PagedownWidget as OldPagedownWidget except ImportError: @@ -14,7 +20,7 @@ MathJaxPagedownWidget = None HeavyPreviewPageDownWidget = None else: - class PagedownWidget(CompressorWidgetMixin, OldPagedownWidget): + class NewPagedownWidget(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 create multiple large JS files to download. @@ -26,7 +32,7 @@ def __init__(self, *args, **kwargs): super(PagedownWidget, self).__init__(*args, **kwargs) - class MathJaxPagedownWidget(PagedownWidget): + class NewMathJaxPagedownWidget(NewPagedownWidget): class Media: js = [ 'mathjax_config.js', @@ -35,7 +41,7 @@ class Media: ] - class HeavyPreviewPageDownWidget(PagedownWidget): + class NewHeavyPreviewPageDownWidget(NewPagedownWidget): def __init__(self, *args, **kwargs): kwargs.setdefault('template', 'pagedown.html') self.preview_url = kwargs.pop('preview') @@ -65,3 +71,8 @@ def get_template_context(self, attrs, value): class Media: js = ['dmmd-preview.js'] + + + PagedownWidget = NewPagedownWidget + MathJaxPagedownWidget = NewMathJaxPagedownWidget + HeavyPreviewPageDownWidget = NewHeavyPreviewPageDownWidget diff --git a/judge/widgets/select2.py b/judge/widgets/select2.py index 8ae0659851..164ded62f4 100644 --- a/judge/widgets/select2.py +++ b/judge/widgets/select2.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="misc" # -*- coding: utf-8 -*- """ Select2 Widgets based on https://github.com/applegrew/django-select2. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000000..61776d4596 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +ignore_missing_imports = True +plugins = mypy_django_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = "dmoj.settings" +strict_settings = false diff --git a/requirements-mypy.txt b/requirements-mypy.txt new file mode 100644 index 0000000000..07b95820ed --- /dev/null +++ b/requirements-mypy.txt @@ -0,0 +1,8 @@ +mypy +celery-stubs +django-stubs +git+https://github.com/lxml/lxml-stubs.git +types-PyMySQL +types-Pygments +types-bleach +types-requests diff --git a/requirements.txt b/requirements.txt index db098fab3d..c2a78fcb66 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,3 +40,4 @@ django-admin-sortable2<2 icalendar # This is a celery dependency whose latest major version is breaking everything. importlib-metadata<5 +django-stubs-ext