예제 #1
0
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()
예제 #2
0
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()
예제 #3
0
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())
예제 #4
0
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}
예제 #5
0
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)
예제 #6
0
            lookup[cls.AUTO_TEST_ENABLED],
            'COURSE_REGISTER_ENABLED':
            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',
예제 #7
0
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]),
    })
예제 #8
0
def create_or_edit_registration_link(
        course_id: int) -> JSONResponse[models.CourseRegistrationLink]:
    """Create or edit an enroll link.

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

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

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

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

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

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

    db.session.commit()
    return jsonify(link)
예제 #9
0
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)
예제 #10
0
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)
예제 #11
0
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,
    )