示例#1
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)
示例#2
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 = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('name', str)])
    name = t.cast(str, content['name'])

    new_course = models.Course(name)
    db.session.add(new_course)
    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)
示例#3
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)
示例#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 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,
                )
        }
    )
示例#6
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()
示例#7
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()
示例#8
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,
        )
    })
示例#9
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()
示例#10
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)
示例#11
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)
示例#12
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()
示例#13
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('can_edit_course_roles', 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 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)
    db.session.add(role)
    db.session.commit()

    return make_empty_response()
示例#14
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()
示例#15
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()
示例#16
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()
示例#17
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
示例#18
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})
示例#19
0
def update_assignment(assignment_id: int) -> EmptyResponse:
    """Update the given :class:`.models.Assignment` with new values.

    :py:func:`psef.helpers.JSONResponse`

    .. :quickref: Assignment; Update assignment information.

    :<json str state: The new state of the assignment, can be `hidden`, `open`
        or `done`. (OPTIONAL)
    :<json str name: The new name of the assignment, this string should not be
        empty. (OPTIONAL)
    :<json str deadline: The new deadline of the assignment. This should be a
        ISO 8061 date without timezone information. (OPTIONAL)
    :<json str done_type: The type to determine if a assignment is done. This
        can be any value of :class:`.models.AssignmentDoneType` or
        ``null``. (OPTIONAL)
    :<json str done_email: The emails to send an email to if the assignment is
        done. Can be ``null`` to disable these emails. (OPTIONAL)
    :<json str reminder_time: The time on which graders which are causing the
        grading to be not should be reminded they have to grade. Can be
        ``null`` to disable these emails. (OPTIONAL)

    If any of ``done_type``, ``done_email`` or ``reminder_time`` is given all
    the other values should be given too.

    :param int assignment_id: The id of the assignment
    :returns: An empty response with return code 204
    :raises APIException: If an invalid value is submitted. (INVALID_PARAM)
    """
    warning = None
    assig = helpers.get_or_404(models.Assignment, assignment_id)
    content = ensure_json_dict(request.get_json())

    if 'state' in content:
        auth.ensure_permission('can_edit_assignment_info', assig.course_id)
        ensure_keys_in_dict(content, [('state', str)])
        state = t.cast(str, content['state'])

        try:
            assig.set_state(state)
        except TypeError:
            raise APIException(
                'The selected state is not valid',
                'The state {} is not a valid state'.format(state),
                APICodes.INVALID_PARAM, 400
            )

    if 'name' in content:
        auth.ensure_permission('can_edit_assignment_info', assig.course_id)
        ensure_keys_in_dict(content, [('name', str)])
        name = t.cast(str, content['name'])

        if not name:
            raise APIException(
                'The name of an assignment should be at least 1 char',
                'len({}) == 0'.format(content['name']),
                APICodes.INVALID_PARAM,
                400,
            )

        assig.name = name

    if 'deadline' in content:
        auth.ensure_permission('can_edit_assignment_info', assig.course_id)
        ensure_keys_in_dict(content, [('deadline', str)])
        deadline = t.cast(str, content['deadline'])
        assig.deadline = parsers.parse_datetime(deadline)

    if 'ignore' in content:
        auth.ensure_permission('can_edit_cgignore', assig.course_id)
        ensure_keys_in_dict(content, [('ignore', str)])
        assig.cgignore = t.cast(str, content['ignore'])

    if any(t in content for t in ['done_type', 'reminder_time', 'done_email']):
        auth.ensure_permission(
            'can_update_course_notifications',
            assig.course_id,
        )
        warning = set_reminder(assig, content) or warning

    db.session.commit()

    return make_empty_response(warning=warning)
