diff --git a/.gitignore b/.gitignore index d8bbde7cabbd7f8ac74f54107fa7e9cfdfad4c0f..1121f3512393b40c65861e87e5f3b5d0631af387 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ saml_config.py *.pem git-roots + +.mypy_cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9406b9afe6d479af6a68be5265bd36c54ce2c6fe..cc1bc36c19aacdb9cc6251e18db060520dd6147f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,9 @@ - python2.7 except: - tags + coverage: '/TOTAL.+ ([0-9]{1,3}%)/' + variables: + CODECOV_TOKEN: "895e3bf2-cfd0-45f8-9a14-4b7bd148f76d" Python 3.5: script: @@ -13,6 +16,9 @@ Python 3.5: - python3.5 except: - tags + coverage: '/TOTAL.+ ([0-9]{1,3}%)/' + variables: + CODECOV_TOKEN: "895e3bf2-cfd0-45f8-9a14-4b7bd148f76d" Documentation: script: @@ -28,9 +34,9 @@ Mypy: script: - curl -L -O -k https://gitlab.tiker.net/inducer/ci-support/raw/master/prepare-and-run-mypy.sh - "cp local_settings.example.py local_settings.py" - - ". ./prepare-and-run-mypy.sh" + - ". ./prepare-and-run-mypy.sh python3.6 mypy==0.521 typed-ast==1.0.4" tags: - - python3.5 + - python3.6 except: - tags diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000000000000000000000000000000000..66dc043ef5c650218fffa68532ca098ffdd89332 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +cache: pip +install: true +matrix: + include: + - python: "2.7" + env: PY=true PY_EXE=python2.7 + - python: "3.5" + env: PY=true PY_EXE=python3.5 + - python: "3.5" + env: Flake8=true PY_EXE=python3.5 + - python: "3.6" + env: Mypy=true PY_EXE=python3.6 +script: + - bash ./run-travis-ci.sh diff --git a/README.rst b/README.rst index e44fa5c0ae8991ac7c41822525c0ddae3a8ebc59..72e7c4654dc9f939864e8032c3e29bd495fab8af 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,15 @@ RELATE Relate is an Environment for Learning And TEaching +.. image:: https://travis-ci.org/inducer/relate.svg?branch=master + :target: https://travis-ci.org/inducer/relate + +.. image:: https://ci.appveyor.com/api/projects/status/d5bigdw90bxnfdgy?svg=true + :target: https://ci.appveyor.com/project/inducer/relate + +.. image:: https://codecov.io/gh/inducer/relate/branch/master/graph/badge.svg + :target: https://codecov.io/gh/inducer/relate/commits + +----------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------+ | .. image:: https://raw.githubusercontent.com/inducer/relate/master/doc/images/screenshot.png | .. image:: https://raw.githubusercontent.com/inducer/relate/master/doc/images/screenshot-2.png | +----------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------+ diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000000000000000000000000000000000000..db90d58940938f34bcafd2ff5fa0427326859fff --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,72 @@ +# +# Most of these settings are taken from here: +# https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor.yml +# + +version: 1.0.{build} + +build: off + +cache: + - '%LOCALAPPDATA%\pip\Cache' + +environment: + global: + # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the + # /E:ON and /V:ON options are not enabled in the batch script intepreter + # See: http://stackoverflow.com/a/13751649/163740 + CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\appveyor\\run_with_env.cmd" + + matrix: + # Pre-installed Python versions, which Appveyor may upgrade to + # a later point release. + # See: http://www.appveyor.com/docs/installed-software#python + + - PYTHON: "C:\\Python27-x64" + PYTHON_VERSION: "2.7.x" # currently 2.7.13 + PYTHON_ARCH: "64" + + - PYTHON: "C:\\Python35-x64" + PYTHON_VERSION: "3.5.x" # currently 3.5.3 + PYTHON_ARCH: "64" + +init: + - "ECHO %PYTHON%" + - ps: "ls C:/Python*" + +install: + # Prepend newly installed Python to the PATH of this build (this cannot be + # done from inside the powershell script as it would require to restart + # the parent CMD process). + - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" + + - ECHO "Filesystem root:" + - ps: "ls \"C:/\"" + + # Check that we have the expected version and architecture for Python + - "python --version" + - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" + + # Upgrade to the latest version of pip to avoid it displaying warnings + # about it being out of date. + - "pip install --disable-pip-version-check --user --upgrade pip" + + - "git submodule update --init --recursive" + + # Install requirements + - ps: "cat requirements.txt | ?{$_ -notmatch 'dnspython'} > req.txt" + - "%CMD_IN_ENV% pip install -r req.txt" + - ps: "if ($env:PYTHON -like '*Python2*') { pip install dnspython mock} else { pip install dnspython3 }" + + +before_test: + - "%CMD_IN_ENV% pip install coverage" + +test_script: + # Run the project tests + - "copy local_settings.example.py local_settings.py /Y" + - "%CMD_IN_ENV% coverage run manage.py test test/" + +after_test: + - "%CMD_IN_ENV% pip install codecov" + - "%CMD_IN_ENV% codecov -X gcov" diff --git a/appveyor/run_with_env.cmd b/appveyor/run_with_env.cmd new file mode 100644 index 0000000000000000000000000000000000000000..5da547c499eea5b8b91ab7f29d6752a376148a8a --- /dev/null +++ b/appveyor/run_with_env.cmd @@ -0,0 +1,88 @@ +:: To build extensions for 64 bit Python 3, we need to configure environment +:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) +:: +:: To build extensions for 64 bit Python 2, we need to configure environment +:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) +:: +:: 32 bit builds, and 64-bit builds for 3.5 and beyond, do not require specific +:: environment configurations. +:: +:: Note: this script needs to be run with the /E:ON and /V:ON flags for the +:: cmd interpreter, at least for (SDK v7.0) +:: +:: More details at: +:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows +:: http://stackoverflow.com/a/13751649/163740 +:: +:: Author: Olivier Grisel +:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ +:: +:: Notes about batch files for Python people: +:: +:: Quotes in values are literally part of the values: +:: SET FOO="bar" +:: FOO is now five characters long: " b a r " +:: If you don't want quotes, don't include them on the right-hand side. +:: +:: The CALL lines at the end of this file look redundant, but if you move them +:: outside of the IF clauses, they do not run properly in the SET_SDK_64==Y +:: case, I don't know why. +@ECHO OFF + +SET COMMAND_TO_RUN=%* +SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows +SET WIN_WDK=c:\Program Files (x86)\Windows Kits\10\Include\wdf + +:: Extract the major and minor versions, and allow for the minor version to be +:: more than 9. This requires the version number to have two dots in it. +SET MAJOR_PYTHON_VERSION=%PYTHON_VERSION:~0,1% +IF "%PYTHON_VERSION:~3,1%" == "." ( + SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,1% +) ELSE ( + SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,2% +) + +:: Based on the Python version, determine what SDK version to use, and whether +:: to set the SDK for 64-bit. +IF %MAJOR_PYTHON_VERSION% == 2 ( + SET WINDOWS_SDK_VERSION="v7.0" + SET SET_SDK_64=Y +) ELSE ( + IF %MAJOR_PYTHON_VERSION% == 3 ( + SET WINDOWS_SDK_VERSION="v7.1" + IF %MINOR_PYTHON_VERSION% LEQ 4 ( + SET SET_SDK_64=Y + ) ELSE ( + SET SET_SDK_64=N + IF EXIST "%WIN_WDK%" ( + :: See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ + REN "%WIN_WDK%" 0wdf + ) + ) + ) ELSE ( + ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" + EXIT 1 + ) +) + +IF %PYTHON_ARCH% == 64 ( + IF %SET_SDK_64% == Y ( + ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture + SET DISTUTILS_USE_SDK=1 + SET MSSdk=1 + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 + ) ELSE ( + ECHO Using default MSVC build environment for 64 bit architecture + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 + ) +) ELSE ( + ECHO Using default MSVC build environment for 32 bit architecture + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) diff --git a/course/apps.py b/course/apps.py index 58f78b149194b87a83c9d41cb45ac2bfa2dece17..6e4d4511d02fc771faf3ef79947c312bb47f8939 100644 --- a/course/apps.py +++ b/course/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig from django.utils.translation import ugettext_lazy as _ +from course.checks import register_startup_checks_extra, register_startup_checks class CourseConfig(AppConfig): @@ -9,3 +10,7 @@ class CourseConfig(AppConfig): def ready(self): import course.receivers # noqa + + # register all checks + register_startup_checks() + register_startup_checks_extra() diff --git a/course/auth.py b/course/auth.py index 1dd295fc9fcce26635d6507ccd6cb6bec9cbcd85..fadb73c3c00caa119366c6b1353fefe27be8eb4e 100644 --- a/course/auth.py +++ b/course/auth.py @@ -912,7 +912,11 @@ class UserForm(StyledModelForm): Submit("submit_user", _("Update"))) def clean_institutional_id(self): - inst_id = self.cleaned_data['institutional_id'].strip() + inst_id = self.cleaned_data['institutional_id'] + + if inst_id is not None: + inst_id = inst_id.strip() + if self.is_inst_id_locked: # Disabled fields are not part of form submit--so simply # assume old value. At the same time, prevent smuggled-in diff --git a/course/calendar.py b/course/calendar.py index bb838fed7c603d7dd295d235d18ca82ba6c87e0b..faa0f7d5c8145ec83229b7a4495593e891568519 100644 --- a/course/calendar.py +++ b/course/calendar.py @@ -411,6 +411,7 @@ def view_calendar(pctx): "events_json": dumps(events_json), "event_info_list": event_info_list, "default_date": default_date.isoformat(), + "edit_view": False }) @@ -418,9 +419,9 @@ class EditEventForm(StyledModelForm): class Meta: model = Event fields = ['kind', 'ordinal', 'time', - 'end_time', 'all_day', 'shown_in_calendar'] + 'end_time', 'all_day', 'shown_in_calendar'] help_texts = { - 'shown_in_calendar': ('Shown in students\' calendar') + 'shown_in_calendar': ('Shown in students\' calendar') } def __init__(self, *args, **kwargs): @@ -432,14 +433,13 @@ class EditEventForm(StyledModelForm): @login_required @course_view def edit_calendar(pctx): + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) + from course.content import markup_to_html, parse_date_spec from course.views import get_now_or_fake_time now = get_now_or_fake_time(pctx.request) - - if not pctx.has_permission(pperm.edit_events): - raise PermissionDenied(_("may not edit events")) - request = pctx.request edit_existing_event_flag = False @@ -452,7 +452,8 @@ def edit_calendar(pctx): event_to_delete = get_object_or_404(Event, id=request.POST['id_to_delete']) default_date = event_to_delete.time - event_to_delete.delete() + with transaction.atomic(): + event_to_delete.delete() messages.add_message(request, messages.SUCCESS, _("Event deleted.")) @@ -475,16 +476,25 @@ def edit_calendar(pctx): kind = form_event.cleaned_data['kind'] ordinal = form_event.cleaned_data['ordinal'] try: - form_event.save() + with transaction.atomic(): + form_event.save() except IntegrityError: + if ordinal is not None: + ordinal = str(int(ordinal)) + else: + ordinal = _("(no ordinal)") e = EventAlreadyExists( - _("'%(event_kind)s %(event_ordinal)d' already exists") % - {'event_kind': kind, - 'event_ordinal': ordinal}) + _("'%(event_kind)s %(event_ordinal)s' already exists") + % {'event_kind': kind, + 'event_ordinal': ordinal}) + if 'existing_event_to_save' in request.POST: + msg = _("Event not updated.") + else: + msg = _("No event created.") messages.add_message(request, messages.ERROR, string_concat( "%(err_type)s: %(err_str)s. ", - _("No event created.")) + msg) % { "err_type": type(e).__name__, "err_str": str(e)}) @@ -609,13 +619,14 @@ def edit_calendar(pctx): default_date = pctx.course.end_date from json import dumps - return render_course_page(pctx, "course/calender_edit.html", { + return render_course_page(pctx, "course/calendar.html", { "form": edit_event_form, "events_json": dumps(events_json), "event_info_list": event_info_list, "default_date": default_date.isoformat(), "edit_existing_event_flag": edit_existing_event_flag, "id_to_edit": id_to_edit, + "edit_view": True }) # }}} diff --git a/course/checks.py b/course/checks.py new file mode 100644 index 0000000000000000000000000000000000000000..a4b59f2e8b2b3820e0ac448a23bad0e8370e9260 --- /dev/null +++ b/course/checks.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import six +from django.conf import settings +from django.core.checks import Critical, Warning, register +from django.core.exceptions import ImproperlyConfigured + +REQUIRED_CONF_ERROR_PATTERN = ( + "You must configure %(location)s for RELATE to run properly.") +INSTANCE_ERROR_PATTERN = "%(location)s must be an instance of %(types)s." +GENERIC_ERROR_PATTERN = "Error in '%(location)s': %(error_type)s: %(error_str)s" + +EMAIL_CONNECTIONS = "EMAIL_CONNECTIONS" +RELATE_BASE_URL = "RELATE_BASE_URL" +RELATE_EMAIL_APPELATION_PRIORITY_LIST = "RELATE_EMAIL_APPELATION_PRIORITY_LIST" +RELATE_FACILITIES = "RELATE_FACILITIES" +RELATE_MAINTENANCE_MODE_EXCEPTIONS = "RELATE_MAINTENANCE_MODE_EXCEPTIONS" +RELATE_SESSION_RESTART_COOLDOWN_SECONDS = "RELATE_SESSION_RESTART_COOLDOWN_SECONDS" +RELATE_TICKET_MINUTES_VALID_AFTER_USE = "RELATE_TICKET_MINUTES_VALID_AFTER_USE" +GIT_ROOT = "GIT_ROOT" +RELATE_STARTUP_CHECKS = "RELATE_STARTUP_CHECKS" +RELATE_STARTUP_CHECKS_EXTRA = "RELATE_STARTUP_CHECKS_EXTRA" + +RELATE_STARTUP_CHECKS_TAG = "start_up_check" +RELATE_STARTUP_CHECKS_EXTRA_TAG = "startup_checks_extra" + + +class RelateCriticalCheckMessage(Critical): + def __init__(self, *args, **kwargs): + super(RelateCriticalCheckMessage, self).__init__(*args, **kwargs) + if not self.obj: + self.obj = ImproperlyConfigured.__name__ + + +class DeprecatedException(Exception): + pass + + +def get_ip_network(ip_range): + import ipaddress + return ipaddress.ip_network(six.text_type(ip_range)) + + +def check_relate_settings(app_configs, **kwargs): + errors = [] + + # {{{ check RELATE_BASE_URL + relate_base_url = getattr(settings, RELATE_BASE_URL, None) + if relate_base_url is None: + errors.append(RelateCriticalCheckMessage( + msg=REQUIRED_CONF_ERROR_PATTERN % {"location": RELATE_BASE_URL}, + id="relate_base_url.E001" + )) + elif not isinstance(relate_base_url, str): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_BASE_URL, "types": "str"}), + id="relate_base_url.E002" + )) + elif not relate_base_url.strip(): + errors.append(RelateCriticalCheckMessage( + msg="%(location)s should not be an empty string" + % {"location": RELATE_BASE_URL}, + id="relate_base_url.E003" + )) + # }}} + + # {{{ check RELATE_EMAIL_APPELATION_PRIORITY_LIST + relate_email_appelation_priority_list = getattr( + settings, RELATE_EMAIL_APPELATION_PRIORITY_LIST, None) + if relate_email_appelation_priority_list is not None: + if not isinstance(relate_email_appelation_priority_list, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": RELATE_EMAIL_APPELATION_PRIORITY_LIST, + "types": "list or tuple"}), + id="relate_email_appelation_priority_list.E002") + ) + # }}} + + # {{{ check EMAIL_CONNECTIONS + email_connections = getattr(settings, EMAIL_CONNECTIONS, None) + if email_connections is not None: + if not isinstance(email_connections, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": EMAIL_CONNECTIONS, + "types": "dict"}), + id="email_connections.E001" + )) + else: + for label, c in six.iteritems(email_connections): + if not isinstance(c, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": "'%s' in '%s'" + % (label, EMAIL_CONNECTIONS), + "types": "dict"}), + id="email_connections.E002" + )) + else: + if "backend" in c: + from django.utils.module_loading import import_string + try: + import_string(c["backend"]) + except ImportError as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": + "'%s' in %s" + % (label, RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="email_connections.E003") + ) + # }}} + + # {{{ check RELATE_FACILITIES + + relate_facilities_conf = getattr(settings, RELATE_FACILITIES, None) + if relate_facilities_conf is not None: + from course.utils import get_facilities_config + try: + facilities = get_facilities_config() + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": RELATE_FACILITIES, + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_facilities.E001") + ) + else: + if not isinstance(facilities, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + "'%(location)s' must either be or return a dictionary" + % {"location": RELATE_FACILITIES}), + id="relate_facilities.E002") + ) + else: + for facility, conf in six.iteritems(facilities): + if not isinstance(conf, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": + "Facility `%s` in %s" + % (facility, RELATE_FACILITIES), + "types": "dict"}), + id="relate_facilities.E003") + ) + else: + ip_ranges = conf.get("ip_ranges", []) + if ip_ranges: + if not isinstance(ip_ranges, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": + "'ip_ranges' in facility `%s` in %s" + % (facilities, RELATE_FACILITIES), + "types": "list or tuple"}), + id="relate_facilities.E004") + ) + else: + for ip_range in ip_ranges: + try: + get_ip_network(ip_range) + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": + "'ip_ranges' in " + "facility `%s` in %s" + % (facility, + RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_facilities.E005") + ) + else: + if not callable(relate_facilities_conf): + errors.append(Warning( + msg=( + "Faclity `%s` in %s is an open facility " + "as it has no configured `ip_ranges`" + % (facility, RELATE_FACILITIES) + ), + id="relate_facilities.W001" + )) + + # }}} + + # {{{ check RELATE_MAINTENANCE_MODE_EXCEPTIONS + relate_maintenance_mode_exceptions = getattr( + settings, RELATE_MAINTENANCE_MODE_EXCEPTIONS, None) + if relate_maintenance_mode_exceptions is not None: + if not isinstance(relate_maintenance_mode_exceptions, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_MAINTENANCE_MODE_EXCEPTIONS, + "types": "list or tuple"}), + id="relate_maintenance_mode_exceptions.E001") + ) + else: + for ip in relate_maintenance_mode_exceptions: + try: + get_ip_network(ip) + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % {"location": + "ip/ip_ranges '%s' in %s" + % (ip, RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_maintenance_mode_exceptions.E002") + ) + # }}} + + # {{{ check RELATE_SESSION_RESTART_COOLDOWN_SECONDS + relate_session_restart_cooldown_seconds = getattr( + settings, RELATE_SESSION_RESTART_COOLDOWN_SECONDS, None) + if relate_session_restart_cooldown_seconds is not None: + if not isinstance(relate_session_restart_cooldown_seconds, (int, float)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_SESSION_RESTART_COOLDOWN_SECONDS, + "types": "int or float"}), + id="relate_session_restart_cooldown_seconds.E001") + ) + else: + if relate_session_restart_cooldown_seconds < 0: + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s must be a positive number, " + "got %(value)s instead" + % {"location": RELATE_SESSION_RESTART_COOLDOWN_SECONDS, + "value": relate_session_restart_cooldown_seconds}), + id="relate_session_restart_cooldown_seconds.E002") + ) + + # }}} + + # {{{ check RELATE_SESSION_RESTART_COOLDOWN_SECONDS + relate_ticket_minutes_valid_after_use = getattr( + settings, RELATE_TICKET_MINUTES_VALID_AFTER_USE, None) + if relate_ticket_minutes_valid_after_use is not None: + if not isinstance(relate_ticket_minutes_valid_after_use, (int, float)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_TICKET_MINUTES_VALID_AFTER_USE, + "types": "int or float"}), + id="relate_ticket_minutes_valid_after_use.E001") + ) + else: + if relate_ticket_minutes_valid_after_use < 0: + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s must be a positive number, " + "got %(value)s instead" + % {"location": RELATE_TICKET_MINUTES_VALID_AFTER_USE, + "value": relate_ticket_minutes_valid_after_use}), + id="relate_ticket_minutes_valid_after_use.E002") + ) + + # }}} + + # {{{ check GIT_ROOT + git_root = getattr(settings, GIT_ROOT, None) + if git_root is None: + errors.append(RelateCriticalCheckMessage( + msg=REQUIRED_CONF_ERROR_PATTERN % {"location": GIT_ROOT}, + id="git_root.E001" + )) + elif not isinstance(git_root, str): + errors.append(RelateCriticalCheckMessage( + msg=INSTANCE_ERROR_PATTERN % {"location": GIT_ROOT, "types": "str"}, + id="git_root.E002" + )) + else: + import os + if not os.path.isdir(git_root): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not a valid path" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E003" + )) + else: + if not os.access(git_root, os.W_OK): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not writable " + "by RELATE" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E004" + )) + if not os.access(git_root, os.R_OK): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not readable " + "by RELATE" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E005" + )) + + # }}} + + return errors + + +def register_startup_checks(): + register(check_relate_settings, RELATE_STARTUP_CHECKS_TAG) + + +def register_startup_checks_extra(): + """ + Register extra checks provided by user. + Here we will have to raise error for Exceptions, as that can not be done + via check: all checks, including check_relate_settings, will only be + executed after self.ready() is done. + """ + startup_checks_extra = getattr(settings, RELATE_STARTUP_CHECKS_EXTRA, None) + if startup_checks_extra: + if not isinstance(startup_checks_extra, (list, tuple)): + raise ImproperlyConfigured( + INSTANCE_ERROR_PATTERN + % {"location": RELATE_STARTUP_CHECKS_EXTRA, + "types": "list or tuple" + } + ) + from django.utils.module_loading import import_string + for c in startup_checks_extra: + try: + check_item = import_string(c) + except Exception as e: + raise ImproperlyConfigured( + GENERIC_ERROR_PATTERN + % { + "location": RELATE_STARTUP_CHECKS_EXTRA, + "error_type": type(e).__name__, + "error_str": str(e) + }) + else: + register(check_item, RELATE_STARTUP_CHECKS_EXTRA_TAG) + +# vim: foldmethod=marker diff --git a/course/constants.py b/course/constants.py index aea63ae0a79fd99a125cae343a42ffa084dad18c..f418746b796327abd60311e4fbd1040f3fcd62bf 100644 --- a/course/constants.py +++ b/course/constants.py @@ -24,6 +24,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +if False: + import typing # noqa from django.utils.translation import pgettext_lazy, ugettext # Allow 10x extra credit at the very most. @@ -34,7 +36,7 @@ COURSE_ID_REGEX = "(?P[-a-zA-Z0-9]+)" FLOW_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" GRADING_OPP_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" # FIXME : Support page hierarchy. Add '/' here, fix validation code. -STATICPAGE_PATH_REGEX = "(?P[-\w]+)" +STATICPAGE_PATH_REGEX = r"(?P[-\w]+)" class user_status: # noqa @@ -286,7 +288,7 @@ FLOW_SESSION_EXPIRATION_MODE_CHOICES = ( def is_expiration_mode_allowed(expmode, permissions): - # type: (str, frozenset[str]) -> bool + # type: (str, typing.FrozenSet[str]) -> bool if expmode == flow_session_expiration_mode.roll_over: if (flow_permission.set_roll_over_expiration_mode in permissions): diff --git a/course/content.py b/course/content.py index 7c215db0f0895cb13e0bfd0c48eef37777dceffe..7ab613bd2c4a64f30f4eebb5f3b66af694d26ae2 100644 --- a/course/content.py +++ b/course/content.py @@ -62,7 +62,7 @@ else: if False: # for mypy from typing import ( # noqa - Any, List, Tuple, Optional, Callable, Text, Dict) + Any, List, Tuple, Optional, Callable, Text, Dict, FrozenSet) from course.models import Course, Participation # noqa import dulwich # noqa from course.validation import ValidationContext # noqa @@ -300,6 +300,7 @@ def get_repo_blob_data_cached(repo, full_name, commit_sha): except ImproperlyConfigured: cache_key = None + result = None # type: Optional[bytes] if cache_key is None: result = get_repo_blob(repo, full_name, commit_sha, allow_tree=False).data @@ -312,17 +313,18 @@ def get_repo_blob_data_cached(repo, full_name, commit_sha): def_cache = cache.caches["default"] - result = None # Memcache is apparently limited to 250 characters. if len(cache_key) < 240: - result = def_cache.get(cache_key) - if result is not None: - (result,) = result - assert isinstance(result, six.binary_type), cache_key - return result + cached_result = def_cache.get(cache_key) + + if cached_result is not None: + (result,) = cached_result + assert isinstance(result, six.binary_type), cache_key + return result result = get_repo_blob(repo, full_name, commit_sha, allow_tree=False).data + assert result is not None if len(result) <= getattr(settings, "RELATE_CACHE_MAX_BYTES", 0): def_cache.add(cache_key, (result,), None) @@ -384,7 +386,7 @@ YAML_BLOCK_START_SCALAR_RE = re.compile( r"(:\s*[|>])" "(J?)" "((?:[0-9][-+]?|[-+][0-9]?)?)" - "(?:\s*\#.*)?" + r"(?:\s*\#.*)?" "$") GROUP_COMMENT_START = re.compile(r"^\s*#\s*\{\{\{") @@ -561,24 +563,29 @@ def get_raw_yaml_from_repo(repo, full_name, commit_sha): import django.core.cache as cache def_cache = cache.caches["default"] - result = None + + result = None # type: Optional[Any] # Memcache is apparently limited to 250 characters. if len(cache_key) < 240: result = def_cache.get(cache_key) if result is not None: return result - result = load_yaml( - expand_yaml_macros( + yaml_str = expand_yaml_macros( repo, commit_sha, get_repo_blob(repo, full_name, commit_sha, - allow_tree=False).data)) + allow_tree=False).data) + + result = load_yaml(yaml_str) # type: ignore def_cache.add(cache_key, result, None) return result +LINE_HAS_INDENTING_TABS_RE = re.compile("^\s*\t\s*", re.MULTILINE) + + def get_yaml_from_repo(repo, full_name, commit_sha, cached=True): # type: (Repo_ish, Text, bytes, bool) -> Any @@ -605,12 +612,19 @@ def get_yaml_from_repo(repo, full_name, commit_sha, cached=True): if result is not None: return result + yaml_bytestream = get_repo_blob( + repo, full_name, commit_sha, allow_tree=False).data + yaml_text = yaml_bytestream.decode("utf-8") + + if LINE_HAS_INDENTING_TABS_RE.search(yaml_text): + raise ValueError("File uses tabs in indentation. " + "This is not allowed.") + expanded = expand_yaml_macros( - repo, commit_sha, - get_repo_blob(repo, full_name, commit_sha, - allow_tree=False).data) + repo, commit_sha, yaml_bytestream) - result = dict_to_struct(load_yaml(expanded)) + yaml_data = load_yaml(expanded) # type:ignore + result = dict_to_struct(yaml_data) if cached: def_cache.add(cache_key, result, None) @@ -902,7 +916,7 @@ def markup_to_html( cache_key = None else: import hashlib - cache_key = ("markup:v6:%s:%d:%s:%s" + cache_key = ("markup:v7:%s:%d:%s:%s" % (CACHE_KEY_ROOT, course.id, str(commit_sha), hashlib.md5(text.encode("utf-8")).hexdigest())) @@ -1206,7 +1220,7 @@ def compute_chunk_weight_and_shown( chunk, # type: ChunkDesc roles, # type: List[Text] now_datetime, # type: datetime.datetime - facilities, # type: frozenset[Text] + facilities, # type: FrozenSet[Text] ): # type: (...) -> Tuple[float, bool] if not hasattr(chunk, "rules"): @@ -1265,7 +1279,7 @@ def get_processed_page_chunks( page_desc, # type: StaticPageDesc roles, # type: List[Text] now_datetime, # type: datetime.datetime - facilities, # type: frozenset[Text] + facilities, # type: FrozenSet[Text] ): # type: (...) -> List[ChunkDesc] for chunk in page_desc.chunks: @@ -1470,14 +1484,14 @@ def get_course_commit_sha(course, participation): if participation.preview_git_commit_sha: preview_sha = participation.preview_git_commit_sha - repo = get_course_repo(course) - if isinstance(repo, SubdirRepoWrapper): - repo = repo.repo + with get_course_repo(course) as repo: + if isinstance(repo, SubdirRepoWrapper): + repo = repo.repo - try: - repo[preview_sha.encode()] - except KeyError: - preview_sha = None + try: + repo[preview_sha.encode()] + except KeyError: + preview_sha = None if preview_sha is not None: sha = preview_sha diff --git a/course/enrollment.py b/course/enrollment.py index 6e2c914666207d1be85a8e3109d38cb8fe0ad12c..cfbc7802c7f891e07d1519509337f6efb173d12a 100644 --- a/course/enrollment.py +++ b/course/enrollment.py @@ -71,7 +71,7 @@ from pytools.lex import RE as REBase # noqa # {{{ for mypy if False: - from typing import Any, Tuple, Text, Optional, List # noqa + from typing import Any, Tuple, Text, Optional, List, FrozenSet # noqa from course.utils import CoursePageContext # noqa # }}} @@ -137,7 +137,7 @@ def get_participation_permissions( course, # type: Course participation, # type: Optional[Participation] ): - # type: (...) -> frozenset[Tuple[Text, Optional[Text]]] + # type: (...) -> FrozenSet[Tuple[Text, Optional[Text]]] if participation is not None: return participation.permissions() @@ -233,6 +233,8 @@ def enroll_view(request, course_identifier): course, user, participation_status.requested, roles, request) + assert participation is not None + with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE): from django.template.loader import render_to_string message = render_to_string("course/enrollment-request-email.txt", { @@ -994,46 +996,53 @@ def edit_participation(pctx, participation_id): add_new, pctx, request.POST, instance=participation) reset_form = False - if form.is_valid(): - if "submit" in request.POST: - form.save() - - messages.add_message(request, messages.SUCCESS, - _("Changes saved.")) - - elif "approve" in request.POST: - send_enrollment_decision(participation, True, pctx.request) - - # FIXME: Double-saving - participation = form.save() - participation.status = participation_status.active - participation.save() - reset_form = True - - messages.add_message(request, messages.SUCCESS, - _("Successfully enrolled.")) - - elif "deny" in request.POST: - send_enrollment_decision(participation, False, pctx.request) - - # FIXME: Double-saving - participation = form.save() - participation.status = participation_status.denied - participation.save() - reset_form = True - - messages.add_message(request, messages.SUCCESS, - _("Successfully denied.")) - - elif "drop" in request.POST: - # FIXME: Double-saving - participation = form.save() - participation.status = participation_status.dropped - participation.save() - reset_form = True - - messages.add_message(request, messages.SUCCESS, - _("Successfully dropped.")) + try: + if form.is_valid(): + if "submit" in request.POST: + form.save() + + messages.add_message(request, messages.SUCCESS, + _("Changes saved.")) + + elif "approve" in request.POST: + send_enrollment_decision(participation, True, pctx.request) + + # FIXME: Double-saving + participation = form.save() + participation.status = participation_status.active + participation.save() + reset_form = True + + messages.add_message(request, messages.SUCCESS, + _("Successfully enrolled.")) + + elif "deny" in request.POST: + send_enrollment_decision(participation, False, pctx.request) + + # FIXME: Double-saving + participation = form.save() + participation.status = participation_status.denied + participation.save() + reset_form = True + + messages.add_message(request, messages.SUCCESS, + _("Successfully denied.")) + + elif "drop" in request.POST: + # FIXME: Double-saving + participation = form.save() + participation.status = participation_status.dropped + participation.save() + reset_form = True + + messages.add_message(request, messages.SUCCESS, + _("Successfully dropped.")) + except IntegrityError as e: + messages.add_message(request, messages.ERROR, + _("A data integrity issue was detected when saving " + "this participation. Maybe a participation for " + "this user already exists? (%s)") + % str(e)) if reset_form: form = EditParticipationForm( diff --git a/course/exam.py b/course/exam.py index 6cd666c7aa36962bc7b22c3f339f2ab3eae197a8..ee6d53919280ceac6e930c3e70c26f6d8f54f53c 100644 --- a/course/exam.py +++ b/course/exam.py @@ -615,7 +615,7 @@ def is_from_exams_only_facility(request): def get_login_exam_ticket(request): - # type: (http.HttpRequest) -> ExamTicket + # type: (http.HttpRequest) -> Optional[ExamTicket] exam_ticket_pk = request.session.get("relate_exam_ticket_pk_used_for_login") if exam_ticket_pk is None: diff --git a/course/flow.py b/course/flow.py index 76f78c4847647b7f3c2d8c26981ce46b6173b9b6..3ce19d96c61dd178a6890b93bc421d4abd927b5f 100644 --- a/course/flow.py +++ b/course/flow.py @@ -88,7 +88,7 @@ from relate.utils import retry_transaction_decorator # {{{ mypy if False: - from typing import Any, Optional, Iterable, Tuple, Text, List # noqa + from typing import Any, Optional, Iterable, Sequence, Tuple, Text, List, FrozenSet # noqa import datetime # noqa from course.models import Course # noqa from course.utils import ( # noqa @@ -338,55 +338,54 @@ def grade_page_visit(visit, visit_grade_model=FlowPageVisitGrade, get_flow_page_desc, instantiate_flow_page) - repo = get_course_repo(course) + with get_course_repo(course) as repo: + course_commit_sha = get_course_commit_sha( + course, flow_session.participation if respect_preview else None) - course_commit_sha = get_course_commit_sha( - course, flow_session.participation if respect_preview else None) + flow_desc = get_flow_desc(repo, course, + flow_session.flow_id, course_commit_sha) - flow_desc = get_flow_desc(repo, course, - flow_session.flow_id, course_commit_sha) + page_desc = get_flow_page_desc( + flow_session.flow_id, + flow_desc, + page_data.group_id, page_data.page_id) - page_desc = get_flow_page_desc( - flow_session.flow_id, - flow_desc, - page_data.group_id, page_data.page_id) + page = instantiate_flow_page( + location="flow '%s', group, '%s', page '%s'" + % (flow_session.flow_id, page_data.group_id, page_data.page_id), + repo=repo, page_desc=page_desc, + commit_sha=course_commit_sha) - page = instantiate_flow_page( - location="flow '%s', group, '%s', page '%s'" - % (flow_session.flow_id, page_data.group_id, page_data.page_id), - repo=repo, page_desc=page_desc, - commit_sha=course_commit_sha) + assert page.expects_answer() + if not page.is_answer_gradable(): + return - assert page.expects_answer() - if not page.is_answer_gradable(): - return + from course.page import PageContext + grading_page_context = PageContext( + course=course, + repo=repo, + commit_sha=course_commit_sha, + flow_session=flow_session) - from course.page import PageContext - grading_page_context = PageContext( - course=course, - repo=repo, - commit_sha=course_commit_sha, - flow_session=flow_session) + with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE): + answer_feedback = page.grade( + grading_page_context, visit.page_data.data, + visit.answer, grade_data=grade_data) - with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE): - answer_feedback = page.grade( - grading_page_context, visit.page_data.data, - visit.answer, grade_data=grade_data) + grade = visit_grade_model() + grade.visit = visit + grade.grade_data = grade_data + grade.max_points = page.max_points(visit.page_data) + grade.graded_at_git_commit_sha = course_commit_sha.decode() - grade = visit_grade_model() - grade.visit = visit - grade.grade_data = grade_data - grade.max_points = page.max_points(visit.page_data) - grade.graded_at_git_commit_sha = course_commit_sha.decode() + bulk_feedback_json = None + if answer_feedback is not None: + grade.correctness = answer_feedback.correctness + grade.feedback, bulk_feedback_json = answer_feedback.as_json() - bulk_feedback_json = None - if answer_feedback is not None: - grade.correctness = answer_feedback.correctness - grade.feedback, bulk_feedback_json = answer_feedback.as_json() + grade.save() - grade.save() - - update_bulk_feedback(page_data, grade, bulk_feedback_json) + update_bulk_feedback(page_data, grade, bulk_feedback_json) # }}} @@ -494,6 +493,7 @@ def get_prev_answer_visits_qset(page_data): def get_first_from_qset(qset): + # type: (Sequence) -> Optional[Any] for item in qset[:1]: return item @@ -1347,7 +1347,7 @@ def recalculate_session_grade(repo, course, session): def lock_down_if_needed( request, # type: http.HttpRequest - permissions, # type: frozenset[Text] + permissions, # type: FrozenSet[Text] flow_session, # type: FlowSession ): # type: (...) -> None @@ -1607,7 +1607,7 @@ def get_and_check_flow_session(pctx, flow_session_id): def will_receive_feedback(permissions): - # type: (frozenset[Text]) -> bool + # type: (FrozenSet[Text]) -> bool return ( flow_permission.see_correctness in permissions @@ -1615,14 +1615,14 @@ def will_receive_feedback(permissions): def may_send_email_about_flow_page(permissions): - # type: (frozenset[Text]) -> bool + # type: (FrozenSet[Text]) -> bool return flow_permission.send_email_about_flow_page in permissions def get_page_behavior( page, # type: PageBase - permissions, # type: frozenset[Text] + permissions, # type: FrozenSet[Text] session_in_progress, # type: bool answer_was_graded, # type: bool generates_grade, # type: bool @@ -1679,7 +1679,7 @@ def get_page_behavior( def add_buttons_to_form(form, fpctx, flow_session, permissions): - # type: (StyledForm, FlowPageContext, FlowSession, frozenset[Text]) -> StyledForm + # type: (StyledForm, FlowPageContext, FlowSession, FrozenSet[Text]) -> StyledForm from crispy_forms.layout import Submit show_save_button = getattr(form, "show_save_button", True) @@ -1839,7 +1839,9 @@ def view_flow_page(pctx, flow_session_id, ordinal): answer_data, answer_was_graded) = post_result - prev_visit_id = prev_answer_visits[0].id + if prev_answer_visits: + prev_visit_id = prev_answer_visits[0].id + # continue at common flow page generation below else: @@ -2135,7 +2137,7 @@ def post_flow_page( flow_session, # type: FlowSession fpctx, # type: FlowPageContext request, # type: http.HttpRequest - permissions, # type: frozenset[Text] + permissions, # type: FrozenSet[Text] generates_grade, # type: bool ): # type: (...) -> Tuple[PageBehavior, List[FlowPageVisit], forms.Form, Optional[AnswerFeedback], Any, bool] # noqa @@ -2830,6 +2832,7 @@ def regrade_flows_view(pctx): class UnsubmitFlowPageForm(forms.Form): def __init__(self, *args, **kwargs): + # type: (*Any, **Any) -> None self.helper = FormHelper() super(UnsubmitFlowPageForm, self).__init__(*args, **kwargs) diff --git a/course/grading.py b/course/grading.py index 910edc9a7a122892d491b0612817f020c9357369..5691309d0f4123abfffef7988bf730b1c1f82cdd 100644 --- a/course/grading.py +++ b/course/grading.py @@ -58,7 +58,7 @@ from course.constants import ( # {{{ for mypy if False: - from typing import Text, Any, Optional, Dict # noqa + from typing import Text, Any, Optional, Dict, List # noqa from course.models import ( # noqa GradingOpportunity) from course.utils import ( # noqa @@ -320,6 +320,8 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal): else: grading_form = None + grading_form_html = None # type: Optional[Text] + if grading_form is not None: from crispy_forms.layout import Submit grading_form.helper.form_class += " relate-grading-form" @@ -332,9 +334,6 @@ def grade_flow_page(pctx, flow_session_id, page_ordinal): grading_form_html = fpctx.page.grading_form_to_html( pctx.request, fpctx.page_context, grading_form, grade_data) - else: - grading_form_html = None - # }}} # {{{ compute points_awarded diff --git a/course/models.py b/course/models.py index 6a0f84c34ef3bdbb81035fb20721a92b680b6962..52e0fd4a23fc950d878f5ead4c5927b645085b08 100644 --- a/course/models.py +++ b/course/models.py @@ -61,8 +61,9 @@ from course.page.base import AnswerFeedback # {{{ mypy if False: - from typing import List, Dict, Any, Optional, Text, Iterable # noqa # noqa + from typing import List, Dict, Any, Optional, Text, Iterable, Tuple, FrozenSet # noqa from course.content import FlowDesc # noqa + import datetime # noqa # }}} @@ -290,6 +291,16 @@ class Event(models.Model): else: return self.kind + def save(self, *args, **kwargs): + # When ordinal is Null, unique_together failed to identify duplicate entries + if not self.ordinal: + object_exist = bool( + Event.objects.filter(kind=self.kind, ordinal__isnull=True).count()) + if object_exist: + from django.db import IntegrityError + raise IntegrityError() + super(Event, self).save(*args, **kwargs) + if six.PY3: __str__ = __unicode__ @@ -468,11 +479,13 @@ class Participation(models.Model): # {{{ permissions handling + _permissions_cache = None # type: FrozenSet[Tuple[Text, Optional[Text]]] + def permissions(self): - try: + # type: () -> FrozenSet[Tuple[Text, Optional[Text]]] + + if self._permissions_cache is not None: return self._permissions_cache - except AttributeError: - pass perm = ( list( @@ -486,14 +499,15 @@ class Participation(models.Model): participation=self) .values_list("permission", "argument"))) - perm = frozenset( + fset_perm = frozenset( (permission, argument) if argument else (permission, None) for permission, argument in perm) - self._permissions_cache = perm - return perm + self._permissions_cache = fset_perm + return fset_perm def has_permission(self, perm, argument=None): + # type: (Text, Optional[Text]) -> bool return (perm, argument) in self.permissions() # }}} @@ -793,7 +807,7 @@ class FlowSession(models.Model): __str__ = __unicode__ def append_comment(self, s): - # type: (Text) -> None + # type: (Optional[Text]) -> None if s is None: return @@ -816,6 +830,7 @@ class FlowSession(models.Model): return assemble_answer_visits(self) def last_activity(self): + # type: () -> Optional[datetime.datetime] for visit in (FlowPageVisit.objects .filter( flow_session=self, @@ -1280,16 +1295,16 @@ class FlowRuleException(models.Model): from relate.utils import dict_to_struct rule = dict_to_struct(self.rule) - repo = get_course_repo(self.participation.course) - commit_sha = get_course_commit_sha( - self.participation.course, self.participation) - ctx = ValidationContext( - repo=repo, - commit_sha=commit_sha) + with get_course_repo(self.participation.course) as repo: + commit_sha = get_course_commit_sha( + self.participation.course, self.participation) + ctx = ValidationContext( + repo=repo, + commit_sha=commit_sha) - flow_desc = get_flow_desc(repo, - self.participation.course, - self.flow_id, commit_sha) + flow_desc = get_flow_desc(repo, + self.participation.course, + self.flow_id, commit_sha) tags = None grade_identifier = None diff --git a/course/page/base.py b/course/page/base.py index 4bc5c7eb450d9bac9f36cbb6d009a38beff04471..bfb3c81c4f849c737926a79c013cd7ae60468937 100644 --- a/course/page/base.py +++ b/course/page/base.py @@ -44,7 +44,7 @@ from django.conf import settings # {{{ mypy if False: - from typing import Text, Optional, Any, Tuple, Dict, Callable # noqa + from typing import Text, Optional, Any, Tuple, Dict, Callable, FrozenSet # noqa from django import http # noqa from course.models import ( # noqa Course, @@ -216,7 +216,7 @@ class AnswerFeedback(object): @staticmethod def from_json(json, bulk_json): - # type: (Any, Any) -> AnswerFeedback + # type: (Any, Any) -> Optional[AnswerFeedback] if json is None: return json @@ -371,7 +371,7 @@ class PageBase(object): ) def get_modified_permissions_for_page(self, permissions): - # type: (frozenset[Text]) -> frozenset[Text] + # type: (FrozenSet[Text]) -> FrozenSet[Text] rw_permissions = set(permissions) if hasattr(self.page_desc, "access_rules"): @@ -387,9 +387,11 @@ class PageBase(object): return frozenset(rw_permissions) def make_page_data(self): + # type: () -> Dict return {} def initialize_page_data(self, page_context): + # type: (PageContext) -> Dict """Return (possibly randomly generated) data that is used to generate the content on this page. This is passed to methods below as the *page_data* argument. One possible use for this argument would be a random permutation diff --git a/course/page/code.py b/course/page/code.py index 520fa22fb687d59c67bb05442f28b1249b7e64b7..4bacfea386678bf3efe16504ad4759bee3fa5551 100644 --- a/course/page/code.py +++ b/course/page/code.py @@ -263,12 +263,18 @@ def is_nuisance_failure(result): return True - if ("traceback" in result - and "bind: address already in use" in result["traceback"]): + if "traceback" in result: + if "bind: address already in use" in result["traceback"]: + # https://github.com/docker/docker/issues/8714 - # https://github.com/docker/docker/issues/8714 + return True - return True + if ("requests.packages.urllib3.exceptions.NewConnectionError" + in result["traceback"]): + return True + + if "http.client.RemoteDisconnected" in result["traceback"]: + return True return False @@ -368,8 +374,10 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue): .. attribute:: test_code Optional. - Symbols that the participant's code is expected to define. - These will be made available to the :attr:`test_code`. + Code that will be run to determine the correctness of a + student-provided solution. Will have access to variables in + :attr:`names_from_user` (which will be *None*) if not provided. Should + never raise an exception. This may contain the marker "###CORRECT_CODE###", which will be replaced with the contents of :attr:`correct_code`, with diff --git a/course/page/inline.py b/course/page/inline.py index 32ecb33445b9e5a777af625ebc009e010c3d8ccd..5c883f65e651c796fc450820704c57c1ec2d3d08 100644 --- a/course/page/inline.py +++ b/course/page/inline.py @@ -206,7 +206,7 @@ EM_LEN_DICT = { "%": ""} ALLOWED_LENGTH_UNIT = EM_LEN_DICT.keys() -WIDTH_STR_RE = re.compile("^(\d*\.\d+|\d+)\s*(.*)$") +WIDTH_STR_RE = re.compile(r"^(\d*\.\d+|\d+)\s*(.*)$") class ShortAnswer(AnswerBase): diff --git a/course/sandbox.py b/course/sandbox.py index 3ffd16b6bf1f58e6180db0ef304ffb686ccde9df..23258395ed0638fdadba1757bf4824a8019920d2 100644 --- a/course/sandbox.py +++ b/course/sandbox.py @@ -240,7 +240,9 @@ def view_page_sandbox(pctx): from course.content import expand_yaml_macros new_page_source = expand_yaml_macros( pctx.repo, pctx.course_commit_sha, new_page_source) - page_desc = dict_to_struct(yaml.load(new_page_source)) + + yaml_data = yaml.load(new_page_source) # type: ignore + page_desc = dict_to_struct(yaml_data) if not isinstance(page_desc, Struct): raise ValidationError("Provided page source code is not " @@ -295,7 +297,8 @@ def view_page_sandbox(pctx): have_valid_page = page_source is not None if have_valid_page: - page_desc = cast(FlowPageDesc, dict_to_struct(yaml.load(page_source))) + yaml_data = yaml.load(page_source) # type: ignore + page_desc = cast(FlowPageDesc, dict_to_struct(yaml_data)) from course.content import instantiate_flow_page try: @@ -313,6 +316,8 @@ def view_page_sandbox(pctx): have_valid_page = False if have_valid_page: + page_desc = cast(FlowPageDesc, page_desc) + # Try to recover page_data, answer_data page_data = get_sandbox_data_for_page( pctx, page_desc, PAGE_DATA_SESSION_KEY) diff --git a/course/templates/course/calendar.html b/course/templates/course/calendar.html index ec4f99fa71f34ab506401da14a19ee8b4a7ddf6e..2c9f661d1c355a9c5ec8e5411f93937ac641a3c7 100644 --- a/course/templates/course/calendar.html +++ b/course/templates/course/calendar.html @@ -8,41 +8,45 @@ {% trans "Calendar" %} - {% trans "RELATE" %} {% endblock %} -{%block header_extra %} +{% block header_extra %} {# load calendar with local language #} {% get_current_language as LANGUAGE_CODE %} + {% if edit_view %} + + + {% endif %} {% endblock %} {% block content %}

