def test_get_for_team(self): """You can list cycles for a team you own.""" other, teammate, captain, team, cycles = self.create() # Forbidden for non-members. response = self.testapp.get( '/api/teams/{}/cycles'.format(team.uid), headers=jwt_headers(other), status=403 ) # Successful for members. response = self.testapp.get( '/api/teams/{}/cycles'.format(team.uid), headers=jwt_headers(teammate), ) response_list = json.loads(response.body) self.assertEqual(len(response_list), 2) # Can filter by ordinal response = self.testapp.get( '/api/teams/{}/cycles?ordinal=1'.format(team.uid), headers=jwt_headers(teammate), ) response_list = json.loads(response.body) self.assertEqual(len(response_list), 1) self.assertEqual(response_list[0]['ordinal'], 1)
def test_create(self): """Only captains can add cycles.""" other, teammate, captain, team, cycles = self.create() last = cycles[-1] start_date = last.start_date + datetime.timedelta(weeks=5) end_date = last.end_date + datetime.timedelta(weeks=5) # Forbidden for non-captains. for user in (other, teammate): self.testapp.post_json( '/api/cycles', { 'team_id': team.uid, 'start_date': start_date.strftime(config.iso_date_format), 'end_date': end_date.strftime(config.iso_date_format), }, headers=jwt_headers(user), status=403, ) # Successful for captains. response = self.testapp.post_json( '/api/cycles', { 'team_id': team.uid, 'start_date': start_date.strftime(config.iso_date_format), 'end_date': end_date.strftime(config.iso_date_format), }, headers=jwt_headers(captain), ) self.assertIsNotNone(Cycle.get_by_id(json.loads(response.body)['uid']))
def test_delete(self): other, teammate, team, cycles, responses = self.create() # Forbidden to delete other people's responses. self.testapp.delete( '/api/responses/{}'.format(responses['user_team_other'].uid), headers=jwt_headers(teammate), status=403, ) # Successful to delete your own. self.testapp.delete( '/api/responses/{}'.format(responses['user_team_user1'].uid), headers=jwt_headers(teammate), status=204, ) self.assertIsNone(Response.get_by_id(responses['user_team_user1'].uid)) # Forbidden to delete other team's responses. self.testapp.delete( '/api/responses/{}'.format(responses['team_other'].uid), headers=jwt_headers(teammate), status=403, ) # Successful to delete responses from your team. self.testapp.delete( '/api/responses/{}'.format(responses['team_team'].uid), headers=jwt_headers(teammate), status=204, ) self.assertIsNone(Response.get_by_id(responses['team_team'].uid))
def test_delete(self): """Only team captains can delete cycles.""" other, teammate, captain, team, cycles = self.create() other = User.create(name='other', email='*****@*****.**') other.put() # Forbidden by non-captains. for user in (teammate, other): self.testapp.delete( '/api/cycles/{}'.format(cycles[1].uid), headers=jwt_headers(user), status=403, ) # Successful by captain. self.testapp.delete( '/api/cycles/{}'.format(cycles[1].uid), headers=jwt_headers(captain), status=204, ) self.assertIsNone(Cycle.get_by_id(cycles[1].uid)) # Forbidden if cycles are too few. self.testapp.delete( '/api/cycles/{}'.format(cycles[2].uid), headers=jwt_headers(captain), status=403, ) # The last cycle wasn't deleted. self.assertIsNotNone(Cycle.get_by_id(cycles[2].uid))
def test_overlap_forbidden(self): other, teammate, captain, team, cycles = self.create() # Try to create a new cycle that wraps the others. before = cycles[1].start_date - datetime.timedelta(days=1) after = cycles[2].end_date + datetime.timedelta(days=1) wrapping_params = { 'team_id': team.uid, 'ordinal': 1, 'start_date': before.strftime(config.iso_date_format), 'end_date': after.strftime(config.iso_date_format), } self.testapp.post_json( '/api/cycles', wrapping_params, headers=jwt_headers(captain), status=400, ) # Nothing created in db. self.assertEqual(len(Cycle.get()), 3) # Same story for updating. self.testapp.put_json( '/api/cycles/{}'.format(cycles[1].uid), wrapping_params, headers=jwt_headers(captain), status=400, ) # Cycle's dates haven't changed. fetched = Cycle.get_by_id(cycles[1].uid) self.assertEqual(fetched.start_date, cycles[1].start_date) self.assertEqual(fetched.end_date, cycles[1].end_date)
def test_get_for_team(self): """You can list participants for a team you own.""" other, teammate, contact, captain, team, classroom, ppnt = self.create( ) # Forbidden for non-members. response = self.testapp.get('/api/teams/{}/participants'.format( team.uid), headers=jwt_headers(other), status=403) # Make sure query excludes participants on other teams. other_ppnt = Participant.create(first_name='other', last_name='person', team_id='Team_foo', classroom_ids=['Classroom_foo'], student_id='STUDENTID001') other_ppnt.put() # Successful for members. response = self.testapp.get( '/api/teams/{}/participants'.format(team.uid), headers=jwt_headers(teammate), ) response_list = json.loads(response.body) self.assertEqual(len(response_list), 1)
def test_update_question_level(self): """Question-level updates without conflicts.""" other, teammate, team, cycles, responses = self.create() # New question. Added to the existing body (existing remain). response_id = responses['team_team'].uid self.testapp.put_json( '/api/responses/{}'.format(response_id), {'body': { 'question_new': { 'value': 'foo', 'modified': None } }}, headers=jwt_headers(teammate), ) fetched = Response.get_by_id(response_id) self.assertIn('question', fetched.body) # New question was given a timestamp. self.assertIsNotNone(fetched.body['question_new']['modified']) # Question unchanged. Do nothing, even if timestamp is old. old_body = self.default_body() old_body['question']['modified'] = '2000-01-01T00:00:00Z' self.testapp.put_json( '/api/responses/{}'.format(response_id), {'body': old_body}, headers=jwt_headers(teammate), ) fetched = Response.get_by_id(response_id) self.assertEqual( fetched.body['question'], # current value is... self.default_body()['question'], # how it was originally created ) # Non-stale update. Existing timestamp matches and value changes. new_body = self.default_body() new_body['question']['value'] = 'updated' self.testapp.put_json( '/api/responses/{}'.format(response_id), {'body': new_body}, headers=jwt_headers(teammate), ) fetched = Response.get_by_id(response_id) self.assertEqual( fetched.body['question']['value'], new_body['question']['value'], ) # Timestamp should update. self.assertGreater( fetched.body['question']['modified'], new_body['question']['modified'], )
def test_update_force(self): """Force flag is set, override conflicts and save.""" other, teammate, team, cycles, responses = self.create() # Value is changed and timestamp is old response_id = responses['team_team'].uid now = datetime.datetime.now().strftime(config.iso_datetime_format) old_time = '2000-01-01T00:00:00Z' body = { # based on stale data, but will be accepted anyway 'question': { 'value': 'bar', 'modified': old_time }, # this should be accepted also 'question_new': { 'value': 'foo', 'modified': old_time }, } self.testapp.put_json( '/api/responses/{}?force=true'.format(response_id), {'body': body}, headers=jwt_headers(teammate), ) fetched = Response.get_by_id(response_id) for k, info in body.items(): self.assertIn(k, fetched.body) self.assertEqual(info['value'], fetched.body[k]['value']) self.assertTrue(fetched.body[k]['modified'] >= now)
def test_survey_participation(self): org_id = 'Org_foo' user = User.create(email='*****@*****.**', owned_organizations=[org_id]) user.put() pds = mock_one_finished_one_unfinished( 1, 'Participant_unfinished', 'Participant_finished', program_label=self.program_label, cohort_label=self.cohort_label, organization_id=org_id, ) result = self.testapp.get( '/api/surveys/{}/participation'.format(pds[0].survey_id), headers=jwt_headers(user), ) expected = [ { "survey_ordinal": 1, "value": "1", "n": 1 }, { "survey_ordinal": 1, "value": "100", "n": 1 }, ] self.assertEqual(json.loads(result.body), expected)
def test_update_with_conflicts(self): other, teammate, team, cycles, responses = self.create() # Value is changed and timestamp is old response_id = responses['team_team'].uid self.testapp.put_json( '/api/responses/{}'.format(response_id), { 'body': { # based on stale data 'question': { 'value': 'bar', 'modified': '2000-01-01T00:00:00Z', }, # this should be ignored b/c of above 'question_new': { 'value': 'foo', 'modified': '2000-01-01T00:00:00Z', }, }, }, headers=jwt_headers(teammate), status=409, ) fetched = Response.get_by_id(response_id) # Whole update is rejected, body unchanged. self.assertEqual(responses['team_team'].body, fetched.body)
def test_batch_rollback(self): (other, teammate, contact, captain, team, classroom, ppnt) = self.create() class1ppt1 = { 'first_name': u'Je\u017cu', 'last_name': u'Kl\u0105tw', 'classroom_id': classroom.uid, 'student_id': 'duplicateId', } class1ppt2 = { 'first_name': u'Ppt', 'last_name': u'One-Two', 'classroom_id': classroom.uid, 'student_id': 'duplicateId', } def postBody(ppt): return {'method': 'POST', 'path': '/api/participants', 'body': ppt} # Posting a duplicate should roll back the whole change. self.testapp.patch_json( '/api/participants', [postBody(class1ppt1), postBody(class1ppt2)], headers=jwt_headers(captain), status=400, ) self.assertEqual(len(Participant.get()), 0)
def test_update_simple(self): """Check ownership-based update permission, no conflicts.""" other, teammate, team, cycles, responses = self.create() new_body = self.default_body() new_body['question']['value'] = 'change' # Forbidden to update other people's responses. self.testapp.put_json( '/api/responses/{}'.format(responses['user_team_other'].uid), {'body': new_body}, headers=jwt_headers(teammate), status=403, ) # Successful to update your own. self.testapp.put_json( '/api/responses/{}'.format(responses['user_team_user1'].uid), {'body': new_body}, headers=jwt_headers(teammate), ) self.assertTrue( self.body_values_match( Response.get_by_id(responses['user_team_user1'].uid).body, new_body, )) # Forbidden to update other teams. self.testapp.put_json( '/api/responses/{}'.format(responses['team_other'].uid), {'body': new_body}, headers=jwt_headers(teammate), status=403, ) # Successful to update team-level. self.testapp.put_json( '/api/responses/{}'.format(responses['team_team'].uid), {'body': new_body}, headers=jwt_headers(teammate), ) self.assertTrue( self.body_values_match( Response.get_by_id(responses['team_team'].uid).body, new_body, ))
def test_get_for_team(self): """You can list responses for a team you own.""" other, teammate, team, cycles, responses = self.create() # Forbidden for non-members. response = self.testapp.get('/api/teams/{}/responses'.format(team.uid), headers=jwt_headers(other), status=403) # Successful for members. response = self.testapp.get( '/api/teams/{}/responses'.format(team.uid), headers=jwt_headers(teammate), ) response_list = json.loads(response.body) def get_by_type(typ, response_list): return next(rd for rd in response_list if rd['uid'] == responses[typ].uid) # Responses from yourself or your team should have bodies. for typ in ('user_team_user1', 'user_team_user2', 'team_team'): self.assertEqual( get_by_type(typ, response_list)['body'], responses[typ].body, ) # Responses from other users should be present but have empty bodies. self.assertEqual( get_by_type('user_team_other', response_list)['body'], {}, ) # Any responses from other teams should not be returned. self.assertEqual(len(response_list), 4) # Can filter by cycle. cycle_id = cycles[0].uid response = self.testapp.get( '/api/teams/{}/responses?parent_id={}'.format(team.uid, cycle_id), headers=jwt_headers(teammate), ) response_list = json.loads(response.body) self.assertEqual(len(response_list), 2) self.assertEqual(set(r['parent_id'] for r in response_list), {cycle_id})
def test_get(self): other, teammate, captain, team, cycles = self.create() current = cycles[-1] response = self.testapp.get( '/api/cycles/{}'.format(current.uid), headers=jwt_headers(teammate), ) self.assertEqual(current.uid, json.loads(response.body)['uid'])
def test_delete_not_supported(self): other, teammate, contact, captain, team, classroom, ppnt = self.create( ) # Not supported. for user in (other, teammate, contact, captain): self.testapp.delete( '/api/participants/{}'.format(ppnt.uid), headers=jwt_headers(user), status=405, )
def query_pc_participation(self, user, project_cohort): """Populates memcache w/ participation (even if blank) for this pc.""" start = datetime.datetime.now() - datetime.timedelta(days=1) end = datetime.datetime.now() + datetime.timedelta(days=1) url = '/api/project_cohorts/{}/participation?start={}&end={}'.format( project_cohort.uid, start.strftime(config.iso_datetime_format), end.strftime(config.iso_datetime_format), ) self.testapp.get(url, headers=jwt_headers(user))
def test_batch_participation(self): user = User.create(email='*****@*****.**') user.put() pc_kwargs = { 'program_label': self.program_label, 'cohort_label': self.cohort_label, } pcs = [ ProjectCohort.create(**pc_kwargs), ProjectCohort.create(**pc_kwargs), ] ndb.put_multi(pcs) all_pds = [] for pc in pcs: pds = mock_one_finished_one_unfinished( 1, 'Participant_unfinished', 'Participant_finished', pc_id=pc.uid, code=pc.code, program_label=self.program_label, cohort_label=self.cohort_label, ) all_pds += pds # Forbidden without allowed endpoints. pc_ids = [pc.uid for pc in pcs] self.testapp.get( '/api/project_cohorts/participation?uid={}&uid={}'.format(*pc_ids), headers=jwt_headers(user), status=403) # Running various queries works as expected. self.batch_participation(user, pcs) # Simulate a new pd being written to the first pc by clearing that # memcache key. The server should fall back to sql and still give the # same results. id_key = ParticipantData.participation_by_pc_cache_key(pcs[0].uid) code_key = ParticipantData.participation_by_pc_cache_key(pcs[0].code) self.assertIsNotNone(memcache.get(id_key)) self.assertIsNotNone(memcache.get(code_key)) memcache.delete(id_key) memcache.delete(code_key) self.batch_participation(user, pcs) # Now with everything cached, clearing the db and running the same # queries again should have the same result. ParticipantData.delete_multi(all_pds) self.batch_participation(user, pcs)
def test_get(self): other, teammate, team, cycles, responses = self.create() # Forbidden to see all responses. response = self.testapp.get( '/api/responses', headers=jwt_headers(other), status=403, ) # Forbidden to see other user's responses. response = self.testapp.get( '/api/responses/{}'.format(responses['user_team_other'].uid), headers=jwt_headers(other), status=403, ) # Forbidden to see other team's responses. response = self.testapp.get( '/api/responses/{}'.format(responses['team_other'].uid), headers=jwt_headers(other), status=403, ) # Success for own resposnes. user_resp = self.testapp.get( '/api/responses/{}'.format(responses['user_team_user1'].uid), headers=jwt_headers(teammate), ) self.assertEqual(responses['user_team_user1'].uid, json.loads(user_resp.body)['uid']) # Success for team resposnes. team_resp = self.testapp.get( '/api/responses/{}'.format(responses['team_team'].uid), headers=jwt_headers(teammate), ) self.assertEqual(responses['team_team'].uid, json.loads(team_resp.body)['uid'])
def test_patch_with_disassociated_participant(self): """If a participant exists with no classrooms, they are found.""" other, teammate, contact, captain, team, classroom, _ = self.create() student_id = 'disassociated' # Add an participant who is disassociated from all classrooms. ppt = Participant.create( team_id=team.uid, classroom_ids=[], student_id=student_id, ) ppt.put() def postBody(ppt, id_modifier=''): ppt = ppt.copy() ppt['student_id'] += id_modifier return {'method': 'POST', 'path': '/api/participants', 'body': ppt} response = self.testapp.patch_json( '/api/participants', [{ 'method': 'POST', 'path': '/api/participants', 'body': { 'team_id': team.uid, 'classroom_id': classroom.uid, 'student_id': student_id, }, }], headers=jwt_headers(contact), ) response_list = json.loads(response.body) # The provided student id matched the disassociated user and the # db used that uid. self.assertEqual(len(response_list), 1) self.assertEqual(response_list[0]['uid'], ppt.uid) self.assertEqual( Participant.get_by_id(ppt.uid).classroom_ids, [classroom.uid], ) # `num_students` has incremented after adding new participant to # classroom. updated_classroom = Classroom.get_by_id(classroom.uid) self.assertEqual( updated_classroom.num_students, classroom.num_students + 1, )
def test_update(self): other, teammate, contact, captain, team, classroom, ppnt = self.create( ) # Can't update for other classrooms. for user in (other, teammate): self.testapp.put_json( '/api/participants/{}'.format(ppnt.uid), {'student_id': 'Dave'}, headers=jwt_headers(user), status=403, ) # Success for contacts and captains. for user in (contact, captain): new_first = 'Dave-new' response = self.testapp.put_json( '/api/participants/{}'.format(ppnt.uid), {'student_id': new_first}, headers=jwt_headers(user), ) fetched = Participant.get_by_id(json.loads(response.body)['uid']) self.assertEqual(fetched.student_id, new_first)
def test_get(self): other, teammate, contact, captain, team, classroom, ppnt = self.create( ) # Forbidden to see all participants. response = self.testapp.get( '/api/participants', headers=jwt_headers(other), status=403, ) # Forbidden to see other-team participants. response = self.testapp.get( '/api/participants/{}'.format(ppnt.uid), headers=jwt_headers(other), status=403, ) # Success for on-team participants. response = self.testapp.get( '/api/participants/{}'.format(ppnt.uid), headers=jwt_headers(teammate), ) self.assertEqual(ppnt.uid, json.loads(response.body)['uid'])
def test_batch_participation_subqueries(self): """Shouldn't fail if requested >30 pcs. See https://cloud.google.com/appengine/docs/standard/python/datastore/queries#Python_Property_filters """ user = User.create(email='*****@*****.**') user.put() url = '/api/project_cohorts/participation?{}'.format('&'.join( r'uid=cool%20cat{}'.format(x) for x in range(31))) self.testapp.get( url, headers=jwt_headers(user), status=404, # breaking subqueries would respond with a 500 )
def test_get_for_team_super(self): """Super admins should be able to see full responses for anyone.""" other, teammate, team, cycles, responses = self.create() admin = User.create(email='*****@*****.**', name="Super", user_type='super_admin') admin.put() response = self.testapp.get( '/api/teams/{}/responses'.format(team.uid), headers=jwt_headers(admin), ) response_list = json.loads(response.body) for r in response_list: self.assertGreater(len(r['body']), 0)
def test_update_privacy_ignored(self): other, teammate, team, cycles, responses = self.create() # Starts private. to_update = responses['user_team_user1'] self.assertTrue(to_update.private) # Try to make it non-private. self.testapp.put_json( '/api/responses/{}'.format(to_update.uid), dict(to_update.to_client_dict(), private=False), headers=jwt_headers(teammate), ) # Privacy unchanged. fetched = Response.get_by_id(to_update.uid) self.assertEqual(to_update.private, fetched.private)
def test_invalid_timestamp(self): """Timestamp is somehow _newer_ than the db. Indicates bigger error.""" other, teammate, team, cycles, responses = self.create() # Value is changed and timestamp is old response_id = responses['team_team'].uid self.testapp.put_json( '/api/responses/{}'.format(response_id), { 'body': { 'question': { 'value': 'bar', 'modified': '2020-01-01T00:00:00Z', }, }, }, headers=jwt_headers(teammate), status=500, )
def test_update_remove_from_all(self): other, teammate, contact, captain, team, classroom, ppnt = self.create( ) # Updating to have no classrooms DOES NOT delete the participant and # updates classroom counts. We keep participants even if they're not on # any classrooms so that if they're re-added their uid (which must be # synced with Neptune) remains the same. self.testapp.put_json( '/api/participants/{}'.format(ppnt.uid), {'classroom_ids': []}, headers=jwt_headers(captain), ) fetched_ppnt = Participant.get_by_id(ppnt.uid) self.assertIsNotNone(fetched_ppnt) self.assertEqual(fetched_ppnt.classroom_ids, []) fetched_classroom = Classroom.get_by_id(classroom.uid) self.assertEqual(fetched_classroom.num_students, classroom.num_students - 1)
def test_create_insert(self): """Server reorders team cycles and returns them in an envelope.""" other, teammate, captain, team, cycles = self.create() # Move the last cycle ahead one month so we have room to insert. last = cycles[-1] orig_start_date = last.start_date orig_end_date = last.end_date last.start_date = orig_start_date + datetime.timedelta(weeks=5) last.end_date = orig_end_date + datetime.timedelta(weeks=5) last.put() # Now insert a penultimate cycle. response = self.testapp.post_json( '/api/cycles?envelope=team_cycles', { 'team_id': team.uid, 'ordinal': last.ordinal + 1, 'start_date': orig_start_date.strftime(config.iso_date_format), 'end_date': orig_end_date.strftime(config.iso_date_format), }, headers=jwt_headers(captain), ) response_dict = json.loads(response.body) # New cycle is in the 'data' property. self.assertIsNotNone(Cycle.get_by_id(response_dict['data']['uid'])) # Other metadata is present in the envelope. self.assertEqual(response_dict['status'], 200) # Specifically the list of other cycles for this team. team_cycles = response_dict['team_cycles'] self.assertEqual(len(team_cycles), 3) # The one we created should be second-to-last. self.assertEqual(team_cycles[-2]['uid'], response_dict['data']['uid']) # All the ordinals should be updated and sorted. self.assertEqual( [c['ordinal'] for c in team_cycles], [1, 2, 3] )
def test_invite_triton_exists(self): inviter = User.create(email='*****@*****.**') inviter.put() invitee = User.create(email='*****@*****.**', hashed_password='******') invitee.put() response = self.testapp.post_json( '/api/invitations', { 'email': invitee.email, 'platform': 'triton', 'template_content': { 'foo': 'bar' }, 'domain': 'https://copilot.perts.net', 'from_address': '*****@*****.**', 'from_name': 'Copilot', 'reply_to': '*****@*****.**', }, headers=jwt_headers(inviter), ) response_user = json.loads(response.body) self.assertRegexpMatches(response_user['uid'], r'^User_') # One email queued. emails = Email.get() self.assertEqual(len(emails), 1) email = emails[0] # To the right person. email.to = invitee.email # With the right template. self.assertEqual(email.mandrill_template, 'triton-invite-exists') # And right template params. self.assertEqual(type(email.mandrill_template_content['foo']), unicode) self.assertGreater(len(email.mandrill_template_content['foo']), 0) self.assertIn('/login', email.mandrill_template_content['link'])
def test_update_remove_from_multiple_rosters(self): """When ppt is updated to have one fewer classroom id.""" other, teammate, contact, captain, team, classroom, _ = self.create() # Make a second classroom to associate with. classroom2 = Classroom.create( name="Second Classroom", team_id=team.uid, contact_id='User_contact', code="bar", ) # Start with a ppt on two classrooms. ppnt = Participant.create( team_id=team.uid, classroom_ids=[classroom.uid, classroom2.uid], student_id='toremove', ) ppnt.put() # Make sure count of students starts out correct. classroom2.num_students = 1 classroom2.put() # Remove them from just one of their classrooms. response = self.testapp.put_json( '/api/participants/{}'.format(ppnt.uid), {'classroom_ids': [classroom.uid]}, # classroom2 removed headers=jwt_headers(contact), ) # Check they're still on the other classroom. fetched = Participant.get_by_id(ppnt.uid) self.assertEqual(fetched.classroom_ids, [classroom.uid]) # Check that classroom size is updated. fetched_classroom2 = Classroom.get_by_id(classroom2.uid) self.assertEqual(fetched_classroom2.num_students, classroom2.num_students - 1)
def test_get_for_team_supervisor(self): """What organization supervisors can see... * Full responses for team-level * No body for user-level """ other, teammate, team, cycles, responses = self.create() supervisor = User.create(email='*****@*****.**', name="Supervisor", owned_organizations=team.organization_ids) supervisor.put() response = self.testapp.get( '/api/teams/{}/responses'.format(team.uid), headers=jwt_headers(supervisor), ) response_list = json.loads(response.body) for r in response_list: if r['type'] == Response.TEAM_LEVEL_SYMBOL: self.assertGreater(len(r['body']), 0) if r['type'] == Response.USER_LEVEL_SYMBOL: self.assertEqual(len(r['body']), 0)