Beispiel #1
0
def delete_role(course_id: int, role_id: int) -> EmptyResponse:
    """Remove a :class:`.models.CourseRole` from the given
    :class:`.models.Course`.

    .. :quickref: Course; Delete a course role from a course.

    :param int course_id: The id of the course
    :returns: An empty response with return code 204

    :raises APIException: If the role with the given ids does not exist.
        (OBJECT_NOT_FOUND)
    :raises APIException: If there are still users with this role.
        (INVALID_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not manage the course with the
        given id. (INCORRECT_PERMISSION)
    """
    auth.ensure_permission(CPerm.can_edit_course_roles, course_id)

    course = helpers.get_or_404(
        models.Course,
        course_id,
        also_error=lambda c: c.virtual,
    )
    role = helpers.filter_single_or_404(
        models.CourseRole,
        models.CourseRole.course_id == course_id,
        models.CourseRole.id == role_id,
        also_error=lambda r: r.hidden,
    )

    if course.lti_provider is not None:
        if LTICourseRole.codegrade_role_name_used(role.name):
            lms = course.lti_provider.lms_name
            raise APIException(
                f'You cannot delete default {lms} roles',
                ('The course "{}" is an LTI course so it is impossible to '
                 'delete role {}').format(course.id, role.id),
                APICodes.INCORRECT_PERMISSION, 403)

    users_with_role = db.session.query(models.user_course).filter(
        models.user_course.c.course_id == role_id).exists()
    if db.session.query(users_with_role).scalar():
        raise APIException(
            'There are still users with this role',
            'There are still users with role {}'.format(role_id),
            APICodes.INVALID_PARAM, 400)
    links_with_role = db.session.query(
        models.CourseRegistrationLink).filter_by(
            course_role_id=role_id).exists()
    if db.session.query(links_with_role).scalar():
        raise APIException(
            'There are still registration links with this role',
            f'The role "{role_id}" cannot be deleted as it is still in use',
            APICodes.INVALID_PARAM, 400)

    db.session.delete(role)
    db.session.commit()

    return make_empty_response()
Beispiel #2
0
def ensure_can_submit_work(
    assig: 'psef.models.Assignment',
    author: 'psef.models.User',
) -> None:
    """Check if the current user can submit for the given assignment as the given
    author.

    .. note::

        This function also checks if the assignment is a LTI assignment. If
        this is the case it makes sure the ``author`` can do grade passback.

    :param assig: The assignment that should be submitted to.
    :param author: The author of the submission.

    :raises PermissionException: If there the current user cannot submit for
        the given author.
    :raises APIException: If the author is not enrolled in course of the given
        assignment or if the LTI state was wrong.
    """
    submit_self = psef.current_user.id == author.id

    if assig.course_id not in author.courses:
        raise APIException(
            'The given user is not enrolled in this course',
            (
                f'The user "{author.id}" is not enrolled '
                f'in course "{assig.course_id}"'
            ),
            APICodes.INVALID_STATE,
            400,
        )

    if submit_self:
        ensure_permission('can_submit_own_work', assig.course_id)
    else:
        ensure_permission('can_submit_others_work', assig.course_id)

    if not assig.is_open:
        ensure_permission('can_upload_after_deadline', assig.course_id)

    if assig.is_lti and assig.id not in author.assignment_results:
        raise APIException(
            (
                "This assignment is a LTI assignment and it seems we "
                "don't have the possibility to passback the grade to the "
                "LMS. Please {}visit the assignment on the LMS again, if "
                "this issue persist please contact your administrator."
            ).format('let the given author ' if submit_self else ''),
            (
                f'The assignment {assig.id} is not present in the '
                f'user {author.id} `assignment_results`'
            ),
            APICodes.INVALID_STATE,
            400,
        )
Beispiel #3
0
def login() -> ExtendedJSONResponse[t.Mapping[str, t.Union[models.User, str]]]:
    """Login a :class:`.models.User` if the request is valid.

    .. :quickref: User; Login a given user.

    :returns: A response containing the JSON serialized user

    :<json str email: The email of the user to log in.
    :<json str username: The password of the user to log in.

    :>json user: The user that was logged in.
    :>jsonobj user: :py:class:`~.models.User`
    :>json str access_token: A JWT token that can be used to send requests to
        the server logged in as the given user.

    :raises APIException: If the request does not contain email and/or password
        parameter. (MISSING_REQUIRED_PARAM)
    :raises APIException: If no user with email exists or the password is
        wrong. (LOGIN_FAILURE)
    :raises APIException: If the user with the given email and password is
        inactive. (INACTIVE_USER)
    """
    data = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(data, [('username', str), ('password', str)])
    username = t.cast(str, data['username'])
    password = t.cast(str, data['password'])

    # WARNING: Do not use the `helpers.filter_single_or_404` function here as
    # we have to return the same error for a wrong email as for a wrong
    # password!
    user: t.Optional[models.User]
    user = db.session.query(models.User, ).filter(
        models.User.username == username, ).first()

    if user is None or user.password != password:
        raise APIException('The supplied email or password is wrong.',
                           ('The user with username "{}" does not exist ' +
                            'or has a different password').format(username),
                           APICodes.LOGIN_FAILURE, 400)

    if not user.is_active:
        raise APIException(
            'User is not active',
            ('The user with id "{}" is not active any more').format(user.id),
            APICodes.INACTIVE_USER, 403)

    return extended_jsonify({
        'user':
        user,
        'access_token':
        flask_jwt.create_access_token(
            identity=user.id,
            fresh=True,
        )
    })
