def _curated_group_with_complete_student_profiles(curated_group_id, order_by='last_name', term_id=None, offset=0, limit=50): benchmark = get_benchmarker( f'curated group {curated_group_id} with student profiles') benchmark('begin') curated_group = CuratedGroup.find_by_id(curated_group_id) if not curated_group: raise ResourceNotFoundError( f'Sorry, no curated group found with id {curated_group_id}.') if not _can_current_user_view_curated_group(curated_group): raise ForbiddenRequestError( f'Current user, {current_user.get_uid()}, cannot view curated group {curated_group.id}' ) api_json = curated_group.to_api_json(order_by=order_by, offset=offset, limit=limit) sids = [s['sid'] for s in api_json['students']] benchmark('begin profile query') api_json['students'] = get_summary_student_profiles( sids, term_id=term_id, include_historical=True) benchmark('begin alerts query') Alert.include_alert_counts_for_students( viewer_user_id=current_user.get_id(), group=api_json) benchmark('end') benchmark('begin get_referencing_cohort_ids') api_json[ 'referencingCohortIds'] = curated_group.get_referencing_cohort_ids() benchmark('end') return api_json
def _curated_group_with_complete_student_profiles(curated_group_id, order_by='last_name', offset=0, limit=50): benchmark = get_benchmarker( f'curated group {curated_group_id} with student profiles') benchmark('begin') curated_group = CuratedGroup.find_by_id(curated_group_id) if not curated_group: raise ResourceNotFoundError( f'Sorry, no curated group found with id {curated_group_id}.') if curated_group.owner_id != current_user.get_id(): raise ForbiddenRequestError( f'Current user, {current_user.get_uid()}, does not own curated group {curated_group.id}' ) api_json = curated_group.to_api_json(order_by=order_by, offset=offset, limit=limit) sids = [s['sid'] for s in api_json['students']] benchmark('begin profile query') api_json['students'] = get_summary_student_profiles(sids) benchmark('begin alerts query') Alert.include_alert_counts_for_students( viewer_user_id=current_user.get_id(), group=api_json) benchmark('end') return api_json
def create_alerts(client, db_session): """Create assignment and midterm grade alerts.""" # Create three canned alerts for the current term and one for the previous term. from boac.models.alert import Alert Alert.create( sid='11667051', alert_type='late_assignment', key='2172_100900300', message='Week 5 homework in LATIN 100 is late.', ) Alert.create( sid='11667051', alert_type='late_assignment', key='2178_800900300', message='Week 5 homework in RUSSIAN 13 is late.', ) Alert.create( sid='11667051', alert_type='missing_assignment', key='2178_500600700', message='Week 6 homework in PORTUGUESE 12 is missing.', ) Alert.create( sid='2345678901', alert_type='late_assignment', key='2178_100200300', message='Week 5 homework in BOSCRSR 27B is late.', ) # Load our usual student of interest into the cache and generate midterm alerts from fixture data. client.get('/api/student/by_uid/61889') Alert.update_all_for_term(2178)
def test_no_activity_percentile_cutoff(self, app): """Respect percentile cutoff for alert creation.""" with override_config(app, 'ALERT_NO_ACTIVITY_PERCENTILE_CUTOFF', 10): Alert.update_all_for_term(2178) assert len(get_current_alerts('3456789012')) == 0 with override_config(app, 'ALERT_NO_ACTIVITY_PERCENTILE_CUTOFF', 20): Alert.update_all_for_term(2178) assert len(get_current_alerts('3456789012')) == 1
def dismiss_alert(alert_id): user_id = current_user.get_id() Alert.dismiss(alert_id, user_id) CohortFilter.refresh_alert_counts_for_owner(user_id) return tolerant_jsonify({ 'message': f'Alert {alert_id} dismissed by UID {current_user.get_uid()}' }), 200
def test_update_withdrawal_alerts(self, app): """Can be created from SIS feeds.""" with override_config(app, 'ALERT_WITHDRAWAL_ENABLED', True): Alert.update_all_for_term(2178) alerts = get_current_alerts('2345678901') assert len(alerts) == 1 assert alerts[0]['key'] == '2178_withdrawal' assert alerts[0]['message'] == 'Student is no longer enrolled in the Fall 2017 term.'
def test_infrequent_activity_percentile_cutoff(self, app): """Respect percentile cutoff for alert creation.""" with override_config(app, 'ALERT_INFREQUENT_ACTIVITY_ENABLED', True): with override_config(app, 'ALERT_INFREQUENT_ACTIVITY_PERCENTILE_CUTOFF', 10): Alert.update_all_for_term(2178) assert len(get_current_alerts('5678901234')) == 0 with override_config(app, 'ALERT_INFREQUENT_ACTIVITY_PERCENTILE_CUTOFF', 20): Alert.update_all_for_term(2178) assert len(get_current_alerts('5678901234')) == 1
def test_update_no_activity_alerts(self): """Can be created from bCourses analytics feeds, at most one per enrollment.""" Alert.update_all_for_term(2178) alerts = get_current_alerts('3456789012') assert len(alerts) == 1 assert alerts[0]['id'] > 0 assert alerts[0]['alertType'] == 'no_activity' assert alerts[0]['key'] == '2178_MED ST 205' assert alerts[0]['message'] == 'No activity! Student has never visited the MED ST 205 bCourses site for Fall 2017.'
def test_update_infrequent_activity_alerts(self, app): """Can be created from bCourses analytics feeds, at most one per enrollment.""" with override_config(app, 'ALERT_INFREQUENT_ACTIVITY_ENABLED', True): Alert.update_all_for_term(2178) alerts = get_current_alerts('5678901234') assert len(alerts) == 1 assert alerts[0]['id'] > 0 assert alerts[0]['alertType'] == 'infrequent_activity' assert alerts[0]['key'] == '2178_MED ST 205' assert alerts[0]['message'].startswith('Infrequent activity! Last MED ST 205 bCourses activity')
def test_update_assignment_alerts(self): """Can be created from assignment data.""" assert len(get_current_alerts('11667051')) == 0 Alert.update_assignment_alerts(**alert_props) alerts = get_current_alerts('11667051') assert len(alerts) == 1 assert alerts[0]['id'] > 0 assert alerts[0]['alertType'] == 'missing_assignment' assert alerts[0]['key'] == '2178_987654321' assert alerts[0]['message'] == 'MED ST 205 assignment due on Oct 31, 2017.'
def test_no_duplicate_alerts(self): """If an alert exists with the same key, updates the message rather than creating a duplicate.""" assert len(get_current_alerts('11667051')) == 0 Alert.update_assignment_alerts(**alert_props) updated_alert_props = dict(alert_props, due_at='2017-12-25T12:00:00Z') Alert.update_assignment_alerts(**updated_alert_props) alerts = get_current_alerts('11667051') assert len(alerts) == 1 assert alerts[0]['key'] == '2178_987654321' assert alerts[0]['message'] == 'MED ST 205 assignment due on Dec 25, 2017.'
def test_activation_deactivation_all_students(self): """Can activate and deactive across entire population for term.""" assert len(get_current_alerts('11667051')) == 0 assert len(get_current_alerts('3456789012')) == 0 Alert.update_all_for_term(2178) assert len(get_current_alerts('11667051')) == 2 assert len(get_current_alerts('3456789012')) == 1 Alert.deactivate_all_for_term(2178) assert len(get_current_alerts('11667051')) == 0 assert len(get_current_alerts('3456789012')) == 0
def test_update_hold_alerts(self, app): """Can be created from SIS feeds.""" with override_config(app, 'ALERT_HOLDS_ENABLED', True): Alert.update_all_for_term(2178) alerts = get_current_alerts('5678901234') assert len(alerts) == 2 assert alerts[0]['key'] == '2178_S01_CSBAL' assert alerts[0]['message'].startswith( 'Hold: Past due balance! Your student account has a past due balance.' ) assert alerts[1]['key'] == '2178_V00_SMOUT' assert alerts[1]['message'].startswith( 'Hold: Semester Out! You are not eligible to register')
def get_section(term_id, section_id): offset = util.get(request.args, 'offset', None) if offset: offset = int(offset) limit = util.get(request.args, 'limit', None) if limit: limit = int(limit) section = get_sis_section(term_id, section_id) if not section: raise ResourceNotFoundError(f'No section {section_id} in term {term_id}') student_profiles = get_course_student_profiles(term_id, section_id, offset=offset, limit=limit) section.update(student_profiles) Alert.include_alert_counts_for_students(viewer_uid=current_user.uid, cohort=student_profiles) return tolerant_jsonify(section)
def get_curated_cohort(curated_cohort_id): cohort = CuratedCohort.find_by_id(curated_cohort_id) if not cohort: raise ResourceNotFoundError( f'Sorry, no cohort found with id {curated_cohort_id}.') if cohort.owner_id != current_user.id: raise ForbiddenRequestError( f'Current user, {current_user.uid}, does not own cohort {cohort.id}' ) cohort = cohort.to_api_json(sids_only=True) sids = [s['sid'] for s in cohort['students']] cohort['students'] = get_summary_student_profiles(sids) cohort['students'] = api_util.sort_students_by_name(cohort['students']) Alert.include_alert_counts_for_students(viewer_uid=current_user.uid, cohort=cohort) return tolerant_jsonify(cohort)
def search_students(): params = request.get_json() if is_unauthorized_search(params): raise ForbiddenRequestError( 'You are unauthorized to access student data managed by other departments' ) search_phrase = util.get(params, 'searchPhrase', '').strip() if not len(search_phrase): raise BadRequestError('Invalid or empty search input') results = search_for_students( include_profiles=True, search_phrase=search_phrase.replace(',', ' '), is_active_asc=_convert_asc_inactive_arg( util.get(params, 'isInactiveAsc')), order_by=util.get(params, 'orderBy', None), offset=util.get(params, 'offset', 0), limit=util.get(params, 'limit', 50), ) alert_counts = Alert.current_alert_counts_for_viewer(current_user.id) students = results['students'] add_alert_counts(alert_counts, students) return tolerant_jsonify({ 'students': students, 'totalStudentCount': results['totalStudentCount'], })
def get_students_with_alerts(curated_group_id): offset = get_param(request.args, 'offset', 0) limit = get_param(request.args, 'limit', 50) benchmark = get_benchmarker( f'curated group {curated_group_id} students_with_alerts') benchmark('begin') curated_group = CuratedGroup.find_by_id(curated_group_id) if not curated_group: raise ResourceNotFoundError( f'Sorry, no curated group found with id {curated_group_id}.') if not _can_current_user_view_curated_group(curated_group): raise ForbiddenRequestError( f'Current user, {current_user.get_uid()}, cannot view curated group {curated_group.id}' ) students = Alert.include_alert_counts_for_students( benchmark=benchmark, viewer_user_id=current_user.get_id(), group={'sids': CuratedGroup.get_all_sids(curated_group_id)}, count_only=True, offset=offset, limit=limit, ) alert_count_per_sid = {} for s in list(filter(lambda s: s.get('alertCount') > 0, students)): sid = s.get('sid') alert_count_per_sid[sid] = s.get('alertCount') sids = list(alert_count_per_sid.keys()) benchmark('begin profile query') students_with_alerts = get_student_profile_summaries(sids=sids) benchmark('end profile query') for student in students_with_alerts: student['alertCount'] = alert_count_per_sid[student['sid']] benchmark('end') return tolerant_jsonify(students_with_alerts)
def test_assignment_alerts_change_updated_at_timestamp(self): Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='3456789012', viewer_id='2040') assert alerts[0]['updatedAt'] == alerts[0]['createdAt'] sleep(1.0) Alert.deactivate_all_for_term(2178) Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='3456789012', viewer_id='2040') assert alerts[0]['updatedAt'] > alerts[0]['createdAt']
def get_section(term_id, section_id): if not current_user.can_access_canvas_data: raise ForbiddenRequestError('Unauthorized to view course data') offset = util.get(request.args, 'offset', None) if offset: offset = int(offset) limit = util.get(request.args, 'limit', None) if limit: limit = int(limit) featured = util.get(request.args, 'featured', None) section = get_sis_section(term_id, section_id) if not section: raise ResourceNotFoundError(f'No section {section_id} in term {term_id}') student_profiles = get_course_student_profiles(term_id, section_id, offset=offset, limit=limit, featured=featured) section.update(student_profiles) Alert.include_alert_counts_for_students(viewer_user_id=current_user.get_id(), group=student_profiles) return tolerant_jsonify(section)
def test_alert_timezones(self): """For purposes of displaying due dates, loves LA.""" Alert.update_assignment_alerts(**dict(alert_props, due_at='2017-02-03T07:59:01Z')) assert get_current_alerts('11667051')[0]['message'] == 'MED ST 205 assignment due on Feb 2, 2017.' Alert.update_assignment_alerts(**dict(alert_props, due_at='2017-02-03T08:00:01Z')) assert get_current_alerts('11667051')[0]['message'] == 'MED ST 205 assignment due on Feb 3, 2017.' Alert.update_assignment_alerts(**dict(alert_props, due_at='2017-06-17T06:59:59Z')) assert get_current_alerts('11667051')[0]['message'] == 'MED ST 205 assignment due on Jun 16, 2017.' Alert.update_assignment_alerts(**dict(alert_props, due_at='2017-06-17T07:00:01Z')) assert get_current_alerts('11667051')[0]['message'] == 'MED ST 205 assignment due on Jun 17, 2017.'
def test_creates_alert_for_midterm_grade(self, app): from boac.api.cache_utils import refresh_alerts refresh_alerts(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040') alert = next((a for a in alerts if a['alertType'] == 'midterm'), None) assert alert assert 'midterm' == alert['alertType'] assert '2178_90100' == alert['key'] assert 'BURMESE 1A midpoint deficient grade of D+.' == alert['message']
def test_creates_alert_for_midterm_grade(self, app): from boac.api.cache_utils import refresh_alerts refresh_alerts(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040')['shown'] assert 1 == len(alerts) assert 0 < alerts[0]['id'] assert 'midterm' == alerts[0]['alertType'] assert '2178_90100' == alerts[0]['key'] assert 'BURMESE 1A midterm grade of D+.' == alerts[0]['message']
def test_midpoint_deficient_grade_alerts_preserve_updated_at_timestamp(self): Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040') alert = next((a for a in alerts if a['alertType'] == 'midterm'), None) assert alert['updatedAt'] == alert['createdAt'] sleep(0.5) Alert.deactivate_all_for_term(2178) Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040') alert = next((a for a in alerts if a['alertType'] == 'midterm'), None) assert alert['updatedAt'] == alert['createdAt']
def test_my_cohorts_includes_students_with_alert_counts(self, asc_advisor_session, client, create_alerts, db_session): # Pre-load students into cache for consistent alert data. client.get('/api/student/61889/analytics') client.get('/api/student/98765/analytics') from boac.models.alert import Alert Alert.update_all_for_term(2178) cohorts = client.get('/api/filtered_cohorts/my').json assert len(cohorts[0]['alerts']) == 3 deborah = cohorts[0]['alerts'][0] assert deborah['sid'] == '11667051' assert deborah['alertCount'] == 3 # Summary student data is included with alert counts, but full term and analytics feeds are not. assert deborah['cumulativeGPA'] == 3.8 assert deborah['cumulativeUnits'] == 101.3 assert deborah['level'] == 'Junior' assert len(deborah['majors']) == 2 assert deborah['term']['enrolledUnits'] == 12.5 assert 'analytics' not in deborah assert 'enrollments' not in deborah['term'] dave_doolittle = cohorts[0]['alerts'][1] assert dave_doolittle['sid'] == '2345678901' assert dave_doolittle['uid'] assert dave_doolittle['firstName'] assert dave_doolittle['lastName'] assert dave_doolittle['alertCount'] == 1 other_alerts = cohorts[1]['alerts'] assert len(other_alerts) == 1 assert other_alerts[0]['sid'] == '2345678901' assert other_alerts[0]['alertCount'] == 1 alert_to_dismiss = client.get('/api/alerts/current/11667051').json['shown'][0]['id'] client.get('/api/alerts/' + str(alert_to_dismiss) + '/dismiss') alert_to_dismiss = client.get('/api/alerts/current/2345678901').json['shown'][0]['id'] client.get('/api/alerts/' + str(alert_to_dismiss) + '/dismiss') cohorts = client.get('/api/filtered_cohorts/my').json assert len(cohorts[0]['alerts']) == 2 assert cohorts[0]['alerts'][0]['sid'] == '11667051' assert cohorts[0]['alerts'][0]['alertCount'] == 2 assert len(cohorts[1]['alerts']) == 0
def get_my_curated_groups(): curated_groups = [] user_id = current_user.get_id() for curated_group in CuratedGroup.get_curated_groups_by_owner_id(user_id): api_json = curated_group.to_api_json(include_students=False) students = [{'sid': sid} for sid in CuratedGroup.get_all_sids(curated_group.id)] students_with_alerts = Alert.include_alert_counts_for_students( viewer_user_id=user_id, group={'students': students}, count_only=True, ) api_json['alertCount'] = sum(s['alertCount'] for s in students_with_alerts) api_json['totalStudentCount'] = len(students) curated_groups.append(api_json) return curated_groups
def test_academic_standing_action_date_as_created_at(self): actual_action_date = '2017-12-30' Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040') alert = next((a for a in alerts if a['alertType'] == 'academic_standing'), None) created_at = alert['createdAt'] assert created_at.startswith(actual_action_date) assert alert['updatedAt'] == created_at sleep(1.0) Alert.deactivate_all_for_term(2178) Alert.update_all_for_term(2178) alerts = Alert.current_alerts_for_sid(sid='11667051', viewer_id='2040') academic_standing_alert = next((a for a in alerts if a['alertType'] == 'academic_standing'), None) created_at = academic_standing_alert['createdAt'] assert created_at.startswith(actual_action_date) assert academic_standing_alert['updatedAt'] == created_at
def _student_search(search_phrase, params, order_by): student_results = search_for_students( search_phrase=search_phrase.replace(',', ' '), order_by=order_by, offset=util.get(params, 'offset', 0), limit=util.get(params, 'limit', 50), ) students = student_results['students'] sids = [s['sid'] for s in students] alert_counts = Alert.current_alert_counts_for_sids(current_user.get_id(), sids) add_alert_counts(alert_counts, students) return { 'students': students, 'totalStudentCount': student_results['totalStudentCount'], }
def alerts_log_export(): def _param_to_utc_date(key): value = (params.get(key) or '').strip() return localized_timestamp_to_utc( value, date_format='%m/%d/%Y') if value else None params = request.get_json() from_date_utc = _param_to_utc_date('fromDate') to_date_utc = _param_to_utc_date('toDate') + timedelta(days=1) if from_date_utc and to_date_utc: def _to_api_json(alert): term_id_match = re.search(r'^2[012]\d[0258]', alert.key[0:4]) active_until = alert.deleted_at or utc_now() return { 'sid': alert.sid, 'term': term_name_for_sis_id(term_id_match.string) if term_id_match else None, 'key': alert.key, 'type': alert.alert_type, 'is_active': not alert.deleted_at, 'active_duration_hours': round( (active_until - alert.created_at).total_seconds() / 3600), 'created_at': alert.created_at, 'deleted_at': alert.deleted_at, } alerts = Alert.get_alerts_per_date_range(from_date_utc, to_date_utc) return response_with_csv_download( rows=[_to_api_json(a) for a in alerts], filename_prefix='alerts_log', fieldnames=[ 'sid', 'term', 'key', 'type', 'is_active', 'active_duration_hours', 'created_at', 'deleted_at' ], ) else: raise BadRequestError('Invalid arguments')
def test_deactivate_reactivate_alerts(self): """Can be deactivated and reactivated, preserving id.""" assert len(get_current_alerts('11667051')) == 0 Alert.update_assignment_alerts(**alert_props) alerts = get_current_alerts('11667051') assert len(alerts) == 1 alert_id = alerts[0]['id'] Alert.deactivate_all(sid='11667051', term_id='2178', alert_types=['missing_assignment']) assert len(get_current_alerts('11667051')) == 0 Alert.update_assignment_alerts(**alert_props) alerts = get_current_alerts('11667051') assert len(alerts) == 1 assert alerts[0]['id'] == alert_id
def my_curated_cohorts(): alert_counts = Alert.current_alert_counts_for_viewer(current_user.id) curated_cohorts = CuratedCohort.get_curated_cohorts_by_owner_id( current_user.id) curated_cohorts = sorted(curated_cohorts, key=lambda curated_cohort: curated_cohort.name) curated_cohorts = [c.to_api_json(sids_only=True) for c in curated_cohorts] for curated_cohort in curated_cohorts: students = curated_cohort['students'] api_util.add_alert_counts(alert_counts, students) # Only get detailed data for students with alerts. sids_with_alerts = [s['sid'] for s in students if s.get('alertCount')] students_with_alerts = get_summary_student_profiles(sids_with_alerts) for data in students_with_alerts: data['alertCount'] = next( s.get('alertCount') for s in students if s['sid'] == data['sid']) api_util.strip_analytics(data) curated_cohort['students'] = api_util.sort_students_by_name( students_with_alerts) return tolerant_jsonify(curated_cohorts)