def test_get_launch_url(describe, logged_in, admin_user, test_client): with describe('setup'), logged_in(admin_user): canvas_prov = helpers.to_db_object( helpers.create_lti1p3_provider(test_client, 'Canvas'), m.LTI1p3Provider ) moodle_prov = helpers.to_db_object( helpers.create_lti1p3_provider(test_client, 'Moodle'), m.LTI1p3Provider ) with describe('Should return a furl object'): # Furl is not typed yet so it makes sense to check this as mypy sees it # as an `Any` url = canvas_prov.get_launch_url(goto_latest_sub=False) assert isinstance(url, furl.furl) with describe('should not include an ID for canvas'): url = canvas_prov.get_launch_url(goto_latest_sub=False) assert canvas_prov.id not in str(url) with describe('should not include an ID for moodle'): url = moodle_prov.get_launch_url(goto_latest_sub=False) assert moodle_prov.id in str(url) with describe('should be possible to launch to the latest submission'): url = canvas_prov.get_launch_url(goto_latest_sub=True) assert '/launch_to_latest_submission' in str(url)
def test_getting_peer_feedback_connections(test_client, admin_user, session, describe, logged_in, yesterday, tomorrow): with describe('setup'), logged_in(admin_user): course = helpers.create_course(test_client) assignment = helpers.create_assignment(test_client, course, deadline=yesterday, state='open') teacher = helpers.create_user_with_role(session, 'Teacher', course) users = [ helpers.get_id( helpers.create_user_with_role(session, 'Student', course)) for _ in range(5) ] user1, user2, *_ = users for user in users: helpers.create_submission(test_client, assignment, for_user=user) helpers.enable_peer_feedback(test_client, assignment, amount=1) url = (f'/api/v1/assignments/{helpers.get_id(assignment)}/users' f'/{helpers.get_id(user1)}/peer_feedback_subjects/') conns = get_all_connections(assignment, 1)[user1] with describe('Can get connections for own user'): with logged_in(user1): api_conns = test_client.req( 'get', url, 200, result=[{ 'peer': helpers.to_db_object(user1, m.User), 'subject': helpers.to_db_object(conns[0], m.User), }]) with describe('Cannot get connections for other user as student'): with logged_in(user2): test_client.req('get', url, 403) with describe('Can get connections for other user as teacher'): with logged_in(teacher): test_client.req('get', url, 200, result=api_conns) with describe( 'No connections if assignments deadline has not expired just yet'): with logged_in(teacher): test_client.req( 'patch', f'/api/v1/assignments/{helpers.get_id(assignment)}', 200, data={'deadline': tomorrow.isoformat()}) with logged_in(user1): test_client.req('get', url, 200, result=[]) # Not even as teacher with logged_in(teacher): test_client.req('get', url, 200, result=[])
def test_delete_submission_of_group( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.GRADE_UPDATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = stub_function( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade' ) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='done', deadline=tomorrow ) # Create some users and let them have individual submissions g_user1 = helpers.create_lti1p3_user(session, lti1p3_provider) g_user2 = helpers.create_lti1p3_user(session, lti1p3_provider) for user in [g_user1, g_user2]: course_conn.maybe_add_user_to_course(user, ['Learner']) sub = helpers.create_submission(test_client, assig, for_user=user) helpers.to_db_object(sub, m.Work).set_grade( 5.0, m.User.resolve(admin_user) ) # Place the users in a group, with a submission with a different grade # than their individual submission. gset = helpers.create_group_set(test_client, course, 1, 2, [assig]) helpers.create_group(test_client, gset, [g_user1, g_user2]) gsub = helpers.create_submission(test_client, assig, for_user=g_user2) helpers.to_db_object(gsub, m.Work).set_grade( 6.0, m.User.resolve(admin_user) ) session.commit() with describe( 'deleting group submission passes back individual submissions' ): with logged_in(admin_user): test_client.req( 'delete', f'/api/v1/submissions/{helpers.get_id(gsub)}', 204 ) assert stub_passback.called_amount == 2 # The grade passed back is that of their individual submission assert stub_passback.args[0][0].get_score_given() == 5.0 assert stub_passback.args[1][0].get_score_given() == 5.0
def test_passback_single_submission( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) signal = watch_signal( signals.GRADE_UPDATED, clear_all_but=[m.LTI1p3Provider._passback_submission] ) stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = stub_function( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade', raise_pylti1p3_exc ) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='done', deadline=tomorrow ) user = helpers.create_lti1p3_user(session, lti1p3_provider) course_conn.maybe_add_user_to_course(user, []) older_sub, newest_sub = [ helpers.to_db_object( helpers.create_submission(test_client, assig, for_user=user), m.Work ) for _ in range(2) ] with describe('calling directly with non existing assignment is a noop'): m.LTI1p3Provider._passback_submission((newest_sub.id, 100000)) assert not stub_passback.called with describe('changing grade of non newest sub does not passback'): older_sub.set_grade(5.0, admin_user) assert signal.was_send_once assert not stub_passback.called with describe('changing grade on newest sub passes the new grade back'): newest_sub.set_grade(9.5, m.User.resolve(admin_user)) assert signal.was_send_once assert stub_passback.called_amount == 1 assert stub_passback.args[0][0].get_score_given() == 9.5 with describe('unsetting grade should passback something without grade'): newest_sub.set_grade(None, m.User.resolve(admin_user)) assert signal.was_send_once assert stub_passback.called_amount == 1 assert stub_passback.args[0][0].get_score_given() is None assert stub_passback.args[0][0].get_activity_progress() == 'Submitted'
def lti1p3_provider(logged_in, admin_user, test_client): with logged_in(admin_user): prov = helpers.to_db_object( helpers.create_lti1p3_provider(test_client, 'Canvas'), m.LTI1p3Provider ) yield prov
def test_passback_with_bonus_points( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) signal = watch_signal( signals.GRADE_UPDATED, clear_all_but=[m.LTI1p3Provider._passback_submission] ) stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = stub_function( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade', raise_pylti1p3_exc ) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='done', deadline=tomorrow ) test_client.req( 'patch', f'/api/v1/assignments/{helpers.get_id(assig)}', 200, data={'max_grade': 15} ) user = helpers.create_lti1p3_user(session, lti1p3_provider) course_conn.maybe_add_user_to_course(user, []) sub = helpers.to_db_object( helpers.create_submission(test_client, assig, for_user=user), m.Work ) with describe('can passback bonus points'), logged_in(admin_user): test_client.req( 'patch', f'/api/v1/submissions/{helpers.get_id(sub)}', 200, data={'grade': 14}, ) assert signal.was_send_once assert stub_passback.called_amount == 1 assert stub_passback.args[0][0].get_score_given() == 14 assert stub_passback.args[0][0].get_score_maximum() == 10
def test_get_jwks_for_provider( test_client, describe, lms, logged_in, admin_user ): with describe('setup'), logged_in(admin_user): prov = helpers.to_db_object( helpers.create_lti1p3_provider(test_client, lms), m.LTI1p3Provider ) url = f'/api/v1/lti1.3/providers/{helpers.get_id(prov)}/jwks' with describe( 'should be possible to get json config without being logged in' ): test_client.req( 'get', url, 200, result={'keys': [prov.get_public_jwk()]} )
def test_get_self_from_course( describe, admin_user, test_client, session, app, logged_in ): with describe('setup'), logged_in(admin_user): course = helpers.to_db_object( helpers.create_course(test_client), m.Course ) lti_course = helpers.to_db_object( helpers.create_lti_course(session, app, admin_user), m.Course ) with describe('Getting without course should return none'): assert m.LTI1p1Provider._get_self_from_course(None) is None with describe('Getting for non LTI course should return none'): assert m.LTI1p1Provider._get_self_from_course(course) is None with describe('Getting with LTI course should return instance'): prov = m.LTI1p1Provider._get_self_from_course(lti_course) assert prov is not None assert lti_course.lti_provider.id == prov.id with describe('Getting for for other LTI class should return none'): assert m.LTI1p3Provider._get_self_from_course(lti_course) is None
def _login(user, yield_token=False): setattr(ctx_stack.top, 'jwt_user', None) if isinstance(user, str) and user == 'NOT_LOGGED_IN': _TOKENS.append(None) res = None else: _TOKENS.append( helpers.to_db_object(user, m.User).make_access_token() ) res = user yield _TOKENS[-1] if yield_token else res _TOKENS.pop(-1) setattr(ctx_stack.top, 'jwt_user', None)
def test_find_assignment_without_resource_id( lti1p3_provider, describe, make_function_spy, admin_user, logged_in, session, app ): with describe('setup'), logged_in(admin_user): lti_course = helpers.to_db_object( helpers.create_lti_course(session, app, admin_user), m.Course ) spy = make_function_spy( flask_sqlalchemy.BaseQuery, 'filter', pass_self=True ) with describe('find_assignment does not query without resource_id'): res = lti1p3_provider.find_assignment(lti_course, None, None) assert res is None assert not spy.called
def _login(user, yield_token=False): setattr(ctx_stack.top, 'jwt_user', None) if isinstance(user, str) and user == 'NOT_LOGGED_IN': _TOKENS.append(None) res = None else: _TOKENS.append( flask_jwt.create_access_token( identity=helpers.to_db_object(user, m.User).id, fresh=True ) ) res = user yield _TOKENS[-1] if yield_token else res _TOKENS.pop(-1) setattr(ctx_stack.top, 'jwt_user', None)
def test_launch_with_incorrect_provider(test_client, describe, logged_in, admin_user, stub_function, monkeypatched_passback, session, app): with describe('setup'), logged_in(admin_user): # Make sure our own validation works, so don't use the one of pytli1p3 stub_function(pylti1p3.message_validators.ResourceMessageValidator, 'validate', lambda: True) provider = helpers.to_db_object( helpers.create_lti1p3_provider( test_client, 'Canvas', iss='https://canvas.instructure.com', client_id=str(uuid.uuid4()) + '_lms=' + 'Canvas'), m.LTI1p3Provider) def assert_launch_errors(msg, override, with_id=False): oidc = do_oidc_login( test_client, provider, redirect_to=app.config['EXTERNAL_URL'], override_data=override, with_id=with_id, ) blob_id = oidc.query.params['blob_id'] err = test_client.req('post', '/api/v1/lti/launch/2?extended', 400, data={'blob_id': blob_id}) assert msg in err['message'] return err with describe('cannot launch with non finalized provider'): provider._finalized = False session.commit() assert_launch_errors('This LTI connection is not yet finalized', {}) provider._finalized = True session.commit() with describe('cannot launch with incorrect iss'): for with_id in [True, False]: assert_launch_errors( 'This LMS was not found as a LTIProvider', {'iss': 'OTHER ISS'}, with_id=with_id, )
def test_failing_passback( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.GRADE_UPDATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = stub_function( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade', raise_pylti1p3_exc ) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='done', deadline=tomorrow ) user = helpers.create_lti1p3_user(session, lti1p3_provider) course_conn.maybe_add_user_to_course(user, []) sub = helpers.to_db_object( helpers.create_submission(test_client, assig, for_user=user), m.Work ) sub.set_grade(10.0, m.User.resolve(admin_user)) session.commit() hist = m.GradeHistory.query.filter_by(work=sub).one() assert hist assert not hist.passed_back with describe('failing passback should not update history'): m.LTI1p3Provider._passback_grades(assig.id) assert stub_passback.called hist = m.GradeHistory.query.filter_by(work=sub).one() assert hist assert not hist.passed_back
def test_setting_deadline_for_assignment( test_client, describe, logged_in, admin_user, session, tomorrow, lms, err_code ): with describe('setup'), logged_in(admin_user): prov = helpers.to_db_object( helpers.create_lti1p3_provider(test_client, lms), m.LTI1p3Provider ) course, _ = helpers.create_lti1p3_course(test_client, session, prov) assig = helpers.create_lti1p3_assignment(session, course) with describe('should maybe be possible to update the deadline' ), logged_in(admin_user): test_client.req( 'patch', f'/api/v1/assignments/{helpers.get_id(assig)}', err_code, data={'deadline': tomorrow.isoformat()} )
def test_has_permission_filter(describe, test_client, session, admin_user, logged_in, perm): with describe('setup'), logged_in(admin_user): course = helpers.to_db_object(helpers.create_course(test_client), m.Course) rol1 = m.CourseRole('rol1', course, hidden=False) rol2 = m.CourseRole('rol2', course, hidden=False) session.add(rol1) session.add(rol2) session.commit() user1 = helpers.create_user_with_role(session, 'rol1', course) user2 = helpers.create_user_with_role(session, 'rol2', course) rol1.set_permission(perm, True) rol2.set_permission(perm, False) session.commit() with describe('Role is include if (and only if) the permission is true'): roles = m.CourseRole.get_roles_with_permission(perm).all() assert rol1 in roles assert rol2 not in roles with describe('Can be filtered further'): roles = m.CourseRole.get_roles_with_permission(perm).filter( m.CourseRole.course == course).all() assert all(r.course_id == course.id for r in roles) assert 'rol1' in [r.name for r in roles] assert 'Teacher' in [r.name for r in roles] with describe('User show up if permission is true not otherwise'): query = course.get_all_users_in_course( include_test_students=False, with_permission=perm, ) assert sorted([user1.id, admin_user.id]) == sorted(user.id for user, _ in query)
def test_retrieve_users_in_course( describe, lti1p3_provider, stub_function_class, monkeypatch, test_client, admin_user, logged_in, session, app, watch_signal ): with describe('setup'), logged_in(admin_user): course = helpers.to_db_object( helpers.create_course(test_client), m.Course ) membership_url = f'http://{uuid.uuid4()}' lti_course, _ = helpers.create_lti1p3_course( test_client, session, lti1p3_provider, membership_url, ) return_value = [] stub_get = stub_function_class(lambda: copy.deepcopy(return_value)) monkeypatch.setattr( pylti1p3.names_roles.NamesRolesProvisioningService, 'get_members', stub_get ) assig_created_signal = watch_signal( # Make sure we flush the db as we expect that the created # assignment can be found by doing queries. signals.ASSIGNMENT_CREATED, flush_db=True ) user_added_signal = watch_signal( signals.USER_ADDED_TO_COURSE, clear_all_but=[m.LTI1p3Provider._retrieve_users_in_course] ) new_user_id1 = str(uuid.uuid4()) new_user_id2 = str(uuid.uuid4()) do_poll_again = True stub_poll_again = stub_function_class(lambda: do_poll_again) monkeypatch.setattr( m.CourseLTIProvider, 'can_poll_names_again', stub_poll_again ) with describe('make sure it is connected to the necessary signals'): assert signals.USER_ADDED_TO_COURSE.is_connected( m.LTI1p3Provider._retrieve_users_in_course ) assert signals.ASSIGNMENT_CREATED.is_connected( m.LTI1p3Provider._retrieve_users_in_course ) with describe('non lti courses should be ignored'): m.LTI1p3Provider._retrieve_users_in_course(course.id) assert not stub_get.called with describe('should work when no members are returned'): assig = helpers.create_lti1p3_assignment(session, lti_course) assert assig_created_signal.was_send_once assert stub_get.called assert user_added_signal.was_not_send assert stub_poll_again.called with describe('Should be possible to add members'): return_value = [ { 'status': 'Active', # Not correct at all, but the function should still not crash. 'message': object(), 'user_id': new_user_id1, 'email': '*****@*****.**', 'name': 'USER1', }, { 'status': 'Active', 'message': { claims.CUSTOM: {'cg_username_0': 'username_user2'} }, 'user_id': new_user_id2, 'email': '*****@*****.**', 'name': 'USER2', 'roles': ['Student'], }, ] signals.ASSIGNMENT_CREATED.send(assig) assert stub_poll_again.called assert user_added_signal.was_send_once # USER1 should not be added and recursion should not happen assert user_added_signal.called_amount == 1 assert m.User.query.filter_by(username='******' ).one().is_enrolled(lti_course) assert m.User.query.filter_by(email='*****@*****.**' ).one_or_none() is None with describe('Can add known users to new courses, even without username'): # Remove the message claim return_value = [{**r, 'message': {}} for r in return_value] with logged_in(admin_user): lti_course2, _ = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) helpers.create_lti1p3_assignment(session, lti_course2) assert user_added_signal.was_send_once assert m.User.query.filter_by(username='******' ).one().is_enrolled(lti_course2) assert m.User.query.filter_by(email='*****@*****.**' ).one_or_none() is None with describe('if can poll return no poll should be done'): do_poll_again = False signals.ASSIGNMENT_CREATED.send(assig) assert not stub_get.called
def test_valid_saml_flows(test_client, describe, logged_in, admin_user, stub_function, app, session, monkeypatch): with describe('setup'), logged_in(admin_user): with open(helpers.test_data('test_saml_xml', 'valid_with_logo.xml')) as f: xml = f.read() with open(helpers.test_data('test_saml_xml', 'valid_response.json')) as f: valid_response = json.load(f) stub_function(m.saml_provider._MetadataParser, 'get_metadata', lambda: xml) prov = helpers.to_db_object( helpers.create_sso_provider(test_client, stub_function, 'Prov', no_stub=True), m.Saml2Provider) token = valid_response.pop('token') request_time = DatetimeWithTimezone.fromisoformat( valid_response.pop('request_time')) auth_n_request = valid_response.pop('AuthNRequest') prov.id = valid_response.pop('prov_id') prov._key_data = valid_response.pop('key').encode('utf8') prov._cert_data = valid_response.pop('cert').encode('utf8') host = valid_response['http_host'] http = 'https' if valid_response['https'] == 'on' else 'http' external_url = furl.furl() external_url.scheme = http external_url.host = host session.commit() stub_function(psef.saml2, '_prepare_flask_request', lambda: valid_response) with test_client as client, freezegun.freeze_time(request_time): with describe('can do login when all is good'): client.get(f'/api/sso/saml2/login/{helpers.get_id(prov)}') with client.session_transaction() as sess: sess['[SAML]_AuthNRequestID'] = auth_n_request sess['[SAML]_TOKEN'] = token with monkeypatch.context() as ctx: ctx.setitem(app.config, 'EXTERNAL_URL', external_url.tostr()) res = client.post(f'/api/sso/saml2/acs/{helpers.get_id(prov)}', data='Not used') assert 300 <= res.status_code < 400 loc = res.headers['Location'] base = furl.furl(external_url.tostr()).add(path='/sso_login/') assert loc.startswith(base.tostr()) assert token == loc.split('/')[-1] db_base_id = flask.session['[SAML]_DB_BLOB_ID'] assert m.BlobStorage.query.get(db_base_id) is not None with describe('cannot use jwt route without token in session'): with client.session_transaction() as sess: assert sess.pop('[SAML]_TOKEN') == token err = client.req('post', f'/api/sso/saml2/jwts/{token}', 409) assert 'Could not find all required data' in err['message'] with describe('cannot use jwt route without correct token in session'): with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = 'incorrect_token' sess['[SAML]_DB_BLOB_ID'] = db_base_id assert m.BlobStorage.query.get(db_base_id) is not None err = client.req('post', f'/api/sso/saml2/jwts/{token}', 400) assert 'Invalid token provided' in err['message'] with describe( 'cannot use jwt route without correct token in url and session' ): wrong_token = str(uuid.uuid4()) with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = wrong_token sess['[SAML]_DB_BLOB_ID'] = db_base_id err = client.req('post', f'/api/sso/saml2/jwts/{wrong_token}', 400) assert 'Invalid token provided' in err['message'] with describe('cannot use jwt route without correct token in url'): with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = token sess['[SAML]_DB_BLOB_ID'] = db_base_id err = client.req('post', f'/api/sso/saml2/jwts/{wrong_token}', 400) assert 'Invalid token provided' in err['message'] with describe('cannot use blob after some time' ), freezegun.freeze_time(request_time + timedelta(minutes=15)): with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = token sess['[SAML]_DB_BLOB_ID'] = db_base_id client.req('post', f'/api/sso/saml2/jwts/{token}', 404) with describe('can use one time if all is right'): with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = token sess['[SAML]_DB_BLOB_ID'] = db_base_id res = client.req('post', f'/api/sso/saml2/jwts/{token}', 200) assert isinstance(res.get('access_token'), str) # Cannot use again with client.session_transaction() as sess: sess['[SAML]_TOKEN'] = token sess['[SAML]_DB_BLOB_ID'] = db_base_id client.req('post', f'/api/sso/saml2/jwts/{token}', 404)
def test_connecting_users_and_courses_lti1p1_provider( test_client, describe, logged_in, admin_user, stub_function, monkeypatched_passback, session, connect, canvas_lti1p1_provider, watch_signal, add_user_data, add_course_data, add_assig_data): with describe('setup'), logged_in(admin_user): watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) old_lti_context_id = str(uuid.uuid4()) new_lti_context_id = str(uuid.uuid4()) old_resource_id = str(uuid.uuid4()) new_resource_id = str(uuid.uuid4()) original_user = helpers.create_user_with_role(session, 'Student', []) session.commit() old_lti_user_id = str(uuid.uuid4()) assert original_user session.add( m.UserLTIProvider(user=original_user, lti_provider=canvas_lti1p1_provider, lti_user_id=old_lti_user_id)) course = m.Course.create_and_add(name='LTI COURSE JEE') m.CourseLTIProvider.create_and_add( course=course, lti_provider=canvas_lti1p1_provider, lti_context_id=old_lti_context_id, deployment_id=old_lti_context_id, ) assig = m.Assignment(course=course, name='Name', is_lti=True, lti_assignment_id=old_resource_id) session.add(assig) session.commit() provider = helpers.create_lti1p3_provider( test_client, 'Canvas', iss='https://canvas.instructure.com', client_id=str(uuid.uuid4()) + '_lms=' + 'Canvas') provider = helpers.to_db_object(provider, m.LTI1p3Provider) if connect: provider._updates_lti1p1 = canvas_lti1p1_provider session.commit() lti_user_id = str(uuid.uuid4()) data = make_launch_data( CANVAS_DATA, provider, { 'Assignment.id': new_resource_id, 'Course.id': new_lti_context_id, 'User.id': lti_user_id, }, ) to_merge = {} if add_course_data: to_merge['context_id'] = old_lti_context_id if add_user_data: to_merge['user_id'] = old_lti_user_id if add_assig_data: to_merge['resource_link_id'] = old_resource_id extra_data = { "https://purl.imsglobal.org/spec/lti/claim/lti1p1": to_merge, } with describe('should create a user with the given email'): complete_data = merge(data, extra_data) _, launch = do_oidc_and_lti_launch(test_client, provider, complete_data, 200) new_user = m.UserLTIProvider.query.filter_by( lti_user_id=lti_user_id).one().user if connect and add_user_data: assert new_user.id == original_user.id else: assert new_user.id != original_user.id if connect and add_course_data: assert launch['data']['course']['id'] == course.id else: assert launch['data']['course']['id'] != course.id if connect and add_assig_data and add_course_data: assert launch['data']['assignment']['id'] == assig.id assert m.Assignment.query.get( assig.id).lti_assignment_id == new_resource_id else: assert launch['data']['assignment']['id'] != assig.id
def test_giving_peer_feedback_comments( test_client, admin_user, session, describe, logged_in, yesterday, tomorrow, auto_approved, make_add_reply ): with describe('setup'), logged_in(admin_user): course = helpers.create_course(test_client) assignment = helpers.create_assignment( test_client, course, deadline=tomorrow, state='open' ) teacher = helpers.create_user_with_role(session, 'Teacher', course) users = [ helpers.get_id( helpers.create_user_with_role(session, 'Student', course) ) for _ in range(5) ] user1, *other_users = users subs_by_user = {} for user in users: helpers.create_submission(test_client, assignment, for_user=user) # Every user has two submissions subs_by_user[user] = helpers.create_submission( test_client, assignment, for_user=user ) helpers.enable_peer_feedback( test_client, assignment, amount=1, auto_approved=auto_approved ) conns = get_all_connections(assignment, 1)[user1] other_user = next(u for u in other_users if u not in conns) base_url = f'/api/v1/assignments/{helpers.get_id(assignment)}' pf_sub = subs_by_user[conns[0]] own_sub = subs_by_user[user1] add_reply = make_add_reply(pf_sub) with logged_in(teacher): teacher_rep = add_reply('Hello') base_comment_id = teacher_rep['comment_base_id'] teacher_rep.delete() with describe( 'Before deadline we have no connections (and cannot place feedback)' ), logged_in(user1): test_client.req( 'get', f'{base_url}/submissions/?latest_only', 200, result=[{ 'id': int, 'user': { 'id': user1, '__allow_extra__': True, }, '__allow_extra__': True, }] ) add_reply( 'Too early for a reply', expect_error=403, base_id=base_comment_id ) with logged_in(teacher): test_client.req( 'patch', base_url, 200, data={'deadline': yesterday.isoformat()} ) with describe( 'Will receive peer feedback submissions when getting all submissions' ), logged_in(user1): test_client.req( 'get', f'{base_url}/submissions/?latest_only&extended', 200, result=[pf_sub, own_sub], ) # Can also get older subs of a assigned user test_client.req( 'get', f'{base_url}/users/{conns[0]}/submissions/?extended', 200, result=[ pf_sub, { '__allow_extra__': True, 'user': {'id': conns[0], '__allow_extra__': True}, } ], ) with describe('Can comment on other submission'), logged_in(user1): reply = add_reply('A peer feedback comment', expect_peer_feedback=True) assert reply['approved'] == auto_approved with describe('Cannot change approval status yourself'), logged_in(user1): reply.set_approval(not auto_approved, err=403) with describe('Teacher can change approval status'), logged_in(teacher): reply = reply.set_approval(not auto_approved) with describe('Editing feedback resets approval status'): with logged_in(user1): # We don't reset it back to ``True`` if ``auto_approved`` is # ``True`` but we do set it back to ``False``. reply = reply.update('New content', approved=False) if auto_approved: # If reply is approved an ``auto_approved`` is ``True`` the # approval state should not change. with logged_in(teacher): reply.set_approval(True) with logged_in(user1): reply = reply.update('New content2', approved=True) with describe( 'Cannot place or edit peer feedback after pf deadline has expired' ): with logged_in(teacher): helpers.enable_peer_feedback( test_client, assignment, amount=1, auto_approved=auto_approved, days=0.5 ) assert get_all_connections(assignment, 1)[user1] == conns with logged_in(user1): add_reply('Another peer feedback comment', expect_error=403) reply.update('Cannot update!', err=403) with describe('Can always place peer feedback if pf time is None'): with logged_in(teacher): helpers.enable_peer_feedback( test_client, assignment, amount=1, auto_approved=auto_approved, days=None, ) with freezegun.freeze_time( DatetimeWithTimezone.utcnow() + timedelta(days=365 * 20) ), logged_in(user1): reply = reply.update('Can update way after the deadline!') with describe('Cannot add peer feedback to a non assigned sub' ), logged_in(other_user): add_reply( 'Not possible to add this', expect_error=403, base_id=reply['comment_base_id'] ) with describe('Student can see approved peer feedback after done'): with logged_in(teacher): reply = reply.set_approval(not auto_approved) test_client.req( 'get', f'/api/v1/submissions/{helpers.get_id(pf_sub)}/feedbacks/', 200, query={'with_replies': '1'}, result={ 'general': '', 'linter': {}, 'user': [{ 'id': int, '__allow_extra__': True, 'replies': [helpers.dict_without(reply, 'author'), ], }], 'authors': [helpers.to_db_object(user1, m.User)], }, ) test_client.req('patch', base_url, 200, data={'state': 'done'}) with logged_in(conns[0]): test_client.req( 'get', f'/api/v1/submissions/{helpers.get_id(pf_sub)}/feedbacks/', 200, query={'with_replies': '1'}, result={ 'general': '', 'linter': {}, 'authors': ([helpers.to_db_object(user1, m.User)] if reply['approved'] else []), 'user': ([{ 'id': int, '__allow_extra__': True, 'replies': [helpers.dict_without(reply, 'author')], }] if reply['approved'] else []), }, )
def test_disabling_peer_feedback( test_client, admin_user, session, describe, logged_in, yesterday, make_add_reply ): with describe('setup'), logged_in(admin_user): course = helpers.create_course(test_client) assignment = helpers.create_assignment( test_client, course, deadline=yesterday, state='open' ) teacher = helpers.create_user_with_role(session, 'Teacher', course) users = [ helpers.get_id( helpers.create_user_with_role(session, 'Student', course) ) for _ in range(5) ] user1, *other_users = users for user in users: helpers.create_submission(test_client, assignment, for_user=user) helpers.enable_peer_feedback(test_client, assignment) conns = get_all_connections(assignment, 1)[user1] base_url = f'/api/v1/assignments/{helpers.get_id(assignment)}' pf_sub = m.Work.query.filter_by( assignment_id=helpers.get_id(assignment), user_id=helpers.get_id(conns[0]), ).one() pf_url = f'{base_url}/peer_feedback_settings' add_reply = make_add_reply(pf_sub) with logged_in(user1): reply = add_reply( 'A peer feedback comment', expect_peer_feedback=True ) with describe('Students cannot disable peer feedback'), logged_in(user1): test_client.req('delete', pf_url, 403) with describe('Teachers can disable peer feedback'), logged_in(teacher): test_client.req('delete', pf_url, 204) get_all_connections(assignment, 0) with describe('Old feedback still exists after disabling' ), logged_in(teacher): test_client.req( 'get', f'/api/v1/submissions/{helpers.get_id(pf_sub)}/feedbacks/', 200, query={'with_replies': '1'}, result={ 'general': '', 'linter': {}, 'authors': [helpers.to_db_object(user1, m.User)], 'user': [{ 'id': int, '__allow_extra__': True, 'replies': [helpers.dict_without(reply, 'author')], }], } ) # Students can no longer see their peer feedback subs with describe('Student can no longer place new comments' ), logged_in(user1): add_reply( 'No peer feedback', expect_error=403, base_id=reply['comment_base_id'], ) with describe('deleting again does nothing'), logged_in(teacher): test_client.req('delete', pf_url, 204) get_all_connections(assignment, 0)
def test_passing_back_all_grades( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow, make_function_spy ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.GRADE_UPDATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) watch_signal(signals.WORK_DELETED, clear_all_but=[]) signal = watch_signal( signals.ASSIGNMENT_STATE_CHANGED, clear_all_but=[m.LTI1p3Provider._passback_grades] ) stub_get_acccess_token = stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = make_function_spy( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade', pass_self=True ) # Make a session with a response that returns json when the `json` # method is called req_session = requests_stubs.session_maker()() req_session.Response.json = lambda _=None: {} stub_requests_post = stub_function(requests, 'post', req_session.post) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='hidden', deadline=tomorrow ) user1 = helpers.create_lti1p3_user(session, lti1p3_provider) user2 = helpers.create_lti1p3_user(session, lti1p3_provider) user3 = helpers.create_lti1p3_user(session, lti1p3_provider) all_students = [user1, user2, user3] for u in all_students: course_conn.maybe_add_user_to_course(u, ['Learner']) lti_user_ids = [ m.UserLTIProvider.query.filter_by(user=u).one().lti_user_id for u in all_students ] gset = helpers.create_group_set(test_client, course, 1, 2, [assig]) helpers.create_group(test_client, gset, [user1, user3]) sub = helpers.to_db_object( helpers.create_submission(test_client, assig, for_user=user1), m.Work ) sub.set_grade(2.5, admin_user) # Make sure user2 does not have a non deleted submission user2_sub = helpers.create_submission( test_client, assig, for_user=user2 ) test_client.req( 'delete', f'/api/v1/submissions/{helpers.get_id(user2_sub)}', 204 ) with describe('changing assignment state to "open" does not passback'): assig.set_state_with_string('open') assert signal.was_send_once assert not stub_passback.called assert not stub_get_acccess_token.called with describe('changing to done does passback'): assig.set_state_with_string('done') assert signal.was_send_once assert stub_passback.called_amount == len(all_students) # Calls should be cached assert stub_get_acccess_token.called_amount == 1 p1, p2, p3 = stub_passback.args assert p1[0] != p2[0] != p3[0] assert p1[0].get_score_given() == 2.5 assert p2[0].get_score_given() == 2.5 assert {p1[0].get_user_id(), p2[0].get_user_id()} == {lti_user_ids[0], lti_user_ids[2]} # Does not have a submission assert p3[0].get_score_given() is None assert p3[0].get_user_id() == lti_user_ids[1] with describe('toggling to open and done should do a new passback'): assig.set_state_with_string('open') assert signal.was_send_once assert not stub_passback.called assert not stub_get_acccess_token.called assig.set_state_with_string('done') assert signal.was_send_n_times(2) assert stub_passback.called # access token should still be cached assert not stub_get_acccess_token.called
def test_delete_submission_passback( lti1p3_provider, describe, logged_in, admin_user, watch_signal, stub_function, test_client, session, tomorrow ): with describe('setup'), logged_in(admin_user): watch_signal(signals.WORK_CREATED, clear_all_but=[]) watch_signal(signals.GRADE_UPDATED, clear_all_but=[]) watch_signal(signals.USER_ADDED_TO_COURSE, clear_all_but=[]) stub_function( pylti1p3.service_connector.ServiceConnector, 'get_access_token', lambda: '' ) stub_passback = stub_function( pylti1p3.assignments_grades.AssignmentsGradesService, 'put_grade' ) course, course_conn = helpers.create_lti1p3_course( test_client, session, lti1p3_provider ) assig = helpers.create_lti1p3_assignment( session, course, state='done', deadline=tomorrow ) user = helpers.create_lti1p3_user(session, lti1p3_provider) lti_user_id = m.UserLTIProvider.query.filter_by( user=m.User.resolve(user) ).one().lti_user_id course_conn.maybe_add_user_to_course(user, ['Learner']) sub_oldest, sub_older, sub_middle, sub_newest = [ helpers.to_db_object( helpers.create_submission(test_client, assig, for_user=user), m.Work ) for _ in range(4) ] signal = watch_signal( signals.WORK_DELETED, clear_all_but=[m.LTI1p3Provider._delete_submission] ) def do_delete(sub): with logged_in(admin_user): test_client.req( 'delete', f'/api/v1/submissions/{helpers.get_id(sub)}', 204 ) with describe('Delete non newest'): do_delete(sub_older) assert signal.was_send_once assert not stub_passback.called with describe('Calling method for non existing work simply does nothing'): m.LTI1p3Provider._delete_submission(1000000) assert not stub_passback.called with describe('Calling method for non deleted work does nothing'): m.LTI1p3Provider._delete_submission(helpers.get_id(sub_newest)) assert not stub_passback.called with describe('Delete newest should passback grade of new newest'): sub_middle.set_grade(5.0, m.User.resolve(admin_user)) session.commit() # We should have removed the grade_updated signal assert not stub_passback.called do_delete(sub_newest) assert signal.was_send_once assert stub_passback.called_amount == 1 grade, = stub_passback.all_args[0].values() assert grade.get_score_given() == 5.0 assert grade.get_user_id() == lti_user_id with describe('Deleting new newest should passback next non deleted'): sub_older.set_grade(6.0, m.User.resolve(admin_user)) sub_oldest.set_grade(8.0, m.User.resolve(admin_user)) session.commit() assert not m.GradeHistory.query.filter_by(work=sub_oldest ).one().passed_back do_delete(sub_middle) assert signal.was_send_once assert stub_passback.called_amount == 1 grade, = stub_passback.all_args[0].values() # Should passback oldest as we deleted older in an earlier block assert grade.get_score_given() == 8.0 assert grade.get_user_id() == lti_user_id # Should update history assert m.GradeHistory.query.filter_by(work=sub_oldest ).one().passed_back with describe('Deleting without any existing submission should passback'): do_delete(sub_oldest) assert signal.was_send_once assert stub_passback.called_amount == 1 grade, = stub_passback.all_args[0].values() assert grade.get_score_given() is None assert grade.get_grading_progress() == 'NotReady' assert grade.get_user_id() == lti_user_id with describe('Passing back deleted sub should do nothing'): lti1p3_provider._passback_grade( assignment=assig, sub=sub_newest, timestamp=DatetimeWithTimezone.utcnow() ) assert not stub_passback.called