Beispiel #4
0
def user_patch_handle_change_user_data() -> EmptyResponse:
    """Handle the PATCH login route when no ``type`` is given.

    :returns: An empty response.
    """
    data = ensure_json_dict(request.get_json())

    ensure_keys_in_dict(data, [('email', str), ('old_password', str),
                               ('name', str), ('new_password', str)])
    email = t.cast(str, data['email'])
    old_password = t.cast(str, data['old_password'])
    new_password = t.cast(str, data['new_password'])
    name = t.cast(str, data['name'])

    def _ensure_password(
        changed: str,
        msg: str = 'To change your {} you need a correct old password.'
    ) -> None:
        if current_user.password != old_password:
            raise APIException(msg.format(changed),
                               'The given old password was not correct',
                               APICodes.INVALID_CREDENTIALS, 403)

    if old_password != '':
        _ensure_password('', 'The given old password is wrong')

    if current_user.name != name:
        auth.ensure_permission('can_edit_own_info')
        if name == '':
            raise APIException('Your new name cannot be empty',
                               'The given new name was empty',
                               APICodes.INVALID_PARAM, 400)
        current_user.name = name

    if current_user.email != email:
        auth.ensure_permission('can_edit_own_info')
        if not validate_email(email):
            raise APIException(
                'The given email is not valid.',
                'The email "{email}" is not valid.',
                APICodes.INVALID_PARAM,
                400,
            )
        _ensure_password('email')
        current_user.email = email

    if new_password != '':
        _ensure_password('password')
        auth.ensure_permission('can_edit_own_password')
        current_user.password = new_password

    db.session.commit()
    return make_empty_response()
Beispiel #5
0
def extract_to_temp(
        file: FileStorage,
        ignore_filter: IgnoreFilterManager,
        handle_ignore: IgnoreHandling = IgnoreHandling.keep) -> str:
    """Extracts the contents of file into a temporary directory.

    :param file: The archive to extract.
    :param ignore_filter: The files and directories that should be ignored.
    :param handle_ignore: Determines how ignored files should be handled.
    :returns: The pathname of the new temporary directory.
    """
    tmpfd, tmparchive = tempfile.mkstemp()

    try:
        os.remove(tmparchive)
        tmparchive += os.path.basename(
            secure_filename('archive_' + file.filename))
        tmpdir = tempfile.mkdtemp()
        file.save(tmparchive)

        if handle_ignore == IgnoreHandling.error:
            arch = archive.Archive(tmparchive)
            wrong_files = ignore_filter.get_ignored_files_in_archive(arch)
            if wrong_files:
                raise IgnoredFilesException(invalid_files=wrong_files)
            arch.extract(to_path=tmpdir, method='safe')
        else:
            archive.extract(tmparchive, to_path=tmpdir, method='safe')
            if handle_ignore == IgnoreHandling.delete:
                ignore_filter.delete_from_dir(tmpdir)
    except (tarfile.ReadError, zipfile.BadZipFile):
        raise APIException(
            'The given archive could not be extracted',
            "The given archive doesn't seem to be an archive",
            APICodes.INVALID_ARCHIVE,
            400,
        )
    except (InvalidFile, archive.UnsafeArchive) as e:
        raise APIException(
            'The given archive contains invalid files',
            str(e),
            APICodes.INVALID_FILE_IN_ARCHIVE,
            400,
        )
    finally:
        os.close(tmpfd)
        os.remove(tmparchive)

    return tmpdir