{{ course.number}} {% trans "Calendar" %}

- + {% if pperm.edit_events %} + + {% endif %}
+ {% trans "Note" %}: {% if edit_view %}{% trans "Different from the students' calendar, this calender shows all events. " %}{% endif %}{% trans "Some calendar entries are clickable and link to entries below." %} - - -{% blocktrans trimmed %} - Note: Some calendar entries are clickable and link to entries - below. -{% endblocktrans %}
{% for event_info in event_info_list %} @@ -58,5 +62,102 @@ {% endfor%}
+ {% if edit_view %} + +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+ {% endif %} {% endblock %} +{% block page_bottom_javascript_extra %} + + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/course/templates/course/calender_edit.html b/course/templates/course/calender_edit.html deleted file mode 100644 index 9a17b712651bdeceae014eb15b8a6cc7c01bff55..0000000000000000000000000000000000000000 --- a/course/templates/course/calender_edit.html +++ /dev/null @@ -1,181 +0,0 @@ -{% extends "course/course-base.html" %} -{% load i18n %} - -{% load static %} - -{% block title %} - {{ course.number}} - {% trans "Calendar" %} - {% trans "RELATE" %} -{% endblock %} - -{%block header_extra %} - - - - {# load calendar with local language #} - {% get_current_language as LANGUAGE_CODE %} - - - - - - -{% endblock %} - - - - - -{% block content %} - -{% blocktrans trimmed %} - Note: Different from the students' calendar, this calender shows all events. -{% endblocktrans %} - - - - -
-
- -
- -
-
-
- -
- - - - -{% blocktrans trimmed %} - Note: Some calendar entries are clickable and link to entries - below. -{% endblocktrans %} - -
- {% for event_info in event_info_list %} -
-
- {{ event_info.human_title }} - ({{ event_info.start_time }}{% if event_info.end_time %} - {{ event_info.end_time }}{% endif %}) -
-
- {{ event_info.description|safe }} -
-
- {% endfor%} -
- -