Beispiel #1
0
def update_role(course_id: int, role_id: int) -> EmptyResponse:
    """Update the :class:`.models.Permission` of a given
    :class:`.models.CourseRole` in the given :class:`.models.Course`.

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

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

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

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

    auth.ensure_permission('can_edit_course_roles', course_id)

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

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

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

    role.set_permission(perm, value)

    db.session.commit()

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

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

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

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

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

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

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

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

    return make_empty_response()
Beispiel #3
0
def remove_comment(code_id: int, line: int) -> EmptyResponse:
    """Removes the given :class:`.models.Comment` in the given
    :class:`.models.File`

    .. :quickref: Code; Remove a comment.

    :param int code_id: The id of the code file
    :param int line: The line number of the comment
    :returns: An empty response with return code 204

    :raises APIException: If there is no comment at the given line number.
                          (OBJECT_NOT_FOUND)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the user can not can grade work in the
                                 attached course. (INCORRECT_PERMISSION)
    """
    comment = helpers.filter_single_or_404(
        models.Comment, models.Comment.file_id == code_id,
        models.Comment.line == line
    )

    auth.ensure_permission(
        'can_grade_work', comment.file.work.assignment.course_id
    )
    db.session.delete(comment)
    db.session.commit()

    return make_empty_response()
Beispiel #4
0
def create_or_edit_registration_link(
        course_id: int) -> JSONResponse[models.CourseRegistrationLink]:
    """Create or edit a registration link.

    .. :quickref: Course; Create or edit a registration link for a course.

    :param course_id: The id of the course in which this link should enroll
        users.
    :>json id: The id of the link to edit, omit to create a new link.
    :>json role_id: The id of the role that users should get when registering
        with this link.
    :>json expiration_date: The date this link should stop working, this date
        should be in ISO8061 format without any timezone information, as it
        will be interpret as a UTC date.
    :returns: The created or edited link.
    """
    course = helpers.get_or_404(models.Course,
                                course_id,
                                also_error=lambda c: c.virtual)
    auth.ensure_permission(CPerm.can_edit_course_users, course_id)

    with get_from_map_transaction(
            get_json_dict_from_request()) as [get, opt_get]:
        expiration_date = get('expiration_date', str)
        role_id = get('role_id', int)
        link_id = opt_get('id', str, default=None)

    if link_id is None:
        link = models.CourseRegistrationLink(course=course)
        db.session.add(link)
    else:
        link = helpers.filter_single_or_404(
            models.CourseRegistrationLink,
            models.CourseRegistrationLink.id == uuid.UUID(link_id),
            also_error=lambda l: l.course_id != course.id)

    link.course_role = helpers.get_or_404(
        models.CourseRole,
        role_id,
        also_error=lambda r: r.course_id != course.id)
    link.expiration_date = parsers.parse_datetime(expiration_date)
    if link.expiration_date < helpers.get_request_start_time():
        helpers.add_warning('The link has already expired.',
                            APIWarnings.ALREADY_EXPIRED)

    if link.course_role.has_permission(CPerm.can_edit_course_roles):
        helpers.add_warning(
            ('Users that register with this link will have the permission'
             ' to give themselves more permissions.'),
            APIWarnings.DANGEROUS_ROLE)

    db.session.commit()
    return jsonify(link)
Beispiel #5
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()
Beispiel #6
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)
    """
    course = helpers.get_or_404(models.Course, course_id)
    auth.CoursePermissions(course).ensure_may_edit_roles()

    with helpers.get_from_request_transaction() as [get, _]:
        value = get('value', bool)
        permission = get('permission', CPerm)

    role = helpers.filter_single_or_404(
        models.CourseRole,
        models.CourseRole.course == course,
        models.CourseRole.id == role_id,
        also_error=lambda r: r.hidden,
    )

    if (current_user.courses[course_id].id == role.id
            and permission == CPerm.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(permission, value)

    db.session.commit()

    return make_empty_response()
Beispiel #7
0
def get_zip(work: models.Work,
            exclude_owner: FileOwner) -> t.Mapping[str, str]:
    """Return a :class:`.models.Work` as a zip file.

    :param work: The submission which should be returns as zip file.
    :param exclude_owner: The owner to exclude from the files in the zip. So if
        this is `teacher` only files owned by `student` and `both` will be in
        the zip.
    :returns: A object with two keys: ``name`` where the value is the name
        which can be given to ``GET - /api/v1/files/<name>`` and
        ``output_name`` which is the resulting file should be named.

    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If submission does not belong to the current
                                 user and the user can not view files in the
                                 attached course. (INCORRECT_PERMISSION)
    """
    auth.ensure_can_view_files(work, exclude_owner == FileOwner.student)

    code = helpers.filter_single_or_404(
        models.File,
        models.File.work_id == work.id,
        t.cast(DbColumn[int], models.File.parent_id).is_(None),
    )

    path, name = psef.files.random_file_path('MIRROR_UPLOAD_DIR')

    with open(
            path,
            'w+b',
    ) as f, tempfile.TemporaryDirectory(
            suffix='dir', ) as tmpdir, zipfile.ZipFile(
                f,
                'w',
                compression=zipfile.ZIP_DEFLATED,
            ) as zipf:
        # Restore the files to tmpdir
        psef.files.restore_directory_structure(code, tmpdir, exclude_owner)

        zipf.write(tmpdir, code.name)

        for root, _dirs, files in os.walk(tmpdir):
            for file in files:
                path = os.path.join(root, file)
                zipf.write(path, path[len(tmpdir):])

    return {
        'name': name,
        'output_name': f'{work.assignment.name}-{work.user.name}-archive.zip'
    }
Beispiel #8
0
def get_code(file_id: int) -> t.Union[werkzeug.wrappers.Response, JSONResponse[
    t.Union[t.Mapping[str, str], models.File, _FeedbackMapping]
]]:
    """Get data from the :class:`.models.File` with the given id.

    .. :quickref: Code; Get code or its metadata.

    The are several options to change the data that is returned. Based on the
    argument type in the request different functions are called.

    - If ``type == 'metadata'`` the JSON serialized :class:`.models.File` is
      returned.
    - If ``type == 'file-url'`` or ``type == 'pdf'`` (deprecated) an object
      with a single key, `name`, with as value the return values of
      :py:func:`.get_file_url`.
    - If ``type == 'feedback'`` or ``type == 'linter-feedback'`` see
      :py:func:`.code.get_feedback`
    - Otherwise the content of the file is returned as plain text.

    :param int file_id: The id of the file
    :returns: A response containing a plain text file unless specified
        otherwise.

    :raises APIException: If there is not file with the given id.
                          (OBJECT_ID_NOT_FOUND)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If the file does not belong to user and the
                                 user can not view files in the attached
                                 course. (INCORRECT_PERMISSION)
    """
    file = helpers.filter_single_or_404(models.File, models.File.id == file_id)

    auth.ensure_can_view_files(file.work, file.fileowner == FileOwner.teacher)
    get_type = request.args.get('type', None)

    if get_type == 'metadata':
        return jsonify(file)
    elif get_type == 'feedback':
        return jsonify(get_feedback(file, linter=False))
    elif get_type == 'pdf' or get_type == 'file-url':
        return jsonify({'name': get_file_url(file)})
    elif get_type == 'linter-feedback':
        return jsonify(get_feedback(file, linter=True))
    else:
        contents = psef.files.get_file_contents(file)
        res: 'werkzeug.wrappers.Response' = make_response(contents)
        res.headers['Content-Type'] = 'application/octet-stream'
        return res
Beispiel #9
0
def delete_role(course_id: int, role_id: int) -> EmptyResponse:
    """Remove a :class:`.models.CourseRole` from the given
    :class:`.models.Course`.

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

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

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

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

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

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

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

    return make_empty_response()
Beispiel #10
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()
Beispiel #11
0
def get_dir_contents(
    submission_id: int
) -> t.Union[JSONResponse[psef.files.FileTree], JSONResponse[t.Mapping[
        str, t.Any]]]:
    """Return the file directory info of a file of the given submission
    (:class:`.models.Work`).

    .. :quickref: Submission; Get the directory contents for a submission.

    The default file is the root of the submission, but a specific file can be
    specified with the file_id argument in the request.

    :param int submission_id: The id of the submission
    :returns: A response with the JSON serialized directory structure as
        content and return code 200. For the exact structure see
        :py:meth:`.File.list_contents`. If path is given the return value will
        be stat datastructure, see :py:func:`.files.get_stat_information`.

    :query int file_id: The file id of the directory to get. If this is not
        given the parent directory for the specified submission is used.
    :query str path: The path that should be searched. The ``file_id`` query
        parameter is used if both ``file_id`` and ``path`` are present.
    :query str owner: The type of files to list, if set to `teacher` only
        teacher files will be listed, otherwise only student files will be
        listed.

    :raise APIException: If the submission with the given id does not exist or
                         when a file id was specified no file with this id
                         exists. (OBJECT_ID_NOT_FOUND)
    :raises APIException: wWhen a file id is specified and the submission id
                          does not match the submission id of the file.
                          (INVALID_URL)
    :raises APIException: When a file id is specified and the file with that id
                          is not a directory. (OBJECT_WRONG_TYPE)
    :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN)
    :raises PermissionException: If submission does not belong to the current
                                 user and the user can not view files in the
                                 attached course. (INCORRECT_PERMISSION)
    """
    work = helpers.get_or_404(models.Work, submission_id)

    file_id = request.args.get('file_id', False)
    path = request.args.get('path', False)

    exclude_owner = models.File.get_exclude_owner(
        request.args.get('owner', None),
        work.assignment.course_id,
    )

    auth.ensure_can_view_files(work, exclude_owner == FileOwner.student)

    if file_id:
        file = helpers.filter_single_or_404(
            models.File,
            models.File.id == file_id,
            models.File.work_id == work.id,
        )
    elif path:
        found_file = work.search_file(path, exclude_owner)
        return jsonify(psef.files.get_stat_information(found_file))
    else:
        file = helpers.filter_single_or_404(
            models.File, models.File.work_id == submission_id,
            t.cast(DbColumn[int], models.File.parent_id).is_(None),
            models.File.fileowner != exclude_owner)

    if not file.is_directory:
        raise APIException('File is not a directory',
                           f'The file with code {file.id} is not a directory',
                           APICodes.OBJECT_WRONG_TYPE, 400)

    return jsonify(file.list_contents(exclude_owner))
Beispiel #12
0
def upload_work(assignment_id: int) -> JSONResponse[models.Work]:
    """Upload one or more files as :class:`.models.Work` to the given
    :class:`.models.Assignment`.

    .. :quickref: Assignment; Create work by uploading a file.

    :query ignored_files: How to handle ignored files. The options are:
        ``ignore``: this the default, sipmly do nothing about ignored files,
        ``delete``: delete the ignored files, ``error``: raise an
        :py:class:`.APIException` when there are ignored files in the archive.
    :query author: The username of the user that should be the author of this
        new submission. Simply don't give this if you want to be the author.

    :param int assignment_id: The id of the assignment
    :returns: A JSON serialized work and with the status code 201.

    :raises APIException: If the request is bigger than the maximum upload
        size. (REQUEST_TOO_LARGE)
    :raises APIException: If there was no file in the request.
        (MISSING_REQUIRED_PARAM)
    :raises APIException: If some file was under the wrong key or some filename
        is empty. (INVALID_PARAM)
    """
    files = get_submission_files_from_request(check_size=True)
    assig = helpers.get_or_404(models.Assignment, assignment_id)
    given_author = request.args.get('author', None)

    if given_author is None:
        author = current_user
    else:
        author = helpers.filter_single_or_404(
            models.User,
            models.User.username == given_author,
        )

    auth.ensure_can_submit_work(assig, author)

    work = models.Work(assignment=assig, user_id=author.id)
    work.divide_new_work()
    db.session.add(work)

    try:
        raise_or_delete = psef.files.IgnoreHandling[request.args.get(
            'ignored_files',
            'keep',
        )]
    except KeyError:  # The enum value does not exist
        raise APIException(
            'The given value for "ignored_files" is invalid',
            (
                f'The value "{request.args.get("ignored_files")}" is'
                ' not in the `IgnoreHandling` enum'
            ),
            APICodes.INVALID_PARAM,
            400,
        )

    tree = psef.files.process_files(
        files,
        force_txt=False,
        ignore_filter=IgnoreFilterManager(assig.cgignore),
        handle_ignore=raise_or_delete,
    )
    work.add_file_tree(db.session, tree)
    db.session.flush()

    if assig.is_lti:
        work.passback_grade(initial=True)
    db.session.commit()

    work.run_linter()

    return jsonify(work, status_code=201)
Beispiel #13
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
Beispiel #14
0
def send_students_an_email(course_id: int) -> JSONResponse[models.TaskResult]:
    """Sent the authors in this course an email.

    .. :quickref: Course; Send users in this course an email.

    :>json subject: The subject of the email to send, should not be empty.
    :>json body: The body of the email to send, should not be empty.
    :>json email_all_users: If true all users are emailed, except for those in
        ``usernames``. If ``false`` no users are emailed except for those in
        ``usernames``.
    :>jsonarr usernames: The usernames of the users to which you want to send
        an email (or not if ``email_all_users`` is ``true``).
    :returns: A task result that will send these emails.
    """
    course = helpers.filter_single_or_404(
        models.Course,
        models.Course.id == course_id,
        also_error=lambda c: c.virtual,
    )
    auth.ensure_permission(CPerm.can_email_students, course.id)

    with helpers.get_from_request_transaction() as [get, _]:
        subject = get('subject', str)
        body = get('body', str)
        email_all_users = get('email_all_users', bool)
        usernames: t.List[str] = get('usernames', list)

    if helpers.contains_duplicate(usernames):
        raise APIException('The given exceptions list contains duplicates',
                           'Each exception can only be mentioned once',
                           APICodes.INVALID_PARAM, 400)

    exceptions = helpers.get_in_or_error(
        models.User,
        models.User.username,
        usernames,
        same_order_as_given=True,
    )

    if any(course_id not in u.courses for u in exceptions):
        raise APIException(
            'Not all given users are enrolled in this course',
            f'Some given users are not enrolled in course {course_id}',
            APICodes.INVALID_PARAM, 400)

    if not (subject and body):
        raise APIException(
            'Both a subject and body should be given',
            (f'One or both of the given subject ({subject}) or body'
             f' ({body}) is empty'), APICodes.INVALID_PARAM, 400)

    if email_all_users:
        recipients = course.get_all_users_in_course(
            include_test_students=False).filter(
                models.User.id.notin_([e.id for e in exceptions
                                       ])).with_entities(models.User).all()
    else:
        recipients = exceptions
        # The test student cannot be a member of a group, so we do not need to
        # run this on the expanded group members, and we also do not want to
        # run it when `email_all_users` is true because in that case we let the
        # DB handle it for us.
        if any(r.is_test_student for r in recipients):
            raise APIException('Cannot send an email to the test student',
                               'Test student was selected',
                               APICodes.INVALID_PARAM, 400)

    recipients = helpers.flatten(r.get_contained_users() for r in recipients)

    if not recipients:
        raise APIException(
            'At least one recipient should be given as recipient',
            'No recipients were selected', APICodes.INVALID_PARAM, 400)

    task_result = models.TaskResult(current_user)
    db.session.add(task_result)
    db.session.commit()

    psef.tasks.send_email_as_user(
        receiver_ids=[u.id for u in recipients],
        subject=subject,
        body=body,
        task_result_hex_id=task_result.id.hex,
        sender_id=current_user.id,
    )

    return JSONResponse.make(task_result)
Beispiel #15
0
def create_or_edit_registration_link(
        course_id: int) -> JSONResponse[models.CourseRegistrationLink]:
    """Create or edit an enroll link.

    .. :quickref: Course; Create or edit a registration link for a course.

    :param course_id: The id of the course in which this link should enroll
        users.
    :returns: The created or edited link.
    """
    data = rqa.FixedMapping(
        rqa.OptionalArgument(
            'id',
            rqa.RichValue.UUID,
            'The id of the link to edit, omit to create a new link.',
        ),
        rqa.RequiredArgument(
            'role_id',
            rqa.SimpleValue.int,
            """
            The id of the role that users should get when enrolling with this
            link.
            """,
        ),
        rqa.RequiredArgument(
            'expiration_date',
            rqa.RichValue.DateTime,
            'The date this link should stop working.',
        ),
        rqa.OptionalArgument(
            'allow_register',
            rqa.SimpleValue.bool,
            """
            Should students be allowed to register a new account using this
            link. For registration to actually work this feature should be
            enabled.
            """,
        ),
    ).from_flask()
    course = helpers.get_or_404(models.Course,
                                course_id,
                                also_error=lambda c: c.virtual)
    auth.CoursePermissions(course).ensure_may_edit_users()
    if course.is_lti:
        raise APIException(
            'You cannot create course enroll links in LTI courses',
            f'The course {course.id} is an LTI course', APICodes.INVALID_PARAM,
            400)

    if data.id.is_nothing:
        link = models.CourseRegistrationLink(course=course)
        db.session.add(link)
    else:
        link = helpers.filter_single_or_404(
            models.CourseRegistrationLink,
            models.CourseRegistrationLink.id == data.id.value,
            also_error=lambda l: l.course_id != course.id)

    link.course_role = helpers.get_or_404(
        models.CourseRole,
        data.role_id,
        also_error=lambda r: r.course_id != course.id)
    if data.allow_register.is_just:
        link.allow_register = data.allow_register.value

    link.expiration_date = data.expiration_date
    if link.expiration_date < helpers.get_request_start_time():
        helpers.add_warning('The link has already expired.',
                            APIWarnings.ALREADY_EXPIRED)

    if link.course_role.has_permission(CPerm.can_edit_course_roles):
        helpers.add_warning(
            ('Users that register with this link will have the permission'
             ' to give themselves more permissions.'),
            APIWarnings.DANGEROUS_ROLE)

    db.session.commit()
    return jsonify(link)
Beispiel #16
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))
Beispiel #17
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()
Beispiel #18
0
def update_code(file_id: int) -> JSONResponse[models.File]:
    """Update the content or name of the given file.

    .. :quickref: Code; Update the content or name of the given file.

    If a
    student does this request before the deadline, the owner of the file will
    be the student and the teacher (`both`), if the request is done after the
    deadline the owner of the new file will be the one doing the request while
    the old file will be removed or given to the other owner if the file was
    owned by `both`. You can give a request parameter ``operation`` to
    determine the operation:

    - If ``operation`` is ``rename`` the request should also contain a new path
      for the file under the key ``new_path``.
    - If ``operation`` is ``content`` the body of the request should contain
      the new content of the file. This operation is used if no or no valid
      operation was given.

    .. note::

      The id of the returned code object can change, but does not have to.

    :returns: The created code object.

    :raises APIException: If there is not file with the given id.
        (OBJECT_ID_NOT_FOUND)
    :raises APIException: If you do not have permission to change the given
        file. (INCORRECT_PERMISSION)
    :raises APIException: If the request is bigger than the maximum upload
        size. (REQUEST_TOO_LARGE)
    """
    # If the operation is rename it /can/ be a directory. If it is not a rename
    # (so an update of the contents) the target can **not** be a directory.
    dir_filter = None if request.args.get('operation') == 'rename' else True
    code = helpers.filter_single_or_404(
        models.File,
        models.File.id == file_id,
        models.File.is_directory != dir_filter,
    )

    auth.ensure_can_edit_work(code.work)

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

    def _update_file(
        code: models.File,
        other: models.FileOwner,
    ) -> None:
        if request.args.get('operation', None) == 'rename':
            code.rename_code(new_name, new_parent, other)
            db.session.flush()
            code.parent = new_parent
        else:
            with open(code.get_diskname(), 'wb') as f:
                f.write(request.get_data())

    if code.work.assignment.is_open and current_user.id == code.work.user_id:
        current, other = models.FileOwner.both, models.FileOwner.teacher
    elif code.work.user_id == current_user.id:
        current, other = models.FileOwner.student, models.FileOwner.teacher
    else:
        current, other = models.FileOwner.teacher, models.FileOwner.student

    if request.args.get('operation', None) == 'rename':
        ensure_keys_in_dict(request.args, [('new_path', str)])
        new_path = t.cast(str, request.args['new_path'])
        path_arr, _ = psef.files.split_path(new_path)
        new_name = path_arr[-1]
        new_parent = code.work.search_file(
            '/'.join(path_arr[:-1]) + '/', other
        )

    if code.fileowner == current:
        _update_file(code, other)
    elif code.fileowner != models.FileOwner.both:
        raise APIException(
            'This file does not belong to you',
            f'The file {code.id} belongs to {code.fileowner.name}',
            APICodes.INVALID_STATE, 403
        )
    else:
        with db.session.begin_nested():
            code = split_code(code, current, other)
            _update_file(code, other)

    db.session.commit()

    return jsonify(code)