Beispiel #6
0
def select_rubric_item(submission_id: int,
                       rubricitem_id: int) -> EmptyResponse:
    """Select a rubric item of the given submission (:class:`.models.Work`).

    .. :quickref: Submission; Select a rubric item.

    :param int submission_id: The id of the submission
    :param int rubricitem_id: The id of the rubric item
    :returns: Nothing.

    :raises APIException: If either the submission or rubric item with the
                          given ids does not exist. (OBJECT_ID_NOT_FOUND)
    :raises APIException: If the assignment of the rubric is not the assignment
                          of the submission. (INVALID_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not grade the given submission
                                 (INCORRECT_PERMISSION)
    """
    work = helpers.get_or_404(models.Work, submission_id)
    rubric_item = helpers.get_or_404(models.RubricItem, rubricitem_id)

    auth.ensure_permission('can_grade_work', work.assignment.course_id)
    if rubric_item.rubricrow.assignment_id != work.assignment_id:
        raise APIException(
            'Rubric item selected does not match assignment',
            'The rubric item with id {} does not match the assignment'.format(
                rubricitem_id), APICodes.INVALID_PARAM, 400)

    work.remove_selected_rubric_item(rubric_item.rubricrow_id)
    work.select_rubric_items([rubric_item], current_user, False)
    db.session.commit()

    return make_empty_response()
Beispiel #7
0
def unselect_rubric_item(submission_id: int,
                         rubric_item_id: int) -> EmptyResponse:
    """Unselect the given rubric item for the given submission.

    .. :quickref: Submission; Unselect the given rubric item.

    :param submission_id: The submission to unselect the item for.
    :param rubric_item_id: The rubric items id to unselect.
    :returns: Nothing.
    """
    submission = helpers.get_or_404(models.Work, submission_id)

    auth.ensure_permission('can_grade_work', submission.assignment.course_id)

    new_items = [
        item for item in submission.selected_items if item.id != rubric_item_id
    ]
    if len(new_items) == len(submission.selected_items):
        raise APIException(
            'Selected rubric item was not selected for this submission',
            f'The item {rubric_item_id} is not selected for {submission_id}',
            APICodes.INVALID_PARAM, 400)

    submission.selected_items = new_items
    db.session.commit()

    return make_empty_response()
Beispiel #8
0
def parse_datetime(  # pylint: disable=function-redefined
    to_parse: object,
    allow_none: bool = False,
) -> t.Optional[DatetimeWithTimezone]:
    """Parse a datetime string using dateutil.

    :param to_parse: The object to parse, if this is not a string the parsing
        will always fail.
    :param allow_none: Allow ``None`` to be passed without raising a
        exception. if ``to_parse`` is ``None`` and this option is ``True`` the
        result will be ``None``.
    :returns: The parsed DatetimeWithTimezone object.
    :raises APIException: If the parsing fails for whatever reason.
    """
    if to_parse is None and allow_none:
        return None

    if isinstance(to_parse, str):
        try:
            parsed = dateutil.parser.parse(to_parse)
        except (ValueError, OverflowError):
            pass
        else:
            # This assumes that datetimes without tzinfo are in UTC. That is
            # not correct according to the ISO spec, however it is what we used
            # to do so we need to do this because of backwards compatibility.
            return DatetimeWithTimezone.from_datetime(parsed,
                                                      default_tz=timezone.utc)

    raise APIException('The given date is not valid!',
                       '{} cannot be parsed by dateutil.'.format(to_parse),
                       APICodes.INVALID_PARAM, 400)
Beispiel #9
0
def delete_snippets(snippet_id: int) -> EmptyResponse:
    """Delete the :class:`.models.Snippet` with the given id.

    .. :quickref: Snippet; Delete a snippet.

    :param int snippet_id: The id of the snippet
    :returns: An empty response with return code 204

    :raises APIException: If the snippet with the given id does not exist.
                          (OBJECT_ID_NOT_FOUND)
    :raises APIException: If the snippet does not belong the current user.
                          (INCORRECT_PERMISSION)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not use snippets.
                                 (INCORRECT_PERMISSION)
    """
    snip: t.Optional[models.Snippet]
    snip = helpers.get_or_404(models.Snippet, snippet_id)
    snip = models.Snippet.query.get(snippet_id)
    assert snip is not None

    if snip.user_id != current_user.id:
        raise APIException(
            'The given snippet is not your snippet',
            'The snippet "{}" does not belong to user "{}"'.format(
                snip.id, current_user.id), APICodes.INCORRECT_PERMISSION, 403)
    else:
        db.session.delete(snip)
        db.session.commit()
        return make_empty_response()
Beispiel #10
0
def delete_rubric(assignment_id: int) -> EmptyResponse:
    """Delete the rubric for the given assignment.

    .. :quickref: Assignment; Delete the rubric of an assignment.

    :param assignment_id: The id of the :class:`.models.Assignment` whose
        rubric should be deleted.
    :returns: Nothing.

    :raises PermissionException: If the user does not have the
        ``manage_rubrics`` permission (INCORRECT_PERMISSION).
    :raises APIException: If the assignment has no rubric.
        (OBJECT_ID_NOT_FOUND)
    """
    assig = helpers.get_or_404(models.Assignment, assignment_id)
    auth.ensure_permission('manage_rubrics', assig.course_id)

    if not assig.rubric_rows:
        raise APIException(
            'Assignment has no rubric',
            'The assignment with id "{}" has no rubric'.format(assignment_id),
            APICodes.OBJECT_ID_NOT_FOUND, 404
        )

    assig.rubric_rows = []

    db.session.commit()

    return make_empty_response()
