def test_delete_removes_team(self): user = User.create(name='foo', email='*****@*****.**') team = Team.create(name='Team Foo', captain_id=user.uid, program_id=self.demo_program.uid) team.put() user.owned_teams = [team.uid] user.put() survey = Survey.create(team_id=team.uid) survey.put() url = '/api/teams/{}'.format(team.uid) headers = self.login_headers(user) # Delete the team. self.testapp.delete(url, headers=headers, status=204) # Expect the team is gone from the db. self.assertIsNone(Team.get_by_id(team.uid)) # Api should show a 404. self.testapp.get(url, headers=headers, status=404) self.testapp.delete(url, headers=headers, status=404)
def test_delete_disassociates_teams(self): """When you delete an org, associated teams lose their org id.""" user, org_dict = self.test_create() teams = [ Team.create( name="Team Foo", organization_ids=[org_dict['uid']], captain_id='User_captain', program_id=self.program.uid, ), Team.create( name="Team Bar", organization_ids=[org_dict['uid']], captain_id='User_captain', program_id=self.program.uid, ), ] Team.put_multi(teams) response = self.testapp.delete('/api/organizations/{}'.format( org_dict['uid']), headers=self.login_headers(user), status=204) # Make sure the teams have lost their association to the org. for t in teams: fetched = Team.get_by_id(t.uid) self.assertNotIn(org_dict['uid'], fetched.organization_ids)
def test_resolve_mismatch(self): team, classroom, captain, contact = self.create() old_captain_id = captain.uid old_contact_id = contact.uid new_captain_id = 'User_newcaptain' new_contact_id = 'User_newcontact' captain = User.resolve_id_mismatch(captain, new_captain_id) contact = User.resolve_id_mismatch(contact, new_contact_id) self.assertEqual(captain.uid, new_captain_id) self.assertEqual(contact.uid, new_contact_id) fetched_captain = User.get_by_id(new_captain_id) fetched_contact = User.get_by_id(new_contact_id) self.assertIsNotNone(fetched_captain) self.assertIsNotNone(fetched_contact) fetched_team = Team.get_by_id(team.uid) self.assertEqual(fetched_team.captain_id, new_captain_id) fetched_classroom = Classroom.get_by_id(classroom.uid) self.assertEqual(fetched_classroom.contact_id, new_contact_id)
def get_team_reports(self, team_id): user = self.get_current_user() team = Team.get_by_id(team_id) if not team: return self.http_not_found() if not owns(user, team) and not has_captain_permission(user, team): return self.http_forbidden("Only team members can list reports.") # Limit access to preview reports, super admin only. show_preview_reports = True if user.super_admin else False # Returns both team- and class-level reports. all_reports = Report.get_for_team(team.uid, show_preview_reports) # Any team member can see the team reports... allowed_reports = [r for r in all_reports if not r.classroom_id] # ...but limit access to classroom reports. classroom_reports = [r for r in all_reports if r.classroom_id] classroom_ids = [r.classroom_id for r in classroom_reports] classrooms = {c.uid: c for c in Classroom.get_by_id(classroom_ids)} for report in classroom_reports: if has_contact_permission(user, classrooms[report.classroom_id]): allowed_reports.append(report) # Add a custom link for users to access each report. self.write([ dict(r.to_client_dict(), link=self.report_link(r)) for r in allowed_reports ])
def post(self, team_id, date_str=None): if date_str: today = datetime.strptime(date_str, config.iso_date_format).date() else: today = date.today() # Guaranteed to have start and end dates. cycle = Cycle.get_current_for_team(team_id, today) if not cycle: logging.info( "Either the team doesn't exist, or they don't have a cycle " "matching the current date. Doing nothing.") return team = Team.get_by_id(team_id) classrooms = Classroom.get(team_id=team_id) if len(classrooms) == 0: logging.info("No classrooms, setting participation to 0.") cycle.students_completed = 0 else: ppn = get_participation(cycle, classrooms) num_complete = 0 for code, counts in ppn.items(): complete_count = next( (c for c in counts if c['value'] == '100'), None) num_complete += complete_count['n'] if complete_count else 0 cycle.students_completed = num_complete cycle.put()
def test_task_data_too_large(self): user = User.create(name='foo', email='*****@*****.**') user.put() team_params = {'name': 'Team Foo', 'program_id': self.demo_program.uid} # A property is too long. self.testapp.post_json( '/api/teams', dict(team_params, task_data={'foo': 'x' * 10**5}), headers=self.login_headers(user), status=413, ) # Too many properties. self.testapp.post_json( '/api/teams', dict( team_params, task_data={'foo{}'.format(x): 'x' for x in range(10**3)}, ), headers=self.login_headers(user), status=413, ) # Both posts should have prevented teams from being stored. self.assertEqual(len(Team.get()), 0) # Successful POST response = self.testapp.post_json( '/api/teams', dict(team_params, task_data={'safe': 'data'}), headers=self.login_headers(user), ) response_dict = json.loads(response.body) put_url = '/api/teams/{}'.format(response_dict['uid']) # Same errors but for PUT # A property is too long. self.testapp.put_json( put_url, {'task_data': {'foo': 'x' * 10**5}}, headers=self.login_headers(user), status=413, ) # Too many properties. self.testapp.put_json( put_url, {'task_data': {'foo{}'.format(x): 'x' for x in range(10**3)}}, headers=self.login_headers(user), status=413, ) # Puts should have left body unchanged. self.assertEqual( Team.get_by_id(response_dict['uid']).task_data, {'safe': 'data'}, )
def test_delete_by_captain(self): """Captains can delete their teams.""" user, team_dict = self.create() response = self.testapp.delete( '/api/teams/{}'.format(team_dict['uid']), headers=self.login_headers(user), ) self.assertIsNone(Team.get_by_id(team_dict['uid'])) return response, user, team_dict
def test_delete_other_forbidden(self): user, team_dict = self.create() other = User.create(name='other', email='*****@*****.**') other.put() response = self.testapp.delete( '/api/teams/{}'.format(team_dict['uid']), headers=self.login_headers(other), status=403 ) self.assertIsNotNone(Team.get_by_id(team_dict['uid']))
def test_delete_own_forbidden(self): team = Team.create(name='foo', captain_id='User_cap', program_id=self.demo_program.uid) team.put() user = User.create(name='foo', email='*****@*****.**', owned_teams=[team.uid]) user.put() response = self.testapp.delete( '/api/teams/{}'.format(team.uid), headers=self.login_headers(user), status=403 ) self.assertIsNotNone(Team.get_by_id(team.uid))
def post(self, team_id, date_str=None): survey = Survey.get(team_id=team_id)[0] if date_str: today = datetime.strptime(date_str, config.iso_date_format).date() else: today = date.today() # Cycle ultimately comes from Cycle.get_current_for_team() and so is # guaranteed to have start and end dates. cycle = survey.should_notify(today) if not cycle: # This task is run every week, but only actually send notifications # if the date matches the survey interval. return team = Team.get_by_id(survey.team_id) program = Program.get_by_id(team.program_id) classrooms = Classroom.get(team_id=team_id) users = User.query_by_team(team_id) if len(classrooms) == 0: pct_complete_by_id = {} else: ppn = get_participation(cycle, classrooms) pct_complete_by_id = self.participation_to_pct(ppn, classrooms) # Get all the responses once to save trips to the db. Redact them later # according to the relevate user. unsafe_responses = Response.get_for_teams_unsafe([team_id], parent_id=cycle.uid) to_put = [] for user in users: if user.receive_email: safe_responses = Response.redact_private_responses( unsafe_responses, user) email = cycle_emailers.create_cycle_email( program.label, user, # recipient users, team, classrooms, safe_responses, cycle, pct_complete_by_id, ) to_put.append(email) ndb.put_multi(to_put)
def test_change_captain_forbidden(self): team = Team.create(name='foo', captain_id='User_cap', program_id=self.demo_program.uid) team.put() user = User.create(name='foo', email='*****@*****.**', owned_teams=[team.uid]) user.put() response = self.testapp.put_json( '/api/teams/{}'.format(team.uid), {'captain_id': user.uid}, headers=self.login_headers(user), status=403, ) team = Team.get_by_id(team.uid) self.assertNotEqual(team.captain_id, user.uid)
def get(self, parent_type, rel_id): user = self.get_current_user() team = Team.get_by_id(rel_id) if not team: return self.http_not_found() if not owns(user, team) and not has_captain_permission(user, team): return self.http_forbidden("Only team members can get responses.") parent_id = self.get_param('parent_id', str, None) # We return empty dictionaries for the `body` property of some # responses (private responses belonging to other users). responses = Response.get_for_teams(user, [team.uid], parent_id) self.write(responses)
def release_and_notify(self, week): # Update all reports for a given week and set preview = False, _and_ # create notifications for all related users. # Capture which reports are previews before we release them. We'll base # the notifications on this list to make sure we're not notifying about # any reports that aren't previews. reports_to_release = Report.get_previews(week) # This is the "release" part. num_reports_released = Report.release_previews(week) # Now we have to load a bunch of data from the related teams, # classrooms, and users in order to create notifications. if len(reports_to_release) != num_reports_released: logging.error( "Report.get_previews() ({}) and Report.release_previews() " "({}) didn't hit the same number of rows.".format( len(reports_to_release), num_reports_released)) team_ids = {r.team_id for r in reports_to_release} classroom_ids = { r.classroom_id for r in reports_to_release if r.classroom_id } # Load all related teams and classrooms as a batch. teams = Team.get_by_id(team_ids) classrooms = Classroom.get_by_id(classroom_ids) t_index = {t.uid: t for t in teams} c_index = {c.uid: c for c in classrooms} p_index = {p.uid: p for p in Program.get()} notes = [] for r in reports_to_release: team = t_index.get(r.team_id) program = p_index.get(team.program_id) classroom = c_index.get(r.classroom_id, None) result = self.notify_for_single_report(program, team, classroom) # result might be a list or None if result: notes += result Notification.put_multi(notes) return num_reports_released
def team_membership_removal_allowed(subject, requestor, params): """Is the requestor allowed to remove the subject from a team?""" if 'owned_teams' not in params: return False removed_ids = set(subject.owned_teams).difference(params['owned_teams']) if len(removed_ids) == 0: # We're not removing teams, no need to provide permission. return False if len(removed_ids) > 1: logging.info("Team membership can only be revoked one team at a time.") return False team = Team.get_by_id(removed_ids.pop()) # may be None return ( # Case 1: Requestor is team captain of team being removed. (team and has_captain_permission(requestor, team)) or # Case 2: Remove oneself from a team. subject.uid == requestor.uid or # Case 3: Requestor is super_admin requestor.super_admin)
def post(self): """Save references to reports, and perhaps the report file itself. Has two modes: accept a file, in which case the file is saved to GCS, or a dataset id. In both cases a Report is inserted referencing the file/dataset. Either `team_id` or `classroom_id` must be provided. If team_id, then the report's classroom_id is empty (these are "team-level" reports). If classroom_id, the corresponding team_id is looked up (these are "classroom-level" reports). """ # Allow RServe to call this endpoint, then fall back on regular auth. user, error = self.authenticate_rserve() if not user: user = self.get_current_user() error = '' # Replaces function of `requires_auth = True`. if user.user_type == 'public': return self.http_unauthorized() if not user.super_admin: return self.http_forbidden() org_id = self.get_param('organization_id', str, None) team_id = self.get_param('team_id', str, None) classroom_id = self.get_param('classroom_id', str, None) params = self.get_params({ 'dataset_id': str, 'filename': unicode, 'issue_date': str, 'notes': unicode, 'preview': bool, 'template': str, }) # Lookup related objects. classroom = None if classroom_id: classroom = Classroom.get_by_id(classroom_id) if not classroom: return self.http_bad_request( "Classroom not found: {}".format(classroom_id)) team_id = classroom.team_id team = None if team_id: # May be set for team reports, or via lookup for class reports. team = Team.get_by_id(team_id) if not team: return self.http_bad_request( "Team not found: {}".format(team_id)) org = None if org_id: org = Organization.get_by_id(org_id) if not org: return self.http_bad_request( "Organization not found: {}".format(org_id)) content_type = self.request.headers['Content-Type'] is_form = 'multipart/form-data' in content_type is_json = 'application/json' in content_type if is_form: report = self.save_file( params['filename'], self.request.POST['file'], org_id, team_id, classroom_id, ) elif is_json: kwargs = { 'classroom_id': classroom_id, 'dataset_id': params['dataset_id'], 'filename': params['filename'], 'issue_date': params.get('issue_date', None), 'notes': params.get('notes', None), 'organization_id': org_id, 'team_id': team_id, 'template': params['template'], } # Some params we may want to avoid including at all, so that if # they're absent they'll take the default value defined in the db. if params.get('preview', None) is not None: kwargs['preview'] = params['preview'] report = Report.create(**kwargs) else: return self.http_bad_request( "Only supported content types are 1) multipart/form-data for " "uploading files and 2) application/json for datasets. Got {}". format(self.request.headers['Content-Type'])) saved_report = Report.put_for_index(report, 'parent-file') self.write(saved_report)
def owns(user, id_or_entity): """Does this user own the object in question?""" # Supers own everything. if user.super_admin: return True # Standardize id vs. entity, saving db trips where possible. # Also, javascript-like closures are surprisingly hard in python. # https://stackoverflow.com/questions/2009402/read-write-python-closures class entity_closure(object): def __init__(self, id_or_entity): if isinstance(id_or_entity, basestring): self.uid = str(id_or_entity) self._entity = None else: self.uid = id_or_entity.uid self._entity = id_or_entity self.kind = SqlModel.get_kind(self.uid) def __call__(self): # Calling code may have pulled the entity for us already, no sense # in doing it again; on the other hand, merely an id may be # sufficient, depending on the kind, so only run this code if its # called. if self._entity is None: self._entity = SqlModel.kind_to_class(kind).get_by_id(self.uid) return self._entity get_entity = entity_closure(id_or_entity) kind, uid = get_entity.kind, get_entity.uid if kind == 'Classroom': classroom = get_entity() team = Team.get_by_id(classroom.team_id) team_member = classroom.team_id in user.owned_teams owns = team_member or is_supervisor_of_team(user, team) elif kind == 'Digest': digest = get_entity() owns = user.uid == digest.user_id elif kind == 'Metric': owns = False # only supers elif kind == 'Organization': owns = (uid in user.owned_organizations or uid in user.get_networked_organization_ids()) elif kind == 'Network': owns = uid in user.owned_networks elif kind == 'Team': # Users can own a team directly ("team member"), or can own one of the # organizations associated with the team. team = get_entity() owns = uid in user.owned_teams or is_supervisor_of_team(user, team) elif kind == 'Survey': survey = get_entity() team = Team.get_by_id(survey.team_id) team_member = survey.team_id in user.owned_teams owns = team_member or is_supervisor_of_team(user, team) elif kind == 'Report': report = get_entity() if report.classroom_id: classroom = Classroom.get_by_id(report.classroom_id) owns = user.uid == classroom.contact_id else: team = Team.get_by_id(report.team_id) team_member = report.team_id in user.owned_teams owns = team_member or is_supervisor_of_team(user, team) elif kind == 'Cycle': cycle = get_entity() team = Team.get_by_id(cycle.team_id) owns = (cycle.team_id in user.owned_teams or is_supervisor_of_team(user, team)) elif kind == 'Response': response = get_entity() team = Team.get_by_id(response.team_id) owns = ( response.user_id == user.uid or (response.type == Response.TEAM_LEVEL_SYMBOL and (response.team_id in user.owned_teams or # members, captain is_supervisor_of_team(user, team) # org supervisors ))) elif kind == 'Participant': ppnt = get_entity() classrooms = Classroom.get_by_id(ppnt.classroom_ids) team = Team.get_by_id(ppnt.team_id) owns = (any(has_contact_permission(user, c) for c in classrooms) or has_captain_permission(user, team)) elif kind == 'User': owns = uid == user.uid # no slavery! else: raise Exception("Ownership does not apply to " + uid) return owns
def get(self, user_id=None, team_id=None): complete = False # Determine authenticated user based on JWT token # @todo: can we apply jti or some other rule to make sure this URL isn't # inappropriately shareable? token = self.request.get('token', None) payload, error = jwt_helper.decode(token) if not payload or error: return self.http_forbidden() auth_user = User.get_by_id(payload['user_id']) user = User.get_by_id(user_id) team = Team.get_by_id(team_id) if not user or not team: return self.http_not_found() # The authenticated user can only retrieve their own certificate. # The authenticated user must own the team that they are requesting the # certificate for. if not auth_user == user and not owns(auth_user, team): return self.http_forbidden() classrooms = Classroom.get( contact_id=user.uid, team_id=team_id, ) cycles = Cycle.get( team_id=team_id, order='ordinal', ) if len(classrooms) > 0 and len(cycles) > 0: cycle_participation = self.get_cycle_participation_pct( cycles, classrooms, ) participation_complete = self.has_completed_three_cycles( cycle_participation) else: cycle_participation = [{ 'ordinal': c.ordinal, 'pct': 0, } for c in cycles] participation_complete = False exit_survey_complete = self.has_completed_exit_survey( user, team_id, ) if (exit_survey_complete and participation_complete): complete = True if (complete): # If a teacher has successfully completed participation for three # cycles, the certificate should not show any incomplete cycles # because they aren't relevant for the requirement of receiving the # completion certificate. See #1223. cycles_to_display = [ c for c in cycle_participation if c['pct'] >= 80 ][0:3] else: cycles_to_display = cycle_participation if util.is_localhost(): neptune_protocol = 'http' neptune_domain = 'localhost:8080' else: neptune_protocol = 'https' neptune_domain = os.environ['NEPTUNE_DOMAIN'] self.write( 'completion.html', neptune_protocol=neptune_protocol, neptune_domain=neptune_domain, complete=complete, user_to_display=user, team=team, cycles_to_display=cycles_to_display, exit_survey_complete=exit_survey_complete, )