def search_users() -> JSONResponse[t.Sequence[models.User]]: """Search for a user by name and username. .. :quickref: User; Fuzzy search for a user by name and username. :param str q: The string to search for, all SQL wildcard are escaped and spaces are replaced by wildcards. :returns: A list of :py:class:`.models.User` objects that match the given query string. :raises APIException: If the query string less than 3 characters long. (INVALID_PARAM) :raises PermissionException: If the currently logged in user does not have the permission ``can_search_users``. (INCORRECT_PERMISSION) :raises RateLimitExceeded: If you hit this end point more than once per second. (RATE_LIMIT_EXCEEDED) """ ensure_keys_in_dict(request.args, [('q', str)]) query = t.cast(str, request.args.get('q')) if len(query) < 3: raise APIException( 'The search string should be at least 3 chars', f'The search string "{query}" is not 3 chars or longer.', APICodes.INVALID_PARAM, 400) likes = [ t.cast(t.Any, col).ilike('%{}%'.format( escape_like(query).replace(' ', '%'), )) for col in [models.User.name, models.User.username] ] return jsonify(models.User.query.filter(or_(*likes)).all())
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)
def add_course() -> JSONResponse[models.Course]: """Add a new :class:`.models.Course`. .. :quickref: Course; Add a new course. :returns: A response containing the JSON serialization of the new course :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN) :raises PermissionException: If the user can not create courses. (INCORRECT_PERMISSION) :raises APIException: If the parameter "name" is not in the request. (MISSING_REQUIRED_PARAM) """ content = get_json_dict_from_request() ensure_keys_in_dict(content, [('name', str)]) name = t.cast(str, content['name']) new_course = models.Course.create_and_add(name) db.session.commit() role = models.CourseRole.get_initial_course_role(new_course) current_user.courses[new_course.id] = role db.session.commit() return jsonify(new_course)
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, ) })
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)
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, ) } )
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()
def get_course_or_global_permissions( ) -> JSONResponse[t.Union[GlobalPermMap, t.Mapping[int, CoursePermMap]]]: """Get all the global :class:`.psef.models.Permission` or the value of a permission in all courses of the currently logged in :class:`.psef.models.User` .. :quickref: Permission; Get global permissions or all the course permissions for the current user. :qparam str type: The type of permissions to get. This can be ``global`` or ``course``. :qparam str permission: The permissions to get when getting course permissions. You can pass this parameter multiple times to get multiple permissions. DEPRECATED: This option is deprecated, as it is preferred that you simply get all permissions for a course. :returns: The returning object depends on the given ``type``. If it was ``global`` a mapping between permissions name and a boolean indicating if the currently logged in user has this permissions is returned. If it was ``course`` such a mapping is returned for every course the user is enrolled in. So it is a mapping between course ids and permission mapping. The permissions given as ``permission`` query parameter are the only ones that are present in the permission map. When no ``permission`` query is given all course permissions are returned. """ ensure_keys_in_dict(request.args, [('type', str)]) permission_type = t.cast(str, request.args['type']).lower() if permission_type == 'global': return jsonify(GPerm.create_map(current_user.get_all_permissions())) elif permission_type == 'course' and 'permission' in request.args: add_deprecate_warning( 'Requesting a subset of course permissions is deprecated') # Make sure at least one permission is present ensure_keys_in_dict(request.args, [('permission', str)]) perm_names = t.cast(t.List[str], request.args.getlist('permission')) return jsonify({ course_id: CPerm.create_map(v) for course_id, v in current_user.get_permissions_in_courses( [CPerm.get_by_name(p) for p in perm_names]).items() }) elif permission_type == 'course': return jsonify({ course_id: CPerm.create_map(v) for course_id, v in current_user.get_all_permissions_in_courses().items() }) else: raise APIException( 'Invalid permission type given', f'The given type "{permission_type}" is not "global" or "course"', APICodes.INVALID_PARAM, 400, )
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, ) })
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 patch_course_snippet(course_id: int, snippet_id: int) -> EmptyResponse: """Modify the :class:`.models.CourseSnippet` with the given id. .. :quickref: CourseSnippet; Change a snippets key and value. :param int snippet_id: The id of the snippet to change. :returns: An empty response with return code 204. :<json str key: The new key of the snippet. :<json str value: The new value of the snippet. :raises APIException: If the parameters "key" and/or "value" were not in the request. (MISSING_REQUIRED_PARAM) :raises APIException: If the snippet does not belong to the current user. (INCORRECT_PERMISSION) :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN) :raises PermissionException: If the user can not use snippets. (INCORRECT_PERMISSION) :raises APIException: If another snippet with the same key already exists. (OBJECT_ALREADY_EXISTS) """ auth.ensure_permission(CPerm.can_manage_course_snippets, course_id) content = get_json_dict_from_request() ensure_keys_in_dict(content, [('key', str), ('value', str)]) key = t.cast(str, content['key']) value = t.cast(str, content['value']) course = helpers.get_or_404(models.Course, course_id) snip = helpers.get_or_404( models.CourseSnippet, snippet_id, also_error=lambda snip: snip.course_id != course.id) other = models.CourseSnippet.query.filter_by( course=course, key=key, ).first() if other is not None and other.id != snippet_id: raise APIException( 'A snippet with the same key already exists.', 'A snippet with key "{}" already exists for course "{}"'.format( key, course_id), APICodes.OBJECT_ALREADY_EXISTS, 400, ) snip.key = key snip.value = value db.session.commit() return make_empty_response()
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 set_reminder( assig: models.Assignment, content: t.Dict[str, helpers.JSONType], ) -> t.Optional[psef.errors.HttpWarning]: """Set the reminder of an assignment from a JSON dict. :param assig: The assignment to set the reminder for. :param content: The json input. :returns: A warning if it should be returned to the user. """ ensure_keys_in_dict(content, [ ('done_type', (type(None), str)), ('done_email', (type(None), str)), ('reminder_time', (type(None), str)), ]) # yapf: disable done_type = parsers.parse_enum( content.get('done_type', None), models.AssignmentDoneType, allow_none=True, option_name='done type' ) reminder_time = parsers.parse_datetime( content.get('reminder_time', None), allow_none=True, ) done_email = parsers.try_parse_email_list( content.get('done_email', None), allow_none=True, ) if reminder_time and (reminder_time - datetime.datetime.utcnow()).total_seconds() < 60: raise APIException( ( 'The given date is not far enough from the current time, ' 'it should be at least 60 seconds in the future.' ), f'{reminder_time} is not atleast 60 seconds in the future', APICodes.INVALID_PARAM, 400 ) assig.change_notifications(done_type, reminder_time, done_email) if done_email is not None and assig.graders_are_done(): return make_warning( 'Grading is already done, no email will be sent!', APIWarnings.CONDITION_ALREADY_MET ) return None
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)
def add_role(course_id: int) -> EmptyResponse: """Add a new :class:`.models.CourseRole` to the given :class:`.models.Course`. .. :quickref: Course; Add a new course role to a course. :param int course_id: The id of the course :returns: An empty response with return code 204. :<json str name: The name of the new course role. :raises APIException: If the name parameter was not in the request. (MISSING_REQUIRED_PARAM) :raises APIException: If the course with the given id was not found. (OBJECT_NOT_FOUND) :raises APIException: If the course already has a role with the submitted name. (INVALID_PARAM) :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN) :raises PermissionException: If the user can not manage the course with the given id. (INCORRECT_PERMISSION) """ auth.ensure_permission(CPerm.can_edit_course_roles, course_id) content = get_json_dict_from_request() ensure_keys_in_dict(content, [('name', str)]) name = t.cast(str, content['name']) course = helpers.get_or_404( models.Course, course_id, also_error=lambda c: c.virtual, ) if models.CourseRole.query.filter_by( name=name, course_id=course_id).first() is not None: raise APIException( 'This course already has a role with this name', 'The course "{}" already has a role named "{}"'.format( course_id, name), APICodes.INVALID_PARAM, 400) role = models.CourseRole(name=name, course=course, hidden=False) db.session.add(role) db.session.commit() return make_empty_response()
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)
def create_course_snippet( course_id: int) -> JSONResponse[models.CourseSnippet]: """Add or modify a :class:`.models.CourseSnippet` by key. .. :quickref: CourseSnippet; Add or modify a course snippet. :returns: A response containing the JSON serialized snippet and return code 201. :<json str value: The new value of the snippet. :<json str key: The key of the new or existing snippet. :raises APIException: If the parameters "key", "value", and/or "course_id" were not in the request. (MISSING_REQUIRED_PARAM) :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN) :raises PermissionException: If the user can not use snippets (INCORRECT_PERMISSION) """ auth.ensure_permission(CPerm.can_manage_course_snippets, course_id) content = get_json_dict_from_request() ensure_keys_in_dict(content, [('value', str), ('key', str)]) key = t.cast(str, content['key']) value = t.cast(str, content['value']) course = helpers.get_or_404(models.Course, course_id) snippet = models.CourseSnippet.query.filter_by( course=course, key=key, ).first() if snippet is None: snippet = models.CourseSnippet( course=course, key=key, value=value, ) db.session.add(snippet) else: snippet.value = value db.session.commit() return jsonify(snippet, status_code=201)
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 create_new_assignment(course_id: int) -> JSONResponse[models.Assignment]: """Create a new course for the given assignment. .. :quickref: Course; Create a new assignment in a course. :param int course_id: The course to create an assignment in. :<json str name: The name of the new assignment. :returns: The newly created assignment. :raises PermissionException: If the current user does not have the ``can_create_assignment`` permission (INCORRECT_PERMISSION). """ auth.ensure_permission(CPerm.can_create_assignment, course_id) content = get_json_dict_from_request() ensure_keys_in_dict(content, [('name', str)]) name = t.cast(str, content['name']) course = helpers.get_or_404( models.Course, course_id, also_error=lambda c: c.virtual, ) if course.lti_provider is not None: lms = course.lti_provider.lms_name raise APIException(f'You cannot add assignments to a {lms} course', f'The course "{course_id}" is a LTI course', APICodes.INVALID_STATE, 400) assig = models.Assignment( name=name, course=course, is_lti=False, ) db.session.add(assig) db.session.commit() return jsonify(assig)
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()
def get_course_permissions( ) -> JSONResponse[t.Union[_PermMap, t.Mapping[int, _PermMap]]]: """Get all the global :class:`.psef.models.Permission` or the value of a permission in all courses of the currently logged in :class:`.psef.models.User` .. :quickref: Permission; Get global permissions or all the course permissions for the current user. :qparam str type: The type of permissions to get. This can be ``global`` or ``course``. :qparam str permission: The permissions to get when getting course permissions. You can pass this parameter multiple times to get multiple permissions. :returns: The returning object depends on the given ``type``. If it was ``global`` a mapping between permissions name and a boolean indicating if the currently logged in user has this permissions is returned. If it was ``course`` such a mapping is returned for every course the user is enrolled in. So it is a mapping between course ids and permission mapping. The permissions given as ``permission`` query parameter are the only ones that are present in the permission map. """ ensure_keys_in_dict(request.args, [('type', str)]) permission_type = t.cast(str, request.args['type']).lower() if permission_type == 'global': return jsonify(current_user.get_all_permissions()) elif permission_type == 'course': # Make sure at least one permission is present ensure_keys_in_dict(request.args, [('permission', str)]) perms = t.cast(t.List[str], request.args.getlist('permission')) return jsonify(current_user.get_permissions_in_courses(perms)) else: raise APIException( 'Invalid permission type given', f'The given type "{permission_type}" is not "global" or "course"', APICodes.INVALID_PARAM, 400, )
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()
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()
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
def test_ensure_keys_in_dict(): class Enum1(enum.Enum): a = 1 b = '2' class Enum2(enum.Enum): a = '4' c = 5 h.ensure_keys_in_dict({'hello': 'a'}, [('hello', Enum1)]) h.ensure_keys_in_dict({'hello': 'a'}, [('hello', Enum2)]) with pytest.raises(APIException) as err: h.ensure_keys_in_dict({'hello': 'b'}, [('hello', Enum2)]) assert 'should be a member of' in err.value.description assert '(= a, c)' in err.value.description with pytest.raises(APIException) as err: h.ensure_keys_in_dict({'hello': 'c'}, [('hello', Enum1)]) assert 'should be a member of' in err.value.description assert '(= a, b)' in err.value.description
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 create_group_set(course_id: int) -> JSONResponse[models.GroupSet]: """Create or update a :class:`.models.GroupSet` in the given course id. .. :quickref: Course; Create a new group set in the course. :>json int minimum_size: The minimum size attribute that the group set should have. :>json int maximum_size: The maximum size attribute that the group set should have. :>json int id: The id of the group to update. :param course_id: The id of the course in which the group set should be created or updated. The course id of a group set cannot change. :returns: The created or updated group. """ auth.ensure_permission(CPerm.can_edit_group_set, course_id) course = helpers.get_or_404(models.Course, course_id) content = get_json_dict_from_request() ensure_keys_in_dict(content, [ ('minimum_size', int), ('maximum_size', int), ]) min_size = t.cast(int, content['minimum_size']) max_size = t.cast(int, content['maximum_size']) if 'id' in content: ensure_keys_in_dict(content, [('id', int)]) group_set_id = t.cast(int, content['id']) group_set = helpers.get_or_404( models.GroupSet, group_set_id, ) if group_set.course_id != course.id: raise APIException( 'You cannot change the course id of a group set', (f'The group set {group_set.id} is ' f'not connected to course {course.id}'), APICodes.INVALID_PARAM, 400) else: group_set = models.GroupSet(course_id=course.id) models.db.session.add(group_set) if min_size <= 0: raise APIException('Minimum size should be larger than 0', f'Minimum size "{min_size}" is <= than 0', APICodes.INVALID_PARAM, 400) elif max_size < min_size: raise APIException('Maximum size is smaller than minimum size', (f'Maximum size "{max_size}" is smaller ' f'than minimum size "{min_size}"'), APICodes.INVALID_PARAM, 400) elif group_set.largest_group_size > max_size: raise APIException('There are groups larger than the new maximum size', f'Some groups have more than {max_size} members', APICodes.INVALID_PARAM, 400) elif group_set.smallest_group_size < min_size: raise APIException( 'There are groups smaller than the new minimum size', f'Some groups have less than {min_size} members', APICodes.INVALID_PARAM, 400) group_set.minimum_size = min_size group_set.maximum_size = max_size models.db.session.commit() return jsonify(group_set)
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})
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 __get_comment() -> str: content = ensure_json_dict(request.get_json()) ensure_keys_in_dict(content, [('comment', str)]) return t.cast(str, content['comment'])