Beispiel #11
0
def get_assignment_rubric(assignment_id: int
                          ) -> JSONResponse[t.Sequence[models.RubricRow]]:
    """Return the rubric corresponding to the given `assignment_id`.

    .. :quickref: Assignment; Get the rubric of an assignment.

    :param int assignment_id: The id of the assignment
    :returns: A list of JSON of :class:`.models.RubricRows` items

    :raises APIException: If no assignment with given id exists.
        (OBJECT_ID_NOT_FOUND)
    :raises APIException: If the assignment has no rubric.
        (OBJECT_ID_NOT_FOUND)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user is not allowed to see this is
                                 assignment. (INCORRECT_PERMISSION)
    """
    assig = helpers.get_or_404(models.Assignment, assignment_id)

    auth.ensure_permission('can_see_assignments', assig.course_id)
    if not assig.rubric_rows:
        raise APIException(
            'Assignment has no rubric',
            'The assignment with id "{}" has no rubric'.format(assignment_id),
            APICodes.OBJECT_ID_NOT_FOUND, 404
        )

    return jsonify(assig.rubric_rows)
Beispiel #12
0
def create_new_assignment(course_id: int) -> JSONResponse[models.Assignment]:
    """Create a new course for the given assignment.

    .. :quickref: Course; Create a new assignment in a course.

    :param int course_id: The course to create an assignment in.

    :<json str name: The name of the new assignment.

    :returns: The newly created assignment.

    :raises PermissionException: If the current user does not have the
        ``can_create_assignment`` permission (INCORRECT_PERMISSION).
    """
    auth.ensure_permission('can_create_assignment', course_id)

    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('name', str)])
    name = t.cast(str, content['name'])

    course = helpers.get_or_404(models.Course, course_id)

    if course.lti_course_id is not None:
        raise APIException('You cannot add assignments to a LTI course',
                           f'The course "{course_id}" is a LTI course',
                           APICodes.INVALID_STATE, 400)

    assig = models.Assignment(name=name,
                              course=course,
                              deadline=datetime.datetime.utcnow())
    db.session.add(assig)
    db.session.commit()

    return jsonify(assig)
Beispiel #13
0
def ensure_can_edit_work(work: 'psef.models.Work') -> None:
    """Make sure the current user can edit files in the given work.

    :param work: The work the given user should be able to see edit files in.
    :returns: Nothing.
    :raises PermissionException: If the user should not be able te edit these
        files.
    """
    if work.user_id == psef.current_user.id:
        if work.assignment.is_open:
            ensure_permission('can_submit_own_work', work.assignment.course_id)
        else:
            ensure_permission(
                'can_upload_after_deadline', work.assignment.course_id
            )
    else:
        if work.assignment.is_open:
            raise APIException(
                (
                    'You cannot edit work as teacher'
                    ' if the assignment is stil open!'
                ),
                f'The assignment "{work.assignment.id}" is still open.',
                APICodes.INCORRECT_PERMISSION,
                403,
            )
        ensure_permission('can_edit_others_work', work.assignment.course_id)
Beispiel #14
0
def send_reset_password_email(user: models.User) -> None:
    """Send the reset password email to a user.

    :param user: The user that has requested a reset password email.
    :returns: Nothing
    """
    token = user.get_reset_token()
    html_body = current_app.config['EMAIL_TEMPLATE'].replace(
        '\n\n', '<br><br>').format(
            site_url=current_app.config["EXTERNAL_URL"],
            url=(f'{current_app.config["EXTERNAL_URL"]}/reset_'
                 f'password/?user={user.id}&token={token}'),
            user_id=user.id,
            token=token,
            user_name=html.escape(user.name),
            user_email=html.escape(user.email),
        )
    try:
        _send_mail(html_body,
                   f'Reset password on {psef.app.config["EXTERNAL_URL"]}',
                   [user.email])
    except Exception as exc:
        logger.bind(exc_info=True)
        raise APIException(
            'Something went wrong sending the email, '
            'please contact your site admin',
            f'Sending email to {user.id} went wrong.',
            APICodes.UNKOWN_ERROR,
            500,
        ) from exc
Beispiel #15
0
 def raise_error() -> None:
     raise APIException(
         "All files are ignored by a rule in the assignment's ignore file",
         'No files were in the given archive after filtering.',
         APICodes.NO_FILES_SUBMITTED,
         400,
     )