示例#20
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('can_edit_course_users', course_id)

    content = ensure_json_dict(request.get_json())
    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:
        ensure_keys_in_dict(content, [('user_id', int)])
        user_id = t.cast(int, content['user_id'])

        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:
        ensure_keys_in_dict(content, [('username', str)])

        user = helpers.filter_single_or_404(
            models.User, models.User.username == content['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)

    user.courses[role.course_id] = role
    db.session.commit()
    return res
示例#21
0
def divide_assignments(assignment_id: int) -> EmptyResponse:
    """Assign graders to all the latest :class:`.models.Work` objects of
    the given :class:`.models.Assignment`.

    .. :quickref: Assignment; Divide a submission among given TA's.

    The redivide tries to minimize shuffles. This means that calling it twice
    with the same data is effectively a noop. If the relative weight (so the
    percentage of work) of a user doesn't change it will not lose or gain any
    submissions.

    .. warning::

        If a user was marked as done grading and gets assigned new submissions
        this user is marked as not done and gets a notification email!

    :<json dict graders: A mapping that maps user ids (strings) and the new
        weight they should get (numbers).
    :param int assignment_id: The id of the assignment
    :returns: An empty response with return code 204

    :raises APIException: If no assignment with given id exists or the
                          assignment has no submissions. (OBJECT_ID_NOT_FOUND)
    :raises APIException: If there was no grader in the request.
                          (MISSING_REQUIRED_PARAM)
    :raises APIException: If some grader id is invalid or some grader does not
                          have the permission to grade the assignment.
                          (INVALID_PARAM)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user is not allowed to divide
                                 submissions for this assignment.
                                 (INCORRECT_PERMISSION)
    """
    assignment = helpers.get_or_404(models.Assignment, assignment_id)

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

    content = ensure_json_dict(request.get_json())
    ensure_keys_in_dict(content, [('graders', dict)])
    graders = {}

    for user_id, weight in t.cast(dict, content['graders']).items():
        if not (isinstance(user_id, str) and isinstance(weight, (float, int))):
            raise APIException(
                'Given graders weight or id is invalid',
                'Both key and value in graders object should be integers',
                APICodes.INVALID_PARAM, 400
            )
        graders[int(user_id)] = weight

    if graders:
        users = helpers.filter_all_or_404(
            models.User,
            models.User.id.in_(graders.keys())  # type: ignore
        )
    else:
        models.Work.query.filter_by(assignment_id=assignment.id).update(
            {
                'assigned_to': None
            }
        )
        assignment.assigned_graders = {}
        db.session.commit()
        return make_empty_response()

    if len(users) != len(graders):
        raise APIException(
            'Invalid grader id given', 'Invalid grader (=user) id given',
            APICodes.INVALID_PARAM, 400
        )

    can_grade_work = helpers.filter_single_or_404(
        models.Permission, models.Permission.name == 'can_grade_work'
    )
    for user in users:
        if not user.has_permission(can_grade_work, assignment.course_id):
            raise APIException(
                'Selected grader has no permission to grade',
                f'The grader {user.id} has no permission to grade',
                APICodes.INVALID_PARAM, 400
            )

    assignment.divide_submissions([(user, graders[user.id]) for user in users])
    db.session.commit()

    return make_empty_response()
示例#22
0
def add_assignment_rubric(assignment_id: int
                          ) -> JSONResponse[t.Sequence[models.RubricRow]]:
    """Add or update rubric of an assignment.

    .. :quickref: Assignment; Add a rubric to an assignment.

    :>json array rows: An array of rows. Each row should be an object that
        should contain a ``header`` mapping to a string, a ``description`` key
        mapping to a string, an ``items`` key mapping to an array and it may
        contain an ``id`` key mapping to the current id of this row. The items
        array should contain objects with a ``description`` (string),
        ``header`` (string) and ``points`` (number) and optionally an ``id`` if
        you are modifying an existing item in an existing row.
    :>json number max_points: Optionally override the maximum amount of points
        you can get for this rubric. By passing ``null`` you reset this value,
        by not passing it you keep its current value. (OPTIONAL)

    :param int assignment_id: The id of the assignment
    :returns: An empty response with return code 204

    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user is not allowed to manage rubrics.
                                (INCORRECT_PERMISSION)
    """
    assig = helpers.get_or_404(models.Assignment, assignment_id)

    auth.ensure_permission('manage_rubrics', assig.course_id)
    content = ensure_json_dict(request.get_json())

    if 'max_points' in content:
        helpers.ensure_keys_in_dict(
            content, [('max_points',
                       (type(None), int, float))]
        )
        max_points = t.cast(t.Optional[float], content['max_points'])
        if max_points is not None and max_points <= 0:
            raise APIException(
                'The max amount of points you can '
                'score should be higher than 0',
                f'The max amount of points was {max_points} which is <= 0',
                APICodes.INVALID_STATE, 400
            )
        assig.fixed_max_rubric_points = max_points

    if 'rows' in content:
        with db.session.begin_nested():
            helpers.ensure_keys_in_dict(content, [('rows', list)])
            rows = t.cast(list, content['rows'])

            id_wrong = [process_rubric_row(assig, row) for row in rows]
            seen = set(
                item_id for item_id, _ in id_wrong if item_id is not None
            )
            wrong_rows = set(err for _, err in id_wrong if err is not None)

            if wrong_rows:
                single = len(wrong_rows) == 1
                raise APIException(
                    'The row{s} {rows} do{es} not contain at least one item.'.
                    format(
                        rows=', and '.join(wrong_rows),
                        s='' if single else 's',
                        es='es' if single else '',
                    ), 'Not all rows contain at least one '
                    'item after updating the rubric.', APICodes.INVALID_STATE,
                    400
                )

            assig.rubric_rows = [
                row for row in assig.rubric_rows
                if row is None or row.id in seen
            ]

            db.session.flush()
            max_points = assig.max_rubric_points

            if max_points is None or max_points <= 0:
                raise APIException(
                    'The max amount of points you can '
                    'score should be higher than 0',
                    f'The max amount of points was {max_points} which is <= 0',
                    APICodes.INVALID_STATE, 400
                )

    db.session.commit()
    return jsonify(assig.rubric_rows)
示例#23
0
def login() -> ExtendedJSONResponse[
    t.Mapping[str, t.Union[t.MutableMapping[str, t.Any], 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 username: The username of the user to log in.
    :<json str password: The password of the user to log in.
    :query with_permissions: Setting this to true will add the key
        ``permissions`` to the user. The value will be a mapping indicating
        which global permissions this user has.

    :>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 no user with username exists or the password is
        wrong. (LOGIN_FAILURE)
    :raises APIException: If the user with the given username and password is
        inactive. (INACTIVE_USER)
    """
    data = ensure_json_dict(
        request.get_json(),
        replace_log=lambda k, v: '<PASSWORD>' if k == 'password' else v
    )
    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,
        ~models.User.is_test_student,
    ).first()

    if user is None or user.password != password:
        raise APIException(
            'The supplied username or password is wrong.', (
                f'The user with username "{username}" does not exist '
                'or has a different password'
            ), 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
        )

    # Check if the current password is safe, and add a warning to the response
    # if it is not.
    try:
        validate.ensure_valid_password(password, user=user)
    except WeakPasswordException:
        add_warning(
            (
                'Your password does not meet the requirements, consider '
                'changing it.'
            ),
            APIWarnings.WEAK_PASSWORD,
        )

    auth.set_current_user(user)
    json_user = user.__extended_to_json__()

    if request_arg_true('with_permissions'):
        json_user['permissions'] = GPerm.create_map(user.get_all_permissions())

    return extended_jsonify(
        {
            'user': json_user,
            'access_token':
                flask_jwt.create_access_token(
                    identity=user.id,
                    fresh=True,
                )
        }
    )
示例#24
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'])
示例#25
0
def patch_rubric_row(
    header: str,
    description: str,
    rubric_row_id: int,
    items: t.Sequence[JSONType],
) -> int:
    """Update a rubric row of the assignment.

    .. note::

      All items not present in the given ``items`` array will be deleted from
      the rubric row.

    :param rubric_row_id: The id of the rubric row that should be updated.
    :param items: The items (:py:class:`models.RubricItem`) that should be
        added or updated. The format should be the same as in
        :py:func:`add_new_rubric_row` with the addition that if ``id`` is in
        the item the item will be updated instead of added.
    :returns: The amount of items in the resulting row.

    :raises APIException: If `description` or `points` fields are not in
        `item`. (INVALID_PARAM)
    :raises APIException: If no rubric item with given id exists.
        (OBJECT_ID_NOT_FOUND)
    """
    rubric_row = helpers.get_or_404(models.RubricRow, rubric_row_id)

    rubric_row.header = header
    rubric_row.description = description

    seen = set()

    for item in items:
        item = ensure_json_dict(item)
        ensure_keys_in_dict(
            item,
            [('description', str),
             ('points', numbers.Real),
             ('header', str)]
        )
        description = t.cast(str, item['description'])
        header = t.cast(str, item['header'])
        points = t.cast(numbers.Real, item['points'])

        if 'id' in item:
            seen.add(item['id'])
            rubric_item = helpers.get_or_404(models.RubricItem, item['id'])

            rubric_item.header = header
            rubric_item.description = description
            rubric_item.points = float(points)
        else:
            rubric_item = models.RubricItem(
                rubricrow_id=rubric_row.id,
                description=description,
                header=header,
                points=points
            )
            db.session.add(rubric_item)
            rubric_row.items.append(rubric_item)

    rubric_row.items = [
        item for item in rubric_row.items if item.id is None or item.id in seen
    ]

    return len(rubric_row.items)
示例#26
0
def start_linting(assignment_id: int) -> JSONResponse[models.AssignmentLinter]:
    """Starts running a specific linter on all the latest submissions
    (:class:`.models.Work`) of the given :class:`.models.Assignment`.

    .. :quickref: Assignment; Start linting an assignment with a given linter.

    :param int assignment_id: The id of the assignment
    :returns: A response containing the serialized linter that is started by
              the request

    :raises APIException: If a required parameter is missing.
                          (MISSING_REQUIRED_PARAM)
    :raises APIException: If a linter of the same name is already running on
                          the assignment. (INVALID_STATE)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not user linters in this
                                 course. (INCORRECT_PERMISSION)
    """
    content = ensure_json_dict(request.get_json())

    assig = helpers.get_or_404(models.Assignment, assignment_id)
    auth.ensure_permission('can_use_linter', assig.course_id)

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

    if db.session.query(
        models.LinterInstance.query.filter(
            models.AssignmentLinter.assignment_id == assignment_id,
            models.AssignmentLinter.name == content['name']
        ).exists()
    ).scalar():
        raise APIException(
            'There is still a linter instance running',
            'There is a linter named "{}" running for assignment {}'.format(
                content['name'], assignment_id
            ), APICodes.INVALID_STATE, 409
        )

    res = models.AssignmentLinter.create_linter(assignment_id, name, cfg)

    db.session.add(res)
    db.session.commit()

    try:
        linter_cls = linters.get_linter_by_name(name)
    except ValueError:
        raise APIException(
            f'No linter named "{name}" was found',
            (
                f'There is no subclass of the "Linter"'
                f'class with the name "{name}"'
            ),
            APICodes.OBJECT_NOT_FOUND,
            404,
        )
    if linter_cls.RUN_LINTER:
        for i in range(0, len(res.tests), 10):
            psef.tasks.lint_instances(
                name,
                cfg,
                [t.id for t in res.tests[i:i + 10]],
            )
    else:
        for linter_inst in res.tests:
            linter_inst.state = models.LinterState.done
        db.session.commit()

    return jsonify(res)