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()
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()
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()
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)
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()
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()
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' }
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
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()
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()
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))
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)
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
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)
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)
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))
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()
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)