Beispiel #16
0
def user_patch_handle_reset_password() -> JSONResponse[t.Mapping[str, str]]:
    """Handle the ``reset_password`` type for the PATCH login route.

    :returns: A response with a jsonified mapping between ``access_token`` and
        a token which can be used to login. This is only key available.
    """
    data = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(data, [('new_password', str), ('token', str),
                               ('user_id', int)])

    password = t.cast(str, data['new_password'])
    user_id = t.cast(int, data['user_id'])
    token = t.cast(str, data['token'])

    if password == '':
        raise APIException('Password should at least be 1 char',
                           f'The password is {len(password)} chars long',
                           APICodes.INVALID_PARAM, 400)
    user = helpers.get_or_404(models.User, user_id)
    user.reset_password(token, password)
    db.session.commit()
    return jsonify({
        'access_token':
        flask_jwt.create_access_token(
            identity=user.id,
            fresh=True,
        )
    })
Beispiel #17
0
def get_course_data(course_id: int) -> JSONResponse[t.Mapping[str, t.Any]]:
    """Return course data for a given :class:`.models.Course`.

    .. :quickref: Course; Get data for a given course.

    :param int course_id: The id of the course

    :returns: A response containing the JSON serialized course

    :>json str role: The name of the role the current user has in this
        course.
    :>json ``**rest``: JSON serialization of :py:class:`psef.models.Course`.

    :raises APIException: If there is no course with the given id.
                          (OBJECT_ID_NOT_FOUND)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    """
    # TODO: Optimize this loop to a single query
    for course_role in current_user.courses.values():
        if course_role.course_id == course_id:
            return jsonify({
                'role': course_role.name,
                **course_role.course.__to_json__(),
            })

    raise APIException('Course not found',
                       'The course with id {} was not found'.format(course_id),
                       APICodes.OBJECT_ID_NOT_FOUND, 404)
Beispiel #18
0
def search_users() -> JSONResponse[t.Sequence[models.User]]:
    """Search for a user by name and username.

    .. :quickref: User; Fuzzy search for a user by name and username.

    :param str q: The string to search for, all SQL wildcard are escaped and
        spaces are replaced by wildcards.

    :returns: A list of :py:class:`.models.User` objects that match the given
        query string.

    :raises APIException: If the query string less than 3 characters
        long. (INVALID_PARAM)
    :raises PermissionException: If the currently logged in user does not have
        the permission ``can_search_users``. (INCORRECT_PERMISSION)
    :raises RateLimitExceeded: If you hit this end point more than once per
        second. (RATE_LIMIT_EXCEEDED)
    """
    ensure_keys_in_dict(request.args, [('q', str)])
    query = t.cast(str, request.args.get('q'))

    if len(query) < 3:
        raise APIException(
            'The search string should be at least 3 chars',
            f'The search string "{query}" is not 3 chars or longer.',
            APICodes.INVALID_PARAM, 400)

    likes = [
        t.cast(t.Any, col).ilike('%{}%'.format(
            escape_like(query).replace(' ', '%'), ))
        for col in [models.User.name, models.User.username]
    ]
    return jsonify(models.User.query.filter(or_(*likes)).all())
Beispiel #19
0
def parse_email_list(
    to_parse: object,
    allow_none: bool = False,
) -> t.Optional[t.List[t.Tuple[str, str]]]:
    """Parse email list into a list of emails.

    This list should be in the form of a ``address-list`` as specified in
    RFC2822.

    :param to_parse: The object to parse, it should be a :class:`str` if you
        want it to succeed.
    :param allow_none: If ``True`` we will not error if ``to_parse`` is
        ``None``.
    :returns: A list of addresses or ``None`` if ``to_parse`` is ``None`` and
        ``allow_none`` is ``True``.
    :raises APIException: If the parsing fails in some way.
    """
    if allow_none and to_parse is None:
        return None

    if isinstance(to_parse, str):
        addresses = email.utils.getaddresses([to_parse.strip()])
        if all(validate_email(email) for _, email in addresses):
            return addresses

    raise APIException(
        f'The given string of emails contains invalid items',
        f'The string "{to_parse}" contains invalid items.',
        APICodes.INVALID_PARAM, 400
    )
Beispiel #20
0
def parse_enum(
    to_parse: object,
    parse_into_enum: t.Type[T],
    allow_none: bool = False,
    option_name: t.Optional[str] = None,
) -> t.Optional[T]:
    """Parse the given string to the given parse_into_enum.

    :param to_parse: The object to parse. If this value is not a string or
        ``None`` the function will always return a type error.
    :param parse_into_enum: The enum to parse to.
    :param allow_none: Allow ``None`` to be passed and return ``None`` if this
        is the case. If this value is ``False`` and ``None`` is passed the
        function will raise a :class:`.APIException`.
    :param option_name: The name of the option, only used in error display.
    :returns: A instance of the given enum.
    :raises APIException: If the parsing fails in some way.
    """
    if allow_none and to_parse is None:
        return None

    if isinstance(to_parse, str):
        try:
            return parse_into_enum[to_parse]
        except KeyError:
            pass

    raise APIException(
        f'The given {option_name or "option"} is not a valid option',
        f'{to_parse} is not a member from {parse_into_enum.__name__}.',
        APICodes.INVALID_PARAM, 400
    )
