def self_information() -> t.Union[JSONResponse[models.User], JSONResponse[t.Dict[int, str]], ExtendedJSONResponse[models.User], ]: """Get the info of the currently logged in user. .. :quickref: User; Get information about the currently logged in user. :query type: If this is ``roles`` a mapping between course_id and role name will be returned, if this is ``extended`` the result of :py:meth:`.models.User.__extended_to_json__()` will be returned. If this is something else or not present the result of :py:meth:`.models.User.__to_json__()` will be returned. :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. :returns: A response containing the JSON serialized user :raises PermissionException: If there is no logged in user. (NOT_LOGGED_IN) """ args = request.args if args.get('type') == 'roles': return JSONResponse.make( { role.course_id: role.name for role in current_user.courses.values() } ) elif helpers.extended_requested() or args.get('type') == 'extended': user = models.User.resolve(current_user) if request_arg_true('with_permissions'): jsonify_options.get_options().add_permissions_to_user = user return ExtendedJSONResponse.make(user, use_extended=models.User) return JSONResponse.make(current_user)
def as_task(self, fun: t.Callable[[], None]) -> None: """Run the given ``fun`` as the task. .. warning:: One of the first things this function do is committing the current session, however after running ``fun`` nothing is committed. :param fun: The function to run as the task, catching the exceptions it produces and storing them in this task result. :returns: Nothing. """ assert self.state == TaskResultState.not_started, ( 'Cannot start task that has already started, state was in {}' ).format(self.state) self.state = TaskResultState.started db.session.commit() try: self.result = fun() except APIException as exc: self.state = TaskResultState.failed self.result = JSONResponse.dump_to_object(exc) except: # pylint: disable=bare-except logger.warning('The task crashed', exc_info=True) self.state = TaskResultState.crashed self.result = JSONResponse.dump_to_object( APIException( 'The task failed for an unknown reason', f'The task {self.id} failed with a uncaught exception', APICodes.UNKOWN_ERROR, 400)) else: self.state = TaskResultState.finished
def test_jsonify(): class Enum(CGEnum): name_a = 2 name_b = 3 with flask.Flask(__name__).app_context(): assert JSONResponse.dump_to_object(Enum.name_a) == 'name_a' assert JSONResponse.dump_to_object(Enum.name_b) == 'name_b'
def as_task( self, fun: t.Callable[[], t.Optional[TaskResultState]], *, eta: t.Optional[DatetimeWithTimezone] = None, ) -> bool: """Run the given ``fun`` as the task. .. warning:: One of the first things this function do is committing the current session, however after running ``fun`` nothing is committed. :param fun: The function to run as the task, catching the exceptions it produces and storing them in this task result. :param eta: The time the task should run, if the current time is before this ETA the current celery task will be scheduled again and ``fun`` will not be called. :returns: ``True`` if the task ran, otherwise ``False``. """ if not self.state.is_not_started: # pragma: no cover logger.error('Cannot start task that has already started', task_result=self) return False elif eta and current_task.maybe_delay_task(eta): return False self.state = TaskResultState.started db.session.commit() try: result_code = fun() except APIException as exc: self.state = TaskResultState.failed self.result = JSONResponse.dump_to_object(exc) except: # pylint: disable=bare-except logger.warning('The task crashed', exc_info=True) self.state = TaskResultState.crashed self.result = JSONResponse.dump_to_object( APIException( 'The task failed for an unknown reason', f'The task {self.id} failed with a uncaught exception', APICodes.UNKOWN_ERROR, 400)) else: self.result = None self.state = handle_none(result_code, TaskResultState.finished) return True
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 get_data_source( ana_id: int, data_source_name: str, ) -> JSONResponse[models.BaseDataSource]: """Get a data source within a :class:`.models.AnalyticsWorkspace`. .. :quickref: Analytics; Get a data source of a workspace. .. warning:: This route should be considered beta, its behavior and/or existence will change. :param int ana_id: The id of the workspace in which the datasource should be retrieved. :param string data_source_name: The name of the data source to retrieve. """ workspace = helpers.get_or_404( models.AnalyticsWorkspace, ana_id, also_error=lambda a: not a.assignment.is_visible) auth.AnalyticsWorkspacePermissions(workspace).ensure_may_see() data_source = registry.analytics_data_sources[data_source_name](workspace) if not data_source.should_include(): raise exceptions.APIException( 'The given data source is not enabled for this workspace', (f'The data source {data_source_name} is not enabled for' f' worspace {ana_id}.'), exceptions.APICodes.OBJECT_NOT_FOUND, 404) return JSONResponse.make(data_source)
def get_all_sso_providers() -> JSONResponse[t.Sequence[models.Saml2Provider]]: """Get all SSO Providers. .. :quickref: SSO Provider; Get all SSO Providers for this instance. """ return JSONResponse.make( models.Saml2Provider.query.order_by( models.Saml2Provider.created_at.asc()).all())
def get_all_notifications() -> t.Union[ExtendedJSONResponse[NotificationsJSON], JSONResponse[HasUnreadNotifcationJSON], ]: """Get all notifications for the current user. .. :quickref: Notification; Get all notifications. :query boolean has_unread: If considered true a short digest will be send, i.e. a single object with one key ``has_unread`` with a boolean value. Please use this if you simply want to check if there are unread notifications. :returns: Either a :class:`.NotificationsJSON` or a `HasUnreadNotifcationJSON` based on the ``has_unread`` parameter. """ notifications = db.session.query(Notification).join( Notification.comment_reply ).filter( ~models.CommentReply.deleted, Notification.receiver == current_user, ).order_by( Notification.read.asc(), Notification.created_at.desc(), ).options( contains_eager(Notification.comment_reply), defaultload(Notification.comment_reply).defer( models.CommentReply.last_edit ), defaultload( Notification.comment_reply, ).defaultload( models.CommentReply.comment_base, ).defaultload( models.CommentBase.file, ).selectinload( models.File.work, ), ).yield_per(_MAX_NOTIFICATION_AMOUNT) def can_see(noti: Notification) -> bool: return auth.NotificationPermissions(noti).ensure_may_see.as_bool() if request_arg_true('has_unread'): has_unread = any( map(can_see, notifications.filter(~Notification.read)) ) return JSONResponse.make({'has_unread': has_unread}) return ExtendedJSONResponse.make( NotificationsJSON( notifications=[ n for n in itertools.islice(notifications, _MAX_NOTIFICATION_AMOUNT) if can_see(n) ] ), use_extended=(models.CommentReply, Notification) )
def get_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. """ auth.SiteSettingsPermissions().ensure_may_see() return JSONResponse.make(site_settings.Opt.get_all_opts())
def create_sso_providers() -> JSONResponse[models.Saml2Provider]: """Register a new SSO Provider in this instance. .. :quickref: SSO Provider; Create a new SSO Providers in this instance. Users will be able to login using the registered provider. The request should contain two files. One named ``json`` containing the json data explained below and one named ``logo`` containing the backup logo. :>json metadata_url: The url where we can download the metadata for the IdP connected to this provider. :>json name: If no name can be found in the metadata this name will be displayed to users when choosing login methods. :>json description: If no description can be found in the metadata this description will be displayed to users when choosing login methods. :returns: The just created provider. """ json_files = helpers.get_files_from_request( max_size=current_app.max_single_file_size, keys=['json']) data = helpers.ensure_json_dict(json.load(json_files[0])) with helpers.get_from_map_transaction(data) as [get, _]: metadata_url = get('metadata_url', str) name = get('name', str) description = get('description', str) logo = helpers.get_files_from_request( max_size=current_app.max_single_file_size, keys=['logo'])[0] prov = models.Saml2Provider( metadata_url=metadata_url, name=name, description=description, logo=logo, ) db.session.add(prov) db.session.flush() try: prov.check_metadata_url() except: logger.error( 'Error parsing given metadata url', exc_info=True, report_to_sentry=True, ) raise exceptions.APIException( 'Could not parse the metadata in the given metadata url', 'The metadata could not be parsed', exceptions.APICodes.INVALID_PARAM, 400) db.session.commit() return JSONResponse.make(prov)
def do_oidc_login( lti_provider_id: t.Optional[str] = None) -> werkzeug.wrappers.Response: """Do an LTI 1.3 OIDC login. :param lti_provider_id: The id of the provider doing the launch, not required for LMSes that pass all required information. """ req = helpers.maybe_unwrap_proxy(flask.request, flask.Request) if req.method == 'GET': target = req.args.get('target_link_uri') else: target = req.form.get('target_link_uri') assert target is not None try: provider = _maybe_get_provider(lti_provider_id) oidc = lti_v1_3.FlaskOIDCLogin.from_request(lti_provider=provider) red = oidc.get_redirect_object(target) except exceptions.APIException as exc: logger.info('Login request went wrong', exc_info=True) message = exc.message if exc.api_code == exceptions.APICodes.OBJECT_NOT_FOUND: message = ( 'This LMS was not found as a LTIProvider for CodeGrade, this' ' is probably caused by a wrong setup.') return _make_blob_and_redirect( { 'type': 'exception', 'exception_message': message, 'original_exception': JSONResponse.dump_to_object(exc), }, version=LTIVersion.v1_3, goto_latest_submission=False, ) except pylti1p3.exception.OIDCException as exc: logger.info('Login request went wrong', exc_info=True) return _make_blob_and_redirect( { 'type': 'exception', 'exception_message': exc.args[0], }, version=LTIVersion.v1_3, goto_latest_submission=False, ) logger.info( 'Redirecting after oidc', target=target, get_args=req.args, post_args=req.form, redirect=red, ) return red.do_redirect()
def _handle_rate_limit_exceeded( _: RateLimitExceeded) -> JSONResponse[errors.APIException]: return JSONResponse.make( errors.APIException( 'Rate limit exceeded, slow down!', 'Rate limit is exceeded', errors.APICodes.RATE_LIMIT_EXCEEDED, 429, ), 429, )
def _handle_lti_advantage_launch( lti_provider_id: t.Optional[str], goto_latest_sub: bool) -> t.Union[str, werkzeug.wrappers.Response]: app.config['SESSION_COOKIE_SAMESITE'] = 'None' try: provider = _maybe_get_provider(lti_provider_id) message_launch = lti_v1_3.FlaskMessageLaunch.from_request( lti_provider=provider, ).validate() except exceptions.APIException as exc: logger.info('An error occurred during the LTI launch', exc_info=True) return _make_blob_and_redirect( { 'type': 'exception', 'exception_message': exc.message, 'original_exception': JSONResponse.dump_to_object(exc), }, version=LTIVersion.v1_3, goto_latest_submission=False, ) except pylti1p3.exception.LtiException as exc: logger.info('Incorrect LTI launch encountered', exc_info=True) return _make_blob_and_redirect( { 'type': 'exception', 'exception_message': exc.args[0], }, version=LTIVersion.v1_3, goto_latest_submission=False, ) plat_red_url = flask.request.args.get('platform_redirect_url') full_win_launch = flask.request.args.get('full_win_launch_requested') if full_win_launch == '1' and plat_red_url: return flask.redirect(plat_red_url) provider = message_launch.get_lti_provider() if (message_launch.is_deep_link_launch() and not provider.lms_capabilities.actual_deep_linking_required): deep_link = message_launch.get_deep_link() dp_resource = lti_v1_3.CGDeepLinkResource.make(message_launch) form = deep_link.output_response_form([dp_resource]) return f'<!DOCTYPE html>\n<html><body>{form}</body></html>' return _make_blob_and_redirect( { 'launch_data': message_launch.get_launch_data(), 'lti_provider_id': message_launch.get_lti_provider().id, 'request_args': dict(flask.request.args), }, version=LTIVersion.v1_3, goto_latest_submission=goto_latest_sub, )
def get_user_preference(name: str) -> JSONResponse[t.Optional[bool]]: """Get a single UI preferences. .. :quickref: User Setting; Get a single UI preference. :query str token: The token with which you want to get the preferences, if not given the preferences are retrieved for the currently logged in user. :param string name: The preference name you want to get. :returns: The preferences for the user as described by the ``token``. """ pref = rqa.EnumValue(models.UIPreferenceName).try_parse(name) user = _get_user() return JSONResponse.make( models.UIPreference.get_preference_for_user(user, pref) )
def get_notification_settings( ) -> JSONResponse[models.NotificationSettingJSON]: """Update preferences for notifications. .. :quickref: User Setting; Get the preferences for notifications. :query str token: The token with which you want to get the preferences, if not given the preferences are retrieved for the currently logged in user. :returns: The preferences for the user as described by the ``token``. """ user = _get_user() return JSONResponse.make( models.NotificationsSetting.get_notification_setting_json_for_user( user, ) )
def get_analytics(ana_id: int) -> JSONResponse[models.AnalyticsWorkspace]: """Get a :class:`.models.AnalyticsWorkspace`. .. :quickref: Analytics; Get a analytics workspace. .. warning:: This route should be considered beta, its behavior and/or existence will change. :param int ana_id: The id of the workspace to get. """ workspace = helpers.get_or_404( models.AnalyticsWorkspace, ana_id, also_error=lambda a: not a.assignment.is_visible) auth.AnalyticsWorkspacePermissions(workspace).ensure_may_see() return JSONResponse.make(workspace)
def get_task_result(task_result_id: uuid.UUID) -> JSONResponse[TaskResult]: """Get the state of a task result. .. :quickref: Task result; Get a single task result. .. note: To check if the task failed you should use the ``state`` attribute of the returned object as the status code of the response will still be 200. It is 200 as we successfully fulfilled the request, which was getting the task result. :param task_result_id: The task result to get. :returns: The retrieved task result. """ result = get_or_404(TaskResult, task_result_id) TaskResultPermissions(result).ensure_may_see() return JSONResponse.make(result)
def launch_lti() -> t.Any: """Do a LTI launch. """ try: params = lti_v1_1.LTI.create_from_request(flask.request).launch_params except exceptions.APIException as exc: return _make_blob_and_redirect( { 'type': 'exception', 'exception_message': exc.message, 'original_exception': JSONResponse.dump_to_object(exc), }, version=LTIVersion.v1_1, goto_latest_submission=False, ) return _make_blob_and_redirect(params, LTIVersion.v1_1, goto_latest_submission=False)
def get_login_link( login_link_id: uuid.UUID) -> JSONResponse[models.AssignmentLoginLink]: """Get a login link and the connected assignment. .. :quickref: Login link; Get a login link and its connected assignment. :param login_link_id: The id of the login link you want to get. :returns: The requested login link, which will also contain information about the connected assignment. """ login_link = helpers.get_or_404( models.AssignmentLoginLink, login_link_id, also_error=lambda l: (not l.assignment.is_visible or not l.assignment.send_login_links)) auth.set_current_user(login_link.user) return JSONResponse.make(login_link)
def get_user_preferences() -> JSONResponse[t.Mapping[str, t.Optional[bool]]]: """Get ui preferences. .. :quickref: User Setting; Get UI preferences. :query str token: The token with which you want to get the preferences, if not given the preferences are retrieved for the currently logged in user. :returns: The preferences for the user as described by the ``token``. """ user = _get_user() return JSONResponse.make( { pref.name: value for pref, value in models.UIPreference.get_preferences_for_user(user).items() } )
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 JSONResponse.make({'access_token': user.make_access_token()})
def about( ) -> JSONResponse[t.Mapping[str, t.Union[str, object, t.Mapping[str, bool]]]]: """Get the version and features of the currently running instance. .. :quickref: About; Get the version and features. :>json string version: The version of the running instance. :>json object features: A mapping from string to a boolean for every feature indicating if the current instance has it enabled. :returns: The mapping as described above. """ _no_val = object() status_code = 200 features = { key.name: bool(value) for key, value in current_app.config['FEATURES'].items() } res = { 'version': current_app.config['VERSION'], 'commit': current_app.config['CUR_COMMIT'], 'features': features, } if request.args.get('health', _no_val) == current_app.config['HEALTH_KEY']: try: database = len( models.Permission.get_all_permissions( permissions.CoursePermission)) == len(CoursePermission) except: # pylint: disable=bare-except logger.error('Database not working', exc_info=True) database = False uploads = check_dir(current_app.config['UPLOAD_DIR'], check_size=True) mirror_uploads = check_dir(current_app.config['MIRROR_UPLOAD_DIR'], check_size=True) temp_dir = check_dir(tempfile.gettempdir(), check_size=True) with helpers.BrokerSession() as ses: try: # Set a short timeout as the broker really shouldn't take # longer than 2 seconds to answer. ses.get('/api/v1/ping', timeout=2).raise_for_status() except RequestException: logger.error('Broker unavailable', exc_info=True) broker_ok = False else: broker_ok = True health_value = { 'application': True, 'database': database, 'uploads': uploads, 'broker': broker_ok, 'mirror_uploads': mirror_uploads, 'temp_dir': temp_dir, } res['health'] = health_value if not all(health_value.values()): status_code = 500 return JSONResponse.make(res, status_code=status_code)
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']) with helpers.get_from_request_transaction() as [get, _]: auth_token = get('auth_token', str) name = get('name', str) deadline_str = get('deadline', str) if 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 {auth_token} is not correct', exceptions.APICodes.INCORRECT_PERMISSION, 403) deadline = parsers.parse_datetime(deadline_str) dp_resource = lti_v1_3.CGDeepLinkResource.make( lti_provider=provider, deadline=deadline).set_title(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 about() -> JSONResponse[AboutAsJSON]: """Get information about this CodeGrade instance. .. :quickref: About; Get the version and features. """ _no_val = object() status_code = 200 settings = site_settings.Opt.get_frontend_opts() release_info = current_app.config['RELEASE_INFO'] res: AboutAsJSON = { 'version': release_info.get('version'), 'commit': release_info['commit'], # We include the old features here to be backwards compatible. 'features': { 'AUTOMATIC_LTI_ROLE': settings['AUTOMATIC_LTI_ROLE_ENABLED'], 'AUTO_TEST': settings['AUTO_TEST_ENABLED'], 'BLACKBOARD_ZIP_UPLOAD': settings['BLACKBOARD_ZIP_UPLOAD_ENABLED'], 'COURSE_REGISTER': settings['COURSE_REGISTER_ENABLED'], 'EMAIL_STUDENTS': settings['EMAIL_STUDENTS_ENABLED'], 'GROUPS': settings['GROUPS_ENABLED'], 'INCREMENTAL_RUBRIC_SUBMISSION': settings['INCREMENTAL_RUBRIC_SUBMISSION_ENABLED'], 'LINTERS': settings['LINTERS_ENABLED'], 'LTI': settings['LTI_ENABLED'], 'PEER_FEEDBACK': settings['PEER_FEEDBACK_ENABLED'], 'REGISTER': settings['REGISTER_ENABLED'], 'RENDER_HTML': settings['RENDER_HTML_ENABLED'], 'RUBRICS': settings['RUBRICS_ENABLED'], }, 'settings': settings, 'release': release_info, } if request.args.get('health', _no_val) == current_app.config['HEALTH_KEY']: try: database = len( models.Permission.get_all_permissions( permissions.CoursePermission ) ) == len(CoursePermission) except: # pylint: disable=bare-except logger.error('Database not working', exc_info=True) database = False min_free = current_app.config['MIN_FREE_DISK_SPACE'] uploads = current_app.file_storage.check_health( min_free_space=min_free ) mirror_uploads = current_app.file_storage.check_health( min_free_space=min_free ) with models.BrokerSetting.get_current().get_session(retries=2) as ses: try: # Set a short timeout as the broker really shouldn't take # longer than 2 seconds to answer. ses.get('/api/v1/ping', timeout=2).raise_for_status() except RequestException: logger.error('Broker unavailable', exc_info=True) broker_ok = False else: broker_ok = True health_value: HealthAsJSON = { 'application': True, 'database': database, 'uploads': uploads, 'broker': broker_ok, 'mirror_uploads': mirror_uploads, 'temp_dir': True, } res['health'] = health_value if not all(health_value.values()): status_code = 500 return JSONResponse.make(res, status_code=status_code)