Пример #1
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())
Пример #2
0
def add_snippet() -> JSONResponse[models.Snippet]:
    """Add or modify a :class:`.models.Snippet` by key.

    .. :quickref: Snippet; Add or modify a snippet.

    :returns: A response containing the JSON serialized snippet and return
              code 201.
    :<json str value: The new value of the snippet.
    :<json str key: The key of the new or existing snippet.

    :raises APIException: If the parameters "key" and/or "value" were not in
                          the request. (MISSING_REQUIRED_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not user snippets
                                 (INCORRECT_PERMISSION)
    """
    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('value', str), ('key', str)])
    value = t.cast(str, content['value'])

    snippet: t.Optional[models.Snippet] = models.Snippet.query.filter_by(
        user_id=current_user.id, key=content['key']).first()
    if snippet is None:
        snippet = models.Snippet(key=content['key'],
                                 value=content['value'],
                                 user=current_user)
        db.session.add(snippet)
    else:
        snippet.value = value

    db.session.commit()

    return jsonify(snippet, status_code=201)
Пример #3
0
def add_course() -> JSONResponse[models.Course]:
    """Add a new :class:`.models.Course`.

    .. :quickref: Course; Add a new course.

    :returns: A response containing the JSON serialization of the new course

    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not create courses.
                                 (INCORRECT_PERMISSION)
    :raises APIException: If the parameter "name" is not in the request.
        (MISSING_REQUIRED_PARAM)
    """
    content = get_json_dict_from_request()
    ensure_keys_in_dict(content, [('name', str)])
    name = t.cast(str, content['name'])

    new_course = models.Course.create_and_add(name)
    db.session.commit()

    role = models.CourseRole.get_initial_course_role(new_course)
    current_user.courses[new_course.id] = role
    db.session.commit()

    return jsonify(new_course)
Пример #4
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,
        )
    })
Пример #5
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)
Пример #6
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(),
        replace_log=lambda k, v: '<PASSWORD>' if 'password' in k else v
    )
    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'])

    user = helpers.get_or_404(models.User, user_id)
    validate.ensure_valid_password(password, user=user)

    user.reset_password(token, password)
    db.session.commit()
    return jsonify(
        {
            'access_token':
                flask_jwt.create_access_token(
                    identity=user.id,
                    fresh=True,
                )
        }
    )
Пример #7
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(),
        replace_log=lambda k, v: f'<PASSWORD "{k}">' if 'password' in k else v
    )

    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.email != email:
        auth.ensure_permission(GPerm.can_edit_own_info)
        _ensure_password('email')
        validate.ensure_valid_email(email)
        current_user.email = email

    if new_password != '':
        auth.ensure_permission(GPerm.can_edit_own_password)
        _ensure_password('password')
        validate.ensure_valid_password(new_password, user=current_user)
        current_user.password = new_password

    if current_user.name != name:
        auth.ensure_permission(GPerm.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

    db.session.commit()
    return make_empty_response()
Пример #8
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,
        )
Пример #9
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,
        )
    })
Пример #10
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()
Пример #11
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()
Пример #12
0
def user_patch_handle_send_reset_email() -> EmptyResponse:
    """Handle the ``reset_email`` type for the PATCH login route.

    :returns: An empty response.
    """
    data = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(data, [('username', str)])

    mail.send_reset_password_email(
        helpers.filter_single_or_404(models.User,
                                     models.User.username == data['username']))
    db.session.commit()

    return make_empty_response()