Beispiel #21
0
def parse_datetime(
    to_parse: object,
    allow_none: bool = False,
) -> t.Optional[datetime.datetime]:
    """Parse a datetime string using dateutil.

    :param to_parse: The object to parse, if this is not a string the parsing
        will always fail.
    :param allow_none: Allow ``None`` to be passed without raising a
        exception. if ``to_parse`` is ``None`` and this option is ``True`` the
        result will be ``None``.
    :returns: The parsed datetime object.
    :raises APIException: If the parsing fails for whatever reason.
    """
    if to_parse is None and allow_none:
        return None

    if isinstance(to_parse, str):
        try:
            return dateutil.parser.parse(to_parse)
        except (ValueError, OverflowError):
            pass

    raise APIException(
        'The given date is not valid!',
        '{} cannot be parsed by dateutil.'.format(to_parse),
        APICodes.INVALID_PARAM, 400
    )
Beispiel #22
0
def get_submission_files_from_request(
    check_size: bool,
) -> t.MutableSequence[FileStorage]:
    """Get all the submitted files in the current request.

    This function also checks if the files are in the correct format and are
    lot too large.

    :returns: The files in the current request. The length of this list is
        always at least one.
    :raises APIException: When a given files is not correct.
    """
    res = []

    if (
        check_size and request.content_length and
        request.content_length > app.config['MAX_UPLOAD_SIZE']
    ):
        helpers.raise_file_too_big_exception()

    if not request.files:
        raise APIException(
            "No file in HTTP request.",
            "There was no file in the HTTP request.",
            APICodes.MISSING_REQUIRED_PARAM, 400
        )

    for key, file in request.files.items():
        if not key.startswith('file'):
            raise APIException(
                'The parameter name should start with "file".',
                'Expected ^file.*$ got {}.'.format(key),
                APICodes.INVALID_PARAM, 400
            )

        # This will not be used on werkzeug >=0.14.0
        if not file.filename:  # pragma: no cover
            raise APIException(
                'The filename should not be empty.',
                'Got an empty filename for key {}'.format(key),
                APICodes.INVALID_PARAM, 400
            )

        res.append(file)

    return res
Beispiel #23
0
 def _ensure_password(
     changed: str,
     msg: str = 'To change your {} you need a correct old password.'
 ) -> None:
     if current_user.password != old_password:
         raise APIException(msg.format(changed),
                            'The given old password was not correct',
                            APICodes.INVALID_CREDENTIALS, 403)
Beispiel #24
0
def delete_role(course_id: int, role_id: int) -> EmptyResponse:
    """Remove a :class:`.models.CourseRole` from the given
    :class:`.models.Course`.

    .. :quickref: Course; Delete a course role from a course.

    :param int course_id: The id of the course
    :returns: An empty response with return code 204

    :raises APIException: If the role with the given ids does not exist.
        (OBJECT_NOT_FOUND)
    :raises APIException: If there are still users with this role.
        (INVALID_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not manage the course with the
        given id. (INCORRECT_PERMISSION)
    """
    auth.ensure_permission('can_edit_course_roles', course_id)

    course = helpers.get_or_404(models.Course, course_id)
    role = helpers.filter_single_or_404(
        models.CourseRole, models.CourseRole.course_id == course_id,
        models.CourseRole.id == role_id)

    if course.lti_provider is not None:
        if any(r['role'] == role.name for r in LTI_ROLE_LOOKUPS.values()):
            raise APIException(
                'You cannot delete default LTI roles for a LTI course',
                ('The course "{}" is an LTI course '
                 'so it is impossible to delete role {}').format(
                     course.id, role.id), APICodes.INCORRECT_PERMISSION, 403)

    sql = db.session.query(models.user_course).filter(
        models.user_course.c.course_id == role_id).exists()
    if db.session.query(sql).scalar():
        raise APIException(
            'There are still users with this role',
            'There are still users with role {}'.format(role_id),
            APICodes.INVALID_PARAM, 400)

    db.session.delete(role)
    db.session.commit()

    return make_empty_response()
