def update_site_settings( ) -> t.Union[JSONResponse[site_settings.Opt.AllOptsAsJSON], JSONResponse[site_settings.Opt.FrontendOptsAsJSON], ]: """Get the settings for this CodeGrade instance. .. :quickref: Site settings; Get the site settings for this instance. """ options = rqa.FixedMapping( rqa.RequiredArgument( 'updates', rqa.List(site_settings.OPTIONS_INPUT_PARSER), 'The items you want to update', ) ).from_flask() auth.SiteSettingsPermissions().ensure_may_edit() for option in options.updates: # mypy bug: https://github.com/python/mypy/issues/9580 edit_row = models.SiteSetting.set_option( option, # type: ignore option.value, ) models.db.session.add(edit_row) models.db.session.commit() return JSONResponse.make(site_settings.Opt.get_all_opts())
def update_notification_settings() -> EmptyResponse: """Update preferences for notifications. .. :quickref: User Setting; Update preferences for notifications. :query str token: The token with which you want to update the preferences, if not given the preferences are updated for the currently logged in user. :returns: Nothing. """ data = rqa.FixedMapping( rqa.RequiredArgument( 'reason', rqa.EnumValue(models.NotificationReasons), 'For what type notification do you want to change the settings.', ), rqa.RequiredArgument( 'value', rqa.EnumValue(models.EmailNotificationTypes), 'The new value of the notification setting.', ), ).from_flask() user = _get_user() models.NotificationsSetting.update_for_user( user=user, reason=data.reason, value=data.value ) models.db.session.commit() return EmptyResponse.make()
def update_user_preferences() -> EmptyResponse: """Update ui preferences. .. :quickref: User Setting; Update UI preferences. :query str token: The token with which you want to update the preferences, if not given the preferences are updated for the currently logged in user. :returns: Nothing. """ data = rqa.FixedMapping( rqa.RequiredArgument( 'name', rqa.EnumValue(models.UIPreferenceName), 'The ui preference you want to change.', ), rqa.RequiredArgument( 'value', rqa.SimpleValue.bool, 'The new value of the preference.', ), ).from_flask() user = _get_user() models.UIPreference.update_for_user( user=user, name=data.name, value=data.value ) models.db.session.commit() return EmptyResponse.make()
def copy_auto_test(auto_test_id: int) -> JSONResponse[models.AutoTest]: """Copy the given AutoTest configuration. .. :quickref: AutoTest; Copy an AutoTest config to another assignment. :param auto_test_id: The id of the AutoTest config which should be copied. :returns: The copied AutoTest configuration. """ data = rqa.FixedMapping( rqa.RequiredArgument( 'assignment_id', rqa.SimpleValue.int, """ The id of the assignment into which you want to copy this AutoTest. """, )).from_flask() test = get_or_404(models.AutoTest, auto_test_id, also_error=lambda at: not at.assignment.is_visible) auth.AutoTestPermissions(test).ensure_may_see() for fixture in test.fixtures: auth.AutoTestFixturePermissions(fixture).ensure_may_see() for suite in test.all_suites: for step in suite.steps: auth.ensure_can_view_autotest_step_details(step) assignment = filter_single_or_404( models.Assignment, models.Assignment.id == data.assignment_id, with_for_update=True) auth.ensure_permission(CPerm.can_edit_autotest, assignment.course_id) if assignment.auto_test is not None: raise APIException( 'The given assignment already has an AutoTest', f'The assignment "{assignment.id}" already has an auto test', APICodes.INVALID_STATE, 409) assignment.rubric_rows = [] mapping = {} for old_row in test.assignment.rubric_rows: new_row = old_row.copy() mapping[old_row] = new_row assignment.rubric_rows.append(new_row) db.session.flush() with app.file_storage.putter() as putter: assignment.auto_test = test.copy(mapping, putter) db.session.flush() db.session.commit() return jsonify(assignment.auto_test)
def update_course(course_id: int) -> ExtendedJSONResponse[models.Course]: """Update the given :class:`.models.Course` with new values. .. :quickref: Course; Update course data. :param int course_id: The id of the course you want to update. :returns: The updated course, in extended format. """ data = rqa.FixedMapping( rqa.OptionalArgument( 'name', rqa.SimpleValue.str, 'The new name of the course', ), rqa.OptionalArgument( 'state', rqa.EnumValue(models.CourseState), """ The new state of the course, currently you cannot set the state of a course to 'deleted' """, )).from_flask() course = helpers.get_or_404(models.Course, course_id) checker = auth.CoursePermissions(course) checker.ensure_may_see() if data.name.is_just: if course.is_lti: raise APIException( 'You cannot rename LTI courses', ('LTI courses get their name from the LMS, so renaming is' ' not possible'), APICodes.INVALID_PARAM, 400) if not data.name.value: raise APIException( 'The name of a course should contain at least one character', 'A course name cannot be empty', APICodes.INVALID_PARAM, 400) checker.ensure_may_edit_info() course.name = data.name.value if data.state.is_just: if data.state.value.is_deleted: raise APIException( 'It is not yet possible to delete a course', 'Deleting courses in the API is not yet possible', APICodes.INVALID_PARAM, 400) checker.ensure_may_edit_state() course.state = data.state.value db.session.commit() return ExtendedJSONResponse.make(course, use_extended=models.Course)
def _set_version() -> None: cur_commit = subprocess.check_output( ['git', 'rev-parse', 'HEAD'], text=True, ).strip() version = subprocess.check_output( ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True, ).strip() # Always do the parsing, so we are sure the file is valid. with open( os.path.join( os.path.dirname(__file__), 'seed_data', 'release_notes.json' ), 'r' ) as release_notes_f: raw_release_notes = json.load(release_notes_f) import psef.models release_notes = rqa.LookupMapping( rqa.FixedMapping( rqa.RequiredArgument('date', rqa.RichValue.DateTime, ''), rqa.RequiredArgument('message', rqa.SimpleValue.str, ''), rqa.RequiredArgument('version', rqa.SimpleValue.str, ''), rqa.RequiredArgument( 'ui_preference', rqa.EnumValue(psef.models.UIPreferenceName), '' ), ) ).try_parse(raw_release_notes) newest_release = max(release_notes.values(), key=lambda x: x.date) if ( 'stable' in version or # Also show this message for release fixes branches. re.search(r'release.*fixes', version) is not None ): CONFIG['RELEASE_INFO'] = { 'commit': cur_commit, 'version': newest_release.version, 'message': newest_release.message, 'date': newest_release.date, 'ui_preference': newest_release.ui_preference, } else: CONFIG['RELEASE_INFO'] = {'commit': cur_commit}
def update_auto_test_set( auto_test_id: int, auto_test_set_id: int) -> JSONResponse[models.AutoTestSet]: """Update the given :class:`.models.AutoTestSet`. .. :quickref: AutoTest; Update a single AutoTest set. :>json stop_points: The minimum amount of points a student should have after this set to continue testing. :param auto_test_id: The id of the :class:`.models.AutoTest` of the set that should be updated. :param auto_test_set_id: The id of the :class:`.models.AutoTestSet` that should be updated. :returns: The updated set. """ data = rqa.FixedMapping( rqa.OptionalArgument( 'stop_points', rqa.SimpleValue.float, """ The minimum percentage a student should have achieved before the next tests will be run. """)).from_flask() auto_test_set = _get_at_set_by_ids(auto_test_id, auto_test_set_id) auth.AutoTestPermissions(auto_test_set.auto_test).ensure_may_edit() auto_test_set.auto_test.ensure_no_runs() if data.stop_points.is_just: stop_points = data.stop_points.value if stop_points < 0: raise APIException( 'You cannot set stop points to lower than 0', f"The given value for stop points ({stop_points}) isn't valid", APICodes.INVALID_PARAM, 400) elif stop_points > 1: raise APIException( 'You cannot set stop points to higher than 1', f"The given value for stop points ({stop_points}) isn't valid", APICodes.INVALID_PARAM, 400) auto_test_set.stop_points = stop_points db.session.commit() return jsonify(auto_test_set)
lookup[cls.COURSE_REGISTER_ENABLED], 'RENDER_HTML_ENABLED': lookup[cls.RENDER_HTML_ENABLED], 'EMAIL_STUDENTS_ENABLED': lookup[cls.EMAIL_STUDENTS_ENABLED], 'PEER_FEEDBACK_ENABLED': lookup[cls.PEER_FEEDBACK_ENABLED], } OPTIONS_INPUT_PARSER = rqa.Lazy(lambda: (rqa.FixedMapping( rqa.RequiredArgument( 'name', rqa.StringEnum('AUTO_TEST_MAX_TIME_COMMAND'), '', ), rqa.RequiredArgument( 'value', rqa.Nullable(Opt.AUTO_TEST_MAX_TIME_COMMAND.parser), '', ), ).add_tag('opt', Opt.AUTO_TEST_MAX_TIME_COMMAND)) | (rqa.FixedMapping( rqa.RequiredArgument( 'name', rqa.StringEnum('AUTO_TEST_HEARTBEAT_INTERVAL'), '', ), rqa.RequiredArgument( 'value', rqa.Nullable(Opt.AUTO_TEST_HEARTBEAT_INTERVAL.parser), '', ),
def deep_link_lti_assignment( deep_link_blob_id: uuid.UUID ) -> helpers.JSONResponse[t.Mapping[str, str]]: """Create a deeplink response for the given blob. :<json name: The name of the new assignment. :<json deadline: The deadline of the new assignment, formatted as an ISO8601 datetime string. :<json auth_token: The authentication token received after the initial LTI launch. :>json url: The url you should use to post the given ``jwt`` to. :>json jwt: The JWT that should be posted to the outputted ``url``. """ blob = helpers.filter_single_or_404( models.BlobStorage, models.BlobStorage.id == deep_link_blob_id, with_for_update=True, ) db.session.delete(blob) blob_json = blob.as_json() assert isinstance(blob_json, dict) assert blob_json['type'] == 'deep_link_settings' # We need a bit longer expiration here compared to other uses of the blob # storage in this module as users need to have the time to actually input # the needed data. if blob.age > timedelta(days=1): # Really delete the blob in this case too. db.session.commit() raise errors.APIException( 'This deep linking session has expired, please reload', f'The deep linking session with blob {blob.id} has expired', errors.APICodes.OBJECT_EXPIRED, 400) provider = helpers.get_or_404(models.LTI1p3Provider, blob_json['lti_provider_id']) # Docs are not needed here as this route is not part of the public API. data = rqa.FixedMapping( rqa.RequiredArgument('auth_token', rqa.SimpleValue.str, ''), rqa.RequiredArgument('name', rqa.SimpleValue.str, ''), rqa.RequiredArgument('deadline', rqa.RichValue.DateTime, ''), ).from_flask() if data.auth_token != blob_json['auth_token']: raise exceptions.PermissionException( 'You did not provide the correct token to deep link an assignment', f'The provided token {data.auth_token} is not correct', exceptions.APICodes.INCORRECT_PERMISSION, 403) dp_resource = lti_v1_3.CGDeepLinkResource.make( lti_provider=provider, deadline=data.deadline).set_title(data.name) deep_link_settings = t.cast(t.Any, blob_json['deep_link_settings']) deep_link = DeepLink( lti_v1_3.CGRegistration(provider), t.cast(str, blob_json['deployment_id']), deep_link_settings, ) db.session.commit() return JSONResponse.make({ 'url': deep_link_settings['deep_link_return_url'], 'jwt': deep_link.get_response_jwt([dp_resource]), })
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 update_or_create_auto_test_suite( auto_test_id: int, set_id: int) -> JSONResponse[models.AutoTestSuite]: """Update or create a :class:`.models.AutoTestSuite` (also known as category) .. :quickref: AutoTest; Update an AutoTest suite/category. :param auto_test_id: The id of the :class:`.models.AutoTest` in which this suite should be created. :param set_id: The id the :class:`.models.AutoTestSet` in which this suite should be created. :returns: The just updated or created :class:`.models.AutoTestSuite`. """ data = rqa.FixedMapping( rqa.OptionalArgument( 'id', rqa.SimpleValue.int, """ The id of the suite you want to edit. If not provided we will create a new suite. """, ), rqa.RequiredArgument( 'steps', rqa.List( rqa.BaseFixedMapping.from_typeddict( models.AutoTestStepBase.InputAsJSON)), """ The steps that should be in this suite. They will be run as the order they are provided in. """, ), rqa.RequiredArgument( 'rubric_row_id', rqa.SimpleValue.int, 'The id of the rubric row that should be connected to this suite.' ), rqa.RequiredArgument( 'network_disabled', rqa.SimpleValue.bool, 'Should the network be disabled when running steps in this suite'), rqa.OptionalArgument( 'submission_info', rqa.SimpleValue.bool, """ If passed as ``true`` we will provide information about the current submission while running steps. Defaults to ``false`` when creating new suites. """, ), rqa.OptionalArgument( 'command_time_limit', rqa.SimpleValue.float, """ The maximum amount of time a single step (or substeps) can take when running tests. If not provided the default value is depended on configuration of the instance. """, ), ).from_flask() auto_test_set = _get_at_set_by_ids(auto_test_id, set_id) auth.AutoTestPermissions(auto_test_set.auto_test).ensure_may_edit() auto_test_set.auto_test.ensure_no_runs() if data.id.is_just: time_limit = data.command_time_limit.or_default(None) suite = get_or_404(models.AutoTestSuite, data.id.value) else: # Make sure the time_limit is always set when creating a new suite default_time_limit = site_settings.Opt.AUTO_TEST_MAX_TIME_COMMAND def get_default_time_limit() -> float: return default_time_limit.value.total_seconds() time_limit = data.command_time_limit.or_default_lazy( get_default_time_limit) suite = models.AutoTestSuite(auto_test_set=auto_test_set) if time_limit is not None: if time_limit < 1: raise APIException( 'The minimum value for a command time limit is 1 second', (f'The given value for the time limit ({time_limit}) is too' ' low'), APICodes.INVALID_PARAM, 400) suite.command_time_limit = time_limit suite.network_disabled = data.network_disabled if data.submission_info.is_just: suite.submission_info = data.submission_info.value if suite.rubric_row_id != data.rubric_row_id: assig = suite.auto_test_set.auto_test.assignment rubric_row = get_or_404( models.RubricRow, data.rubric_row_id, also_error=lambda row: row.assignment != assig, ) if rubric_row.id in assig.locked_rubric_rows: raise APIException( 'This rubric is already in use by another suite', f'The rubric row "{rubric_row.id}" is already in use', APICodes.INVALID_STATE, 409) if rubric_row.is_selected(): add_warning( 'This rubric category is already used for manual grading', APIWarnings.IN_USE_RUBRIC_ROW) suite.rubric_row = rubric_row suite.set_steps(data.steps) db.session.commit() return jsonify(suite)
def create_auto_test() -> JSONResponse[models.AutoTest]: """Create a new AutoTest configuration. .. :quickref: AutoTest; Create a new AutoTest configuration. :>json assignment_id: The assignment id this AutoTest should be linked to. :>json setup_script: The setup script per student that should be run (OPTIONAL). :>json run_setup_script: The setup for the entire run (OPTIONAL). :returns: The newly created AutoTest. """ data, request_files = rqa.MultipartUpload( _ATUpdateMap.combine( rqa.FixedMapping( rqa.RequiredArgument( 'assignment_id', rqa.SimpleValue.int, """ The id of the assignment in which you want to create this AutoTest. This assignment should have a rubric. """, ), ), ), file_key='fixture', multiple=True, ).from_flask() assignment = filter_single_or_404( models.Assignment, models.Assignment.id == data.assignment_id, models.Assignment.is_visible, with_for_update=True) already_has = assignment.auto_test_id is not None auto_test = models.AutoTest( assignment=assignment, finalize_script='', ) db.session.add(auto_test) db.session.flush() auth.AutoTestPermissions(auto_test).ensure_may_add() if already_has: raise APIException( 'The given assignment already has an auto test', f'The assignment "{assignment.id}" already has an auto test', APICodes.INVALID_STATE, 409) _update_auto_test( auto_test, request_files, data.fixtures, data.setup_script, data.run_setup_script, data.has_new_fixtures, data.grade_calculation, data.results_always_visible, data.prefer_teacher_revision, ) db.session.commit() return jsonify(auto_test)
_ATUpdateMap = rqa.FixedMapping( rqa.OptionalArgument( 'setup_script', rqa.SimpleValue.str, 'The new setup script (per student) of the auto test.'), rqa.OptionalArgument( 'run_setup_script', rqa.SimpleValue.str, 'The new run setup script (global) of the auto test.'), rqa.OptionalArgument( 'has_new_fixtures', rqa.SimpleValue.bool, 'If true all other files in the request will be used as new fixtures', ), rqa.OptionalArgument( 'grade_calculation', rqa.SimpleValue.str, 'The way to do grade calculation for this AutoTest.', ), rqa.OptionalArgument( 'results_always_visible', rqa.Nullable(rqa.SimpleValue.bool), """ Should results be visible for students before the assignment is set to "done"? """, ), rqa.OptionalArgument( 'prefer_teacher_revision', rqa.Nullable(rqa.SimpleValue.bool), """ If ``true`` we will use the teacher revision if available when running tests. """, ), rqa.OptionalArgument( 'fixtures', rqa.List(rqa.BaseFixedMapping.from_typeddict(FixtureLike)), 'A list of old fixtures you want to keep', ), )
def login( ) -> MultipleExtendedJSONResponse[models.User.LoginResponse, models.User]: """Login using your username and password. .. :quickref: User; Login a given user. :returns: A response containing the JSON serialized user :query with_permissions: Setting this to true will add the key ``permissions`` to the user. The value will be a mapping indicating which global permissions this user has. :raises APIException: If no user with username exists or the password is wrong. (LOGIN_FAILURE) :raises APIException: If the user with the given username and password is inactive. (INACTIVE_USER) """ data = rqa.Union( rqa.FixedMapping( rqa.RequiredArgument( 'username', rqa.SimpleValue.str, 'Your username', ), rqa.RequiredArgument( 'password', rqa.RichValue.Password, 'Your password', ) ).add_description('The data required when you want to login').add_tag( 'tag', 'login' ), rqa.FixedMapping( rqa.RequiredArgument( 'username', rqa.SimpleValue.str, 'The username of the user you want to impersonate', ), rqa.RequiredArgument( 'own_password', rqa.RichValue.Password, 'Your own password', ), ).add_description( 'The data required when you want to impersonate a user' ).add_tag('tag', 'impersonate') ).from_flask( log_replacer=lambda k, v: '<PASSWORD>' if 'password' in k else v ) user: t.Optional[models.User] if data.tag == 'impersonate': auth.ensure_permission(GPerm.can_impersonate_users) if current_user.password != data.own_password: raise APIException( 'The supplied own password is incorrect', f'The user {current_user.id} has a different password', APICodes.INVALID_CREDENTIALS, 403 ) user = helpers.filter_single_or_404( models.User, models.User.username == data.username, ~models.User.is_test_student, models.User.active, ) else: # 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 = db.session.query( models.User, ).filter( models.User.username == data.username, ~models.User.is_test_student, ).first() if user is None or user.password != data.password: exc_msg = 'The supplied username or password is wrong.' # If the given username looks like an email we notify the user that # their username is probably not the same as their email. Note that # this doesn't check if the user was found, so this does not leak # information about the existence of a user with the given # username. try: validate.ensure_valid_email(data.username) except validate.ValidationException: pass else: exc_msg += ( ' You have to login to CodeGrade using your username,' ' which is probably not the same as your email.' ) raise APIException( exc_msg, ( f'The user with username "{data.username}" does not exist ' 'or has a different password' ), APICodes.LOGIN_FAILURE, 400 ) if not user.is_active: raise APIException( 'User is not active', f'The user with id "{user.id}" is not active any more', APICodes.INACTIVE_USER, 403 ) # Check if the current password is safe, and add a warning to the # response if it is not. try: validate.ensure_valid_password(data.password, user=user) except WeakPasswordException: add_warning( ( 'Your password does not meet the requirements, consider ' 'changing it.' ), APIWarnings.WEAK_PASSWORD, ) auth.set_current_user(user) if request_arg_true('with_permissions'): jsonify_options.get_options().add_permissions_to_user = user return MultipleExtendedJSONResponse.make( { 'user': user, 'access_token': user.make_access_token(), }, use_extended=models.User, )