Пример #13
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
Пример #14
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)
Пример #15
0
def add_role(course_id: int) -> EmptyResponse:
    """Add a new :class:`.models.CourseRole` to the given
    :class:`.models.Course`.

    .. :quickref: Course; Add a new course role to a course.

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

    :<json str name: The name of the new course role.

    :raises APIException: If the name parameter was not in the request.
                          (MISSING_REQUIRED_PARAM)
    :raises APIException: If the course with the given id was not found.
                          (OBJECT_NOT_FOUND)
    :raises APIException: If the course already has a role with the submitted
                          name. (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)

    content = get_json_dict_from_request()

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

    course = helpers.get_or_404(
        models.Course,
        course_id,
        also_error=lambda c: c.virtual,
    )

    if models.CourseRole.query.filter_by(
            name=name, course_id=course_id).first() is not None:
        raise APIException(
            'This course already has a role with this name',
            'The course "{}" already has a role named "{}"'.format(
                course_id, name), APICodes.INVALID_PARAM, 400)

    role = models.CourseRole(name=name, course=course, hidden=False)
    db.session.add(role)
    db.session.commit()

    return make_empty_response()
Пример #16
0
def add_new_rubric_row(
    assig: models.Assignment, header: str, description: str,
    items: t.Sequence[JSONType]
) -> int:
    """Add new rubric row to the assignment.

    :param assig: The assignment to add the rubric row to
    :param header: The name of the new rubric row.
    :param description: The description of the new rubric row.
    :param items: The items (:py:class:`.models.RubricItem`) that should be
        added to the new rubric row, the JSONType should be a dictionary with
        the keys ``description`` (:py:class:`str`), ``header``
        (:py:class:`str`) and ``points`` (:py:class:`float`).
    :returns: The amount of items in this row.

    :raises APIException: If `description` or `points` fields are not in
        `item`. (INVALID_PARAM)
    """
    rubric_row = models.RubricRow(
        assignment_id=assig.id, header=header, description=description
    )
    for item in items:
        item = ensure_json_dict(item)
        ensure_keys_in_dict(
            item,
            [('description', str),
             ('header', str),
             ('points', numbers.Real)]
        )
        description = t.cast(str, item['description'])
        header = t.cast(str, item['header'])
        points = t.cast(numbers.Real, item['points'])
        rubric_item = models.RubricItem(
            rubricrow_id=rubric_row.id,
            header=header,
            description=description,
            points=points
        )
        db.session.add(rubric_item)
        rubric_row.items.append(rubric_item)
    db.session.add(rubric_row)

    return len(items)
Пример #17
0
def create_course_snippet(
        course_id: int) -> JSONResponse[models.CourseSnippet]:
    """Add or modify a :class:`.models.CourseSnippet` by key.

    .. :quickref: CourseSnippet; Add or modify a course snippet.

    :returns: A response containing the JSON serialized snippet and return
              code 201.
    :<json str value: The new value of the snippet.
    :<json str key: The key of the new or existing snippet.

    :raises APIException: If the parameters "key", "value", and/or "course_id"
        were not in the request. (MISSING_REQUIRED_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not use snippets
        (INCORRECT_PERMISSION)
    """
    auth.ensure_permission(CPerm.can_manage_course_snippets, course_id)
    content = get_json_dict_from_request()
    ensure_keys_in_dict(content, [('value', str), ('key', str)])
    key = t.cast(str, content['key'])
    value = t.cast(str, content['value'])

    course = helpers.get_or_404(models.Course, course_id)
    snippet = models.CourseSnippet.query.filter_by(
        course=course,
        key=key,
    ).first()

    if snippet is None:
        snippet = models.CourseSnippet(
            course=course,
            key=key,
            value=value,
        )
        db.session.add(snippet)
    else:
        snippet.value = value

    db.session.commit()

    return jsonify(snippet, status_code=201)
Пример #18
0
def set_role_permission(role_id: int) -> EmptyResponse:
    """Update the :class:`.models.Permission` of a given
    :class:`.models.Role`.

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

    :param int role_id: The id of the (non 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 PermissionException: If the current user does not have the
        ``can_manage_site_users`` permission. (INCORRECT_PERMISSION)
    """
    content = ensure_json_dict(request.get_json())

    ensure_keys_in_dict(content, [('permission', str), ('value', bool)])

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

    if (current_user.role_id == role_id
            and perm_name == 'can_manage_site_users'):
        raise APIException(
            'You cannot remove this permission from your own role',
            ('The current user is in role {} which'
             ' cannot remove "can_manage_site_users"').format(role_id),
            APICodes.INCORRECT_PERMISSION, 403)

    perm = helpers.filter_single_or_404(models.Permission,
                                        models.Permission.name == perm_name,
                                        ~models.Permission.course_permission)

    role = helpers.get_or_404(models.Role, role_id)

    role.set_permission(perm, value)

    db.session.commit()

    return make_empty_response()
Пример #19
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(CPerm.can_create_assignment, course_id)

    content = get_json_dict_from_request()
    ensure_keys_in_dict(content, [('name', str)])
    name = t.cast(str, content['name'])

    course = helpers.get_or_404(
        models.Course,
        course_id,
        also_error=lambda c: c.virtual,
    )

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

    assig = models.Assignment(
        name=name,
        course=course,
        is_lti=False,
    )
    db.session.add(assig)
    db.session.commit()

    return jsonify(assig)
Пример #20
0
def select_rubric_items(submission_id: int, ) -> EmptyResponse:
    """Select the given rubric items for the given submission.

    .. :quickref: Submission; Select multiple rubric items.

    :param submission_id: The submission to unselect the item for.

    :>json array items: The ids of the rubric items you want to select.

    :returns: Nothing.

    :raises APIException: If the assignment of a given item does not belong to
        the assignment of the given submission. of the submission
        (INVALID_PARAM).
    :raises PermissionException: If the current user cannot grace work
        (INCORRECT_PERMISSION).
    """
    submission = helpers.get_or_404(models.Work, submission_id)

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

    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('items', list)])
    item_ids = t.cast(list, content['items'])

    items = []
    for item_id in item_ids:
        items.append(helpers.get_or_404(models.RubricItem, item_id))

    if any(item.rubricrow.assignment_id != submission.assignment_id
           for item in items):
        raise APIException(
            'Selected rubric item is not coupled to the given submission',
            f'A given item of "{", ".join(str(i) for i in item_ids)}"'
            f' does not belong to assignment "{submission.assignment_id}"',
            APICodes.INVALID_PARAM, 400)

    submission.select_rubric_items(items, current_user, True)
    db.session.commit()

    return make_empty_response()
Пример #21
0
def get_course_permissions(
) -> JSONResponse[t.Union[_PermMap, t.Mapping[int, _PermMap]]]:
    """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.

    :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.
    """
    ensure_keys_in_dict(request.args, [('type', str)])
    permission_type = t.cast(str, request.args['type']).lower()

    if permission_type == 'global':
        return jsonify(current_user.get_all_permissions())
    elif permission_type == 'course':
        # Make sure at least one permission is present
        ensure_keys_in_dict(request.args, [('permission', str)])
        perms = t.cast(t.List[str], request.args.getlist('permission'))
        return jsonify(current_user.get_permissions_in_courses(perms))
    else:
        raise APIException(
            'Invalid permission type given',
            f'The given type "{permission_type}" is not "global" or "course"',
            APICodes.INVALID_PARAM,
            400,
        )
Пример #22
0
def patch_snippet(snippet_id: int) -> EmptyResponse:
    """Modify the :class:`.models.Snippet` with the given id.

    .. :quickref: Snippet; 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)
    """
    content = ensure_json_dict(request.get_json())

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

    snip = helpers.get_or_404(models.Snippet, snippet_id)

    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)

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

    return make_empty_response()
Пример #23
0
def update_submission_grader(submission_id: int) -> EmptyResponse:
    """Change the assigned grader of the given submission.

    .. :quickref: Submission; Update grader for the submission.

    :returns: Empty response and a 204 status.

    :>json int user_id: Id of the new grader. This is a required parameter.

    :raises PermissionException: If the logged in user cannot manage the
        course of the submission. (INCORRECT_PERMISSION)
    :raises APIException: If the new grader does not have the correct
        permission to grade this submission. (INCORRECT_PERMISSION)
    """
    work = helpers.get_or_404(models.Work, submission_id)
    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('user_id', int)])
    user_id = t.cast(int, content['user_id'])

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

    grader = helpers.get_or_404(models.User, user_id)
    if not grader.has_permission('can_grade_work', work.assignment.course_id):
        raise APIException(
            f'User "{grader.name}" doesn\'t have the required permission',
            f'User "{grader.name}" doesn\'t have permission "can_grade_work"',
            APICodes.INCORRECT_PERMISSION, 400)

    work.assignee = grader
    work.assignment.set_graders_to_not_done(
        [grader.id],
        send_mail=grader.id != current_user.id,
        ignore_errors=True,
    )
    db.session.commit()

    return make_empty_response()
Пример #24
0
def process_rubric_row(
    assig: models.Assignment,
    row: JSONType,
) -> t.Tuple[t.Optional[int], t.Optional[str]]:
    """Process a single rubric row updating or adding it.

    This function works on the input json data. It makes sure that the input
    has the correct format and dispatches it to the necessary functions.

    :param assig: The assignment this rubric row should be added to.
    :returns: A tuple with as the first element the id of the rubric row that
        has been processed (this is ``None`` for a new row) and as second item
        a string that describes were an error occurred if such an error did
        occur.
    """
    row = ensure_json_dict(row)
    ensure_keys_in_dict(
        row, [('description', str),
              ('header', str),
              ('items', list)]
    )
    header = t.cast(str, row['header'])
    description = t.cast(str, row['description'])
    items = t.cast(list, row['items'])
    row_id = None

    if 'id' in row:
        ensure_keys_in_dict(row, [('id', int)])
        row_id = t.cast(int, row['id'])
        row_amount = patch_rubric_row(header, description, row_id, items)
    else:
        row_amount = add_new_rubric_row(assig, header, description, items)

    # No items were added which is wrong
    err = header if row_amount == 0 else None
    return row_id, err
Пример #25
0
def test_ensure_keys_in_dict():
    class Enum1(enum.Enum):
        a = 1
        b = '2'

    class Enum2(enum.Enum):
        a = '4'
        c = 5

    h.ensure_keys_in_dict({'hello': 'a'}, [('hello', Enum1)])
    h.ensure_keys_in_dict({'hello': 'a'}, [('hello', Enum2)])

    with pytest.raises(APIException) as err:
        h.ensure_keys_in_dict({'hello': 'b'}, [('hello', Enum2)])

    assert 'should be a member of' in err.value.description
    assert '(= a, c)' in err.value.description

    with pytest.raises(APIException) as err:
        h.ensure_keys_in_dict({'hello': 'c'}, [('hello', Enum1)])

    assert 'should be a member of' in err.value.description
    assert '(= a, b)' in err.value.description
Пример #26
0
def set_course_permission_user(
        course_id: int) -> t.Union[EmptyResponse, JSONResponse[_UserCourse]]:
    """Set the :class:`.models.CourseRole` of a :class:`.models.User` in the
    given :class:`.models.Course`.

    .. :quickref: Course; Change the course role for a user.

    :param int course_id: The id of the course
    :returns: If the user_id parameter is set in the request the response will
              be empty with return code 204. Otherwise the response will
              contain the JSON serialized user and course role with return code
              201

    :raises APIException: If the parameter role_id or not at least one of
                          user_id and user_email are in the request.
                          (MISSING_REQUIRED_PARAM)
    :raises APIException: If no role with the given role_id or no user
                          with the supplied parameters exists.
                          (OBJECT_ID_NOT_FOUND)
    :raises APIException: If the user was selected by email and the user is
                          already in the course. (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)

    .. todo::
        This function should probability be splitted.
    """
    auth.ensure_permission(CPerm.can_edit_course_users, course_id)

    content = get_json_dict_from_request()
    ensure_keys_in_dict(content, [('role_id', int)])
    role_id = t.cast(int, content['role_id'])

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

    res: t.Union[EmptyResponse, JSONResponse[_UserCourse]]

    if 'user_id' in content:
        with get_from_map_transaction(content) as [get, _]:
            user_id = get('user_id', int)

        user = helpers.get_or_404(models.User, user_id)

        if user.id == current_user.id:
            raise APIException(
                'You cannot change your own role',
                'The user requested and the current user are the same',
                APICodes.INCORRECT_PERMISSION, 403)

        res = make_empty_response()
    elif 'username' in content:
        with get_from_map_transaction(content) as [get, _]:
            username = get('username', str)

        user = helpers.filter_single_or_404(models.User,
                                            models.User.username == username)

        if course_id in user.courses:
            raise APIException(
                'The specified user is already in this course',
                'The user {} is in course {}'.format(user.id, course_id),
                APICodes.INVALID_PARAM, 400)

        res = jsonify({
            'User': user,
            'CourseRole': role,
        }, status_code=201)
    else:
        raise APIException(
            'None of the keys "user_id" or "role_id" were found',
            ('The given content ({})'
             ' does  not contain "user_id" or "user_email"').format(content),
            APICodes.MISSING_REQUIRED_PARAM, 400)

    if user.is_test_student:
        raise APIException('You cannot change the role of a test student',
                           f'The user {user.id} is a test student',
                           APICodes.INVALID_PARAM, 400)

    user.courses[role.course_id] = role
    db.session.commit()
    return res
Пример #27
0
def create_group_set(course_id: int) -> JSONResponse[models.GroupSet]:
    """Create or update a :class:`.models.GroupSet` in the given course id.

    .. :quickref: Course; Create a new group set in the course.

    :>json int minimum_size: The minimum size attribute that the group set
        should have.
    :>json int maximum_size: The maximum size attribute that the group set
        should have.
    :>json int id: The id of the group to update.
    :param course_id: The id of the course in which the group set should be
        created or updated. The course id of a group set cannot change.
    :returns: The created or updated group.
    """
    auth.ensure_permission(CPerm.can_edit_group_set, course_id)
    course = helpers.get_or_404(models.Course, course_id)

    content = get_json_dict_from_request()
    ensure_keys_in_dict(content, [
        ('minimum_size', int),
        ('maximum_size', int),
    ])
    min_size = t.cast(int, content['minimum_size'])
    max_size = t.cast(int, content['maximum_size'])

    if 'id' in content:
        ensure_keys_in_dict(content, [('id', int)])
        group_set_id = t.cast(int, content['id'])
        group_set = helpers.get_or_404(
            models.GroupSet,
            group_set_id,
        )
        if group_set.course_id != course.id:
            raise APIException(
                'You cannot change the course id of a group set',
                (f'The group set {group_set.id} is '
                 f'not connected to course {course.id}'),
                APICodes.INVALID_PARAM, 400)
    else:
        group_set = models.GroupSet(course_id=course.id)
        models.db.session.add(group_set)

    if min_size <= 0:
        raise APIException('Minimum size should be larger than 0',
                           f'Minimum size "{min_size}" is <= than 0',
                           APICodes.INVALID_PARAM, 400)
    elif max_size < min_size:
        raise APIException('Maximum size is smaller than minimum size',
                           (f'Maximum size "{max_size}" is smaller '
                            f'than minimum size "{min_size}"'),
                           APICodes.INVALID_PARAM, 400)
    elif group_set.largest_group_size > max_size:
        raise APIException('There are groups larger than the new maximum size',
                           f'Some groups have more than {max_size} members',
                           APICodes.INVALID_PARAM, 400)
    elif group_set.smallest_group_size < min_size:
        raise APIException(
            'There are groups smaller than the new minimum size',
            f'Some groups have less than {min_size} members',
            APICodes.INVALID_PARAM, 400)
    group_set.minimum_size = min_size
    group_set.maximum_size = max_size

    models.db.session.commit()

    return jsonify(group_set)
Пример #28
0
def register_user() -> JSONResponse[t.Mapping[str, str]]:
    """Create a new :class:`.models.User`.

    .. :quickref: User; Create a new user by registering it.

    :<json str username: The username of the new user.
    :<json str password: The password of the new user.
    :<json str email: The email of the new user.
    :<json str name: The full name of the new user.

    :>json str access_token: The JWT token that can be used to log in the newly
        created user.

    :raises APIException: If the not all given strings are at least 1
        char. (INVALID_PARAM)
    :raises APIException: If there is already a user with the given
        username. (OBJECT_ALREADY_EXISTS)
    :raises APIException: If the given email is not a valid
        email. (INVALID_PARAM)
    """
    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('username', str), ('password', str),
                                  ('email', str), ('name', str)])
    username = t.cast(str, content['username'])
    password = t.cast(str, content['password'])
    email = t.cast(str, content['email'])
    name = t.cast(str, content['name'])

    if not all([username, password, email, name]):
        raise APIException(
            'All fields should contain at least one character',
            ('The lengths of the given password, username and '
             'email were not all larger than 1'),
            APICodes.INVALID_PARAM,
            400,
        )

    if db.session.query(
            models.User.query.filter_by(username=username).exists()).scalar():
        raise APIException(
            'The given username is already in use',
            f'The username "{username}" is taken',
            APICodes.OBJECT_ALREADY_EXISTS,
            400,
        )

    if not validate_email(email):
        raise APIException(
            'The given email is not valid',
            f'The email "{email}"',
            APICodes.INVALID_PARAM,
            400,
        )

    role = models.Role.query.filter_by(
        name=current_app.config['DEFAULT_ROLE']).one()
    user = models.User(username=username,
                       password=password,
                       email=email,
                       name=name,
                       role=role,
                       active=True)
    db.session.add(user)
    db.session.commit()

    token: str = flask_jwt.create_access_token(
        identity=user.id,
        fresh=True,
    )
    return jsonify({'access_token': token})
Пример #29
0
def create_new_file(submission_id: int) -> JSONResponse[t.Mapping[str, t.Any]]:
    """Create a new file or directory for the given submission.

    .. :quickref: Submission; Create a new file or directory for a submission.

    :param str path: The path of the new file to create. If the path ends in
        a forward slash a new directory is created and the body of the request
        is ignored, otherwise a regular file is created.

    :returns: Stat information about the new file, see
        :py:func:`.files.get_stat_information`

    :raises APIException: If the request is bigger than the maximum upload
        size. (REQUEST_TOO_LARGE)
    """
    work = helpers.get_or_404(models.Work, submission_id)
    exclude_owner = models.File.get_exclude_owner('auto',
                                                  work.assignment.course_id)

    auth.ensure_can_edit_work(work)
    if exclude_owner == FileOwner.teacher:  # we are a student
        assig = work.assignment
        new_owner = FileOwner.both if assig.is_open else FileOwner.student
    else:
        new_owner = FileOwner.teacher

    ensure_keys_in_dict(request.args, [('path', str)])

    pathname = request.args.get('path', None)
    # `create_dir` means that the last file should be a dir or not.
    patharr, create_dir = psef.files.split_path(pathname)

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

    if len(patharr) < 2:
        raise APIException('Path should contain at least a two parts',
                           f'"{pathname}" only contains {len(patharr)} parts',
                           APICodes.INVALID_PARAM, 400)

    parent = helpers.filter_single_or_404(
        models.File,
        models.File.work_id == submission_id,
        models.File.fileowner != exclude_owner,
        models.File.name == patharr[0],
        t.cast(DbColumn[int], models.File.parent_id).is_(None),
    )

    code = None
    end_idx = 0
    for idx, part in enumerate(patharr[1:]):
        code = models.File.query.filter(
            models.File.fileowner != exclude_owner,
            models.File.name == part,
            models.File.parent == parent,
        ).first()
        end_idx = idx + 1
        if code is None:
            break
        parent = code
    else:
        end_idx += 1

    def _is_last(idx: int) -> bool:
        return end_idx + idx + 1 == len(patharr)

    if _is_last(-1) or not parent.is_directory:
        raise APIException(
            'All part did already exist',
            f'The path "{pathname}" did already exist',
            APICodes.INVALID_STATE,
            400,
        )

    for idx, part in enumerate(patharr[end_idx:]):
        if _is_last(idx) and not create_dir:
            is_dir = False
            d_filename, filename = psef.files.random_file_path()
            with open(d_filename, 'w') as f:
                f.write(request.get_data(as_text=True))
        else:
            is_dir, filename = True, None
        code = models.File(
            work_id=submission_id,
            name=part,
            filename=filename,
            is_directory=is_dir,
            parent=parent,
            fileowner=new_owner,
        )
        db.session.add(code)
        parent = code
    db.session.commit()

    return jsonify(psef.files.get_stat_information(code))
Пример #30
0
 def __get_comment() -> str:
     content = ensure_json_dict(request.get_json())
     ensure_keys_in_dict(content, [('comment', str)])
     return t.cast(str, content['comment'])