Beispiel #25
0
def get_course_or_global_permissions(
) -> JSONResponse[t.Union[GlobalPermMap, t.Mapping[int, CoursePermMap]]]:
    """Get all the global :class:`.psef.models.Permission` or the value of a
    permission in all courses of the currently logged in
    :class:`.psef.models.User`

    .. :quickref: Permission; Get global permissions or all the course
        permissions for the current user.

    :qparam str type: The type of permissions to get. This can be ``global`` or
        ``course``.
    :qparam str permission: The permissions to get when getting course
        permissions. You can pass this parameter multiple times to get multiple
        permissions. DEPRECATED: This option is deprecated, as it is preferred
        that you simply get all permissions for a course.

    :returns: The returning object depends on the given ``type``. If it was
        ``global`` a mapping between permissions name and a boolean indicating
        if the currently logged in user has this permissions is returned.

        If it was ``course`` such a mapping is returned for every course the
        user is enrolled in. So it is a mapping between course ids and
        permission mapping. The permissions given as ``permission`` query
        parameter are the only ones that are present in the permission
        map. When no ``permission`` query is given all course permissions are
        returned.
    """
    ensure_keys_in_dict(request.args, [('type', str)])
    permission_type = t.cast(str, request.args['type']).lower()

    if permission_type == 'global':
        return jsonify(GPerm.create_map(current_user.get_all_permissions()))
    elif permission_type == 'course' and 'permission' in request.args:
        add_deprecate_warning(
            'Requesting a subset of course permissions is deprecated')
        # Make sure at least one permission is present
        ensure_keys_in_dict(request.args, [('permission', str)])
        perm_names = t.cast(t.List[str], request.args.getlist('permission'))
        return jsonify({
            course_id: CPerm.create_map(v)
            for course_id, v in current_user.get_permissions_in_courses(
                [CPerm.get_by_name(p) for p in perm_names]).items()
        })
    elif permission_type == 'course':
        return jsonify({
            course_id: CPerm.create_map(v)
            for course_id, v in
            current_user.get_all_permissions_in_courses().items()
        })
    else:
        raise APIException(
            'Invalid permission type given',
            f'The given type "{permission_type}" is not "global" or "course"',
            APICodes.INVALID_PARAM,
            400,
        )
Beispiel #26
0
def update_role(course_id: int, role_id: int) -> EmptyResponse:
    """Update the :class:`.models.Permission` of a given
    :class:`.models.CourseRole` in the given :class:`.models.Course`.

    .. :quickref: Course; Update a permission for a certain role.

    :param int course_id: The id of the course.
    :param int role_id: The id of the course role.
    :returns: An empty response with return code 204.

    :<json str permission: The name of the permission to change.
    :<json bool value: The value to set the permission to (``True`` means the
        specified role has the specified permission).

    :raises APIException: If the value or permission parameter are not in the
                          request. (MISSING_REQUIRED_PARAM)
    :raises APIException: If the role with the given id does not exist or the
                          permission with the given name does not exist.
                          (OBJECT_NOT_FOUND)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not manage the course with the
                                 given id. (INCORRECT_PERMISSION)
    """
    content = ensure_json_dict(request.get_json())

    auth.ensure_permission('can_edit_course_roles', course_id)

    ensure_keys_in_dict(content, [('value', bool), ('permission', str)])
    value = t.cast(bool, content['value'])
    permission = t.cast(str, content['permission'])

    role = helpers.filter_single_or_404(
        models.CourseRole,
        models.CourseRole.course_id == course_id,
        models.CourseRole.id == role_id,
    )
    perm = helpers.filter_single_or_404(
        models.Permission,
        models.Permission.name == permission,
        models.Permission.course_permission == True,  # pylint: disable=singleton-comparison
    )

    if (current_user.courses[course_id].id == role.id
            and perm.name == 'can_edit_course_roles'):
        raise APIException(
            'You cannot remove this permission from your own role',
            ('The current user is in role {} which'
             ' cannot remove "can_edit_course_roles"').format(role.id),
            APICodes.INCORRECT_PERMISSION, 403)

    role.set_permission(perm, value)

    db.session.commit()

    return make_empty_response()
Beispiel #27
0
def get_file_contents(code: models.File) -> bytes:
    """Get the contents of the given :class:`.models.File`.

    :param code: The file object to read.
    :returns: The contents of the file with newlines.
    """
    if code.is_directory:
        raise APIException(
            'Cannot display this file as it is a directory.',
            f'The selected file with id {code.id} is a directory.',
            APICodes.OBJECT_WRONG_TYPE, 400)

    filename = code.get_diskname()
    if os.path.islink(filename):
        raise APIException(
            f'This file is a symlink to `{os.readlink(filename)}`.',
            'The file {} is a symlink'.format(code.id), APICodes.INVALID_STATE,
            410)
    with open(filename, 'rb') as codefile:
        return codefile.read()
Beispiel #28
0
def patch_course_snippet(course_id: int, snippet_id: int) -> EmptyResponse:
    """Modify the :class:`.models.CourseSnippet` with the given id.

    .. :quickref: CourseSnippet; Change a snippets key and value.

    :param int snippet_id: The id of the snippet to change.
    :returns: An empty response with return code 204.

    :<json str key: The new key of the snippet.
    :<json str value: The new value of the snippet.

    :raises APIException: If the parameters "key" and/or "value" were not in
        the request. (MISSING_REQUIRED_PARAM)
    :raises APIException: If the snippet does not belong to the current user.
        (INCORRECT_PERMISSION)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not use snippets.
        (INCORRECT_PERMISSION)
    :raises APIException: If another snippet with the same key already exists.
        (OBJECT_ALREADY_EXISTS)
    """
    auth.ensure_permission(CPerm.can_manage_course_snippets, course_id)
    content = get_json_dict_from_request()

    ensure_keys_in_dict(content, [('key', str), ('value', str)])
    key = t.cast(str, content['key'])
    value = t.cast(str, content['value'])

    course = helpers.get_or_404(models.Course, course_id)
    snip = helpers.get_or_404(
        models.CourseSnippet,
        snippet_id,
        also_error=lambda snip: snip.course_id != course.id)

    other = models.CourseSnippet.query.filter_by(
        course=course,
        key=key,
    ).first()
    if other is not None and other.id != snippet_id:
        raise APIException(
            'A snippet with the same key already exists.',
            'A snippet with key "{}" already exists for course "{}"'.format(
                key, course_id),
            APICodes.OBJECT_ALREADY_EXISTS,
            400,
        )

    snip.key = key
    snip.value = value
    db.session.commit()

    return make_empty_response()
Beispiel #29
0
def set_reminder(
    assig: models.Assignment,
    content: t.Dict[str, helpers.JSONType],
) -> t.Optional[psef.errors.HttpWarning]:
    """Set the reminder of an assignment from a JSON dict.

    :param assig: The assignment to set the reminder for.
    :param content: The json input.
    :returns: A warning if it should be returned to the user.
    """
    ensure_keys_in_dict(content, [
        ('done_type', (type(None), str)),
        ('done_email', (type(None), str)),
        ('reminder_time', (type(None), str)),
    ])  # yapf: disable

    done_type = parsers.parse_enum(
        content.get('done_type', None),
        models.AssignmentDoneType,
        allow_none=True,
        option_name='done type'
    )
    reminder_time = parsers.parse_datetime(
        content.get('reminder_time', None),
        allow_none=True,
    )
    done_email = parsers.try_parse_email_list(
        content.get('done_email', None),
        allow_none=True,
    )

    if reminder_time and (reminder_time -
                          datetime.datetime.utcnow()).total_seconds() < 60:
        raise APIException(
            (
                'The given date is not far enough from the current time, '
                'it should be at least 60 seconds in the future.'
            ), f'{reminder_time} is not atleast 60 seconds in the future',
            APICodes.INVALID_PARAM, 400
        )

    assig.change_notifications(done_type, reminder_time, done_email)
    if done_email is not None and assig.graders_are_done():
        return make_warning(
            'Grading is already done, no email will be sent!',
            APIWarnings.CONDITION_ALREADY_MET
        )

    return None
Beispiel #30
0
def patch_submission(submission_id: int) -> JSONResponse[models.Work]:
    """Update the given submission (:class:`.models.Work`) if it already
    exists.

    .. :quickref: Submission; Update a submissions grade and feedback.

    :param int submission_id: The id of the submission
    :returns: Empty response with return code 204

    :>json float grade: The new grade, this can be null or float where null
        resets the grade or clears it. This field is optional
    :>json str feedback: The feedback for the student. This field is optional.

    :raise APIException: If the submission with the given id does not exist
        (OBJECT_ID_NOT_FOUND)
    :raise APIException: If the value of the "grade" parameter is not a float
        (INVALID_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If user can not grade the submission with the
        given id (INCORRECT_PERMISSION)
    """
    work = helpers.get_or_404(models.Work, submission_id)
    content = ensure_json_dict(request.get_json())

    auth.ensure_permission('can_grade_work', work.assignment.course_id)

    if 'feedback' in content:
        ensure_keys_in_dict(content, [('feedback', str)])
        feedback = t.cast(str, content['feedback'])

        work.comment = feedback

    if 'grade' in content:
        ensure_keys_in_dict(content, [('grade', (numbers.Real, type(None)))])
        grade = t.cast(t.Optional[float], content['grade'])

        if not (grade is None or (0 <= float(grade) <= 10)):
            raise APIException(
                'Grade submitted not between 0 and 10',
                f'Grade for work with id {submission_id} '
                f'is {content["grade"]} which is not between 0 and 10',
                APICodes.INVALID_PARAM, 400)

        work.set_grade(grade, current_user)

    db.session.commit()
    return jsonify(work)