def unschedule(): params = request.get_json() term_id = params.get('termId') section_id = params.get('sectionId') course = SisSection.get_course(term_id, section_id, include_deleted=True) if (term_id and section_id) else None if not course: raise BadRequestError('Required params missing or invalid') if not (course['scheduled'] or course['hasNecessaryApprovals']): raise BadRequestError(f'Section id {section_id}, term id {term_id} is not currently scheduled or queued for scheduling') Approval.delete(term_id=term_id, section_id=section_id) Scheduled.delete(term_id=term_id, section_id=section_id) event_id = (course.get('scheduled') or {}).get('kalturaScheduleId') if event_id: try: Kaltura().delete(event_id) except (KalturaClientException, KalturaException) as e: message = f'Failed to delete Kaltura schedule: {event_id}' app.logger.error(message) app.logger.exception(e) send_system_error_email( message=f'{message}\n\n<pre>{traceback.format_exc()}</pre>', subject=message, ) CoursePreference.update_opt_out( term_id=term_id, section_id=section_id, opt_out=True, ) return tolerant_jsonify(SisSection.get_course(term_id, section_id, include_deleted=True))
def approve(): term_id = app.config['CURRENT_TERM_ID'] term_name = term_name_for_sis_id(term_id) params = request.get_json() publish_type = params.get('publishType') recording_type = params.get('recordingType') section_id = params.get('sectionId') course = SisSection.get_course(term_id, section_id) if section_id else None if not course or publish_type not in get_all_publish_types() or recording_type not in get_all_recording_types(): raise BadRequestError('One or more required params are missing or invalid') if not current_user.is_admin and current_user.uid not in [i['uid'] for i in course['instructors']]: raise ForbiddenRequestError('Sorry, request unauthorized') if Approval.get_approval(approved_by_uid=current_user.uid, section_id=section_id, term_id=term_id): raise ForbiddenRequestError(f'You have already approved recording of {course["courseName"]}, {term_name}') meetings = course.get('meetings', {}).get('eligible', []) if len(meetings) != 1: raise BadRequestError('Unique eligible meeting pattern not found for course') meeting = meetings[0] location = meeting and meeting.get('location') room = Room.find_room(location=location) if not room: raise BadRequestError(f'{location} is not eligible for Course Capture.') previous_approvals = Approval.get_approvals_per_section_ids(section_ids=[section_id], term_id=term_id) approval = Approval.create( approved_by_uid=current_user.uid, approver_type_='admin' if current_user.is_admin else 'instructor', course_display_name=course['label'], publish_type_=publish_type, recording_type_=recording_type, room_id=room.id, section_id=section_id, term_id=term_id, ) if previous_approvals: # Compare the current approval with preferences submitted in previous approval previous_approval = previous_approvals[-1] if (approval.publish_type, approval.recording_type) != (previous_approval.publish_type, previous_approval.recording_type): notify_instructors_of_changes(course, approval, previous_approvals) all_approvals = previous_approvals + [approval] if len(course['instructors']) > len(all_approvals): approval_uids = [a.approved_by_uid for a in all_approvals] pending_instructors = [i for i in course['instructors'] if i['uid'] not in approval_uids] last_approver = next((i for i in course['instructors'] if i['uid'] == approval.approved_by_uid), None) if last_approver: notify_instructor_waiting_for_approval(course, last_approver, pending_instructors) return tolerant_jsonify(_after_approval(course=SisSection.get_course(term_id, section_id)))
def _assert_schedule_is_obsolete( self, expect_obsolete_dates, expect_obsolete_times, meeting, override_days=None, override_end_date=None, override_end_time=None, override_start_date=None, override_start_time=None, ): with test_approvals_workflow(app): with override_config(app, 'CURRENT_TERM_RECORDINGS_BEGIN', meeting['startDate']): with override_config(app, 'CURRENT_TERM_RECORDINGS_END', meeting['endDate']): mock_scheduled( meeting=meeting, override_days=override_days, override_end_date=override_end_date, override_end_time=override_end_time, override_start_date=override_start_date, override_start_time=override_start_time, section_id=self.section_id, term_id=self.term_id, ) course = SisSection.get_course(section_id=self.section_id, term_id=self.term_id) scheduled = course['scheduled'] assert are_scheduled_dates_obsolete(meeting=meeting, scheduled=scheduled) is expect_obsolete_dates assert are_scheduled_times_obsolete(meeting=meeting, scheduled=scheduled) is expect_obsolete_times
def test_admin_approval(self): """Course is scheduled for recording if an admin user has approved.""" with test_approvals_workflow(app): section_id = 50005 term_id = app.config['CURRENT_TERM_ID'] course = SisSection.get_course(section_id=section_id, term_id=term_id) instructors = course['instructors'] assert len(instructors) == 2 # Verify that course is not scheduled assert Scheduled.get_scheduled(section_id=section_id, term_id=term_id) is None Approval.create( approved_by_uid=admin_uid, approver_type_='admin', course_display_name=course['label'], publish_type_='kaltura_my_media', recording_type_='presentation_audio', room_id=Room.find_room('Barker 101').id, section_id=section_id, term_id=term_id, ) KalturaJob(simply_yield).run() std_commit(allow_test_environment=True) # Admin approval is all we need. assert Scheduled.get_scheduled(section_id=section_id, term_id=term_id)
def test_email_template(template_id): email_template = EmailTemplate.get_template(template_id) if email_template: course = SisSection.get_course(term_id=app.config['CURRENT_TERM_ID'], section_id='12597') template = EmailTemplate.get_template(template_id) subject_line = interpolate_email_content( course=course, recipient_name=current_user.name, templated_string=template.subject_line, ) message = interpolate_email_content( course=course, recipient_name=current_user.name, templated_string=template.message, ) BConnected().send( recipients=[ { 'email': current_user.email_address, 'name': current_user.name, 'uid': current_user.uid, }, ], message=message, subject_line=subject_line, ) return tolerant_jsonify( {'message': f'Email sent to {current_user.email_address}'}), 200 else: raise ResourceNotFoundError('No such email_template')
def test_currently_no_person_teaching_course(self): """If course does not have a proper instructor then the email remains queued.""" def _emails_sent(): return _get_emails_sent(email_template_type=email_template_type, section_id=section_id, term_id=term_id) term_id = app.config['CURRENT_TERM_ID'] section_id = 22460 email_template_type = 'invitation' # Courses with no proper instructor are excluded from query results. assert not SisSection.get_course(term_id=term_id, section_id=section_id) queued_email = QueuedEmail.create(section_id, email_template_type, term_id) std_commit(allow_test_environment=True) emails_sent_before = _emails_sent() # Run the job QueuedEmailsJob(app.app_context).run() std_commit(allow_test_environment=True) # Expect no email sent emails_sent_after = _emails_sent() assert len(emails_sent_after) == len(emails_sent_before) # Assert that email is still queued assert section_id in QueuedEmail.get_all_section_ids( template_type=email_template_type, term_id=term_id) # Clean up QueuedEmail.delete(queued_email)
def test_admin_approval(self): """Course is scheduled for recording if an admin user has approved.""" with test_approvals_workflow(app): section_id = 22287 term_id = app.config['CURRENT_TERM_ID'] course = SisSection.get_course(section_id=section_id, term_id=term_id) instructors = course['instructors'] assert len(instructors) == 2 # Verify that course is not scheduled assert Scheduled.get_scheduled(section_id=section_id, term_id=term_id) is None Approval.create( approved_by_uid=admin_uid, approver_type_='admin', cross_listed_section_ids=[], publish_type_='canvas', recording_type_='presentation_audio', room_id=Room.find_room('Barker 101').id, section_id=section_id, term_id=term_id, ) KalturaJob(app.app_context).run() std_commit(allow_test_environment=True) # Admin approval is all we need. assert Scheduled.get_scheduled(section_id=section_id, term_id=term_id)
def _run(self, args=None): term_id = app.config['CURRENT_TERM_ID'] for queued_email in QueuedEmail.get_all(term_id): course = SisSection.get_course(term_id, queued_email.section_id, include_deleted=True) if not course: app.logger.warn( f'Email will remain queued until course data is present: {queued_email}' ) continue if course['hasOptedOut']: QueuedEmail.delete(queued_email) continue if BConnected().send( message=queued_email.message, recipient=queued_email.recipient, section_id=queued_email.section_id, subject_line=queued_email.subject_line, template_type=queued_email.template_type, term_id=term_id, ): QueuedEmail.delete(queued_email) else: # If send() fails then report the error and DO NOT delete the queued item. app.logger.error(f'Failed to send email: {queued_email}')
def get_course(term_id, section_id): course = SisSection.get_course(term_id, section_id) if not course: raise ResourceNotFoundError(f'No section for term_id = {term_id} and section_id = {section_id}') if not current_user.is_admin and current_user.uid not in [i['uid'] for i in course['instructors']]: raise ForbiddenRequestError(f'Sorry, you are unauthorized to view the course {course["label"]}.') return tolerant_jsonify(course)
def _schedule(): mock_scheduled( override_room_id=Room.find_room('Barker 101').id, section_id=section_id, term_id=term_id, ) course = SisSection.get_course(section_id=section_id, term_id=term_id) assert course['scheduled']['hasObsoleteRoom'] is True
def test_room_change_no_longer_eligible(self, db_session): section_id = 50004 term_id = app.config['CURRENT_TERM_ID'] def _move_course(meeting_location): db.session.execute( text( 'UPDATE sis_sections SET meeting_location = :meeting_location WHERE term_id = :term_id AND section_id = :section_id' ), { 'meeting_location': meeting_location, 'section_id': section_id, 'term_id': term_id, }, ) with enabled_job(job_key=InstructorEmailsJob.key()): with test_approvals_workflow(app): course = SisSection.get_course(section_id=section_id, term_id=term_id) eligible_meetings = course.get('meetings', {}).get('eligible', []) assert len(eligible_meetings) == 1 original_room = eligible_meetings[0]['room'] assert original_room['location'] == 'Li Ka Shing 145' # Schedule _schedule(original_room['id'], section_id) _run_instructor_emails_job() _assert_email_count(0, section_id, 'room_change_no_longer_eligible') # Move course to some other eligible room. _move_course('Barker 101') _run_instructor_emails_job() _assert_email_count(0, section_id, 'room_change_no_longer_eligible') # Move course to an ineligible room. ineligible_room = 'Wheeler 150' _move_course(ineligible_room) _run_instructor_emails_job() _assert_email_count(1, section_id, 'room_change_no_longer_eligible') # Move course back to its original location _move_course(original_room['location']) # Finally, let's pretend the course is scheduled to a room that was previously eligible. Scheduled.delete(section_id=section_id, term_id=term_id) _schedule(Room.find_room(ineligible_room).id, section_id) _run_instructor_emails_job() # Expect email. _assert_email_count(2, section_id, 'room_change_no_longer_eligible') Scheduled.delete(section_id=section_id, term_id=term_id)
def test_currently_no_person_teaching_course(self): """Refuse to queue emails for a course without a proper instructor.""" term_id = app.config['CURRENT_TERM_ID'] section_id = 50006 email_template_type = 'invitation' # Courses with no proper instructor are excluded from query results. assert not SisSection.get_course(term_id=term_id, section_id=section_id) # Queued email creation fails. assert not QueuedEmail.create(section_id, email_template_type, term_id, recipient=None) assert section_id not in QueuedEmail.get_all_section_ids(template_type=email_template_type, term_id=term_id)
def test_interpolate_email_content(self): user = get_calnet_user_for_uid(app, '8765432') course = SisSection.get_course(app.config['CURRENT_TERM_ID'], '28165') interpolated = interpolate_email_content( course=course, recipient_name=user['name'], templated_string=_get_email_template(), ) actual = _normalize(interpolated) expected = _normalize(_get_expected_email()) assert expected == actual
def approve(): term_id = app.config['CURRENT_TERM_ID'] term_name = term_name_for_sis_id(term_id) params = request.get_json() publish_type = params.get('publishType') recording_type = params.get('recordingType') section_id = params.get('sectionId') course = SisSection.get_course(term_id, section_id) if section_id else None if not course or publish_type not in get_all_publish_types() or recording_type not in get_all_recording_types(): raise BadRequestError('One or more required params are missing or invalid') if not current_user.is_admin and current_user.uid not in [i['uid'] for i in course['instructors']]: raise ForbiddenRequestError('Sorry, request unauthorized') if Approval.get_approval(approved_by_uid=current_user.uid, section_id=section_id, term_id=term_id): raise ForbiddenRequestError(f'You have already approved recording of {course["courseName"]}, {term_name}') location = course['meetingLocation'] room = Room.find_room(location=location) if not room: raise BadRequestError(f'{location} is not eligible for Course Capture.') previous_approvals = Approval.get_approvals_per_section_ids(section_ids=[section_id], term_id=term_id) approval = Approval.create( approved_by_uid=current_user.uid, approver_type_='admin' if current_user.is_admin else 'instructor', cross_listed_section_ids=[c['sectionId'] for c in course['crossListings']], publish_type_=publish_type, recording_type_=recording_type, room_id=room.id, section_id=section_id, term_id=term_id, ) _notify_instructors_of_approval( approval=approval, course=course, previous_approvals=previous_approvals, ) return tolerant_jsonify(SisSection.get_course(term_id, section_id))
def get_course(term_id, section_id): course = SisSection.get_course(term_id, section_id, include_deleted=True) if not course: raise ResourceNotFoundError(f'No section for term_id = {term_id} and section_id = {section_id}') if not current_user.is_admin and current_user.uid not in [i['uid'] for i in course['instructors']]: raise ForbiddenRequestError(f'Sorry, you are unauthorized to view the course {course["label"]}.') if current_user.is_admin and course['scheduled']: # When debugging, the raw Kaltura-provided JSON is useful. event_id = course['scheduled'].get('kalturaScheduleId') course['scheduled']['kalturaSchedule'] = Kaltura().get_event(event_id) return tolerant_jsonify(course)
def test_email_alert_when_canceled_course(self, db_session): term_id = app.config['CURRENT_TERM_ID'] with enabled_job(job_key=InstructorEmailsJob.key()): with test_approvals_workflow(app): course = SisSection.get_course(section_id=deleted_section_id, term_id=term_id, include_deleted=True) room = course.get('meetings', {}).get('eligible', [])[0]['room'] _schedule(room['id'], deleted_section_id) _run_instructor_emails_job() _assert_email_count(1, deleted_section_id, 'room_change_no_longer_eligible')
def save_mock_courses(json_file_path): courses = _load_mock_courses(json_file_path) if courses: for course in courses: section_id = course['section_id'] if SisSection.get_course(term_id=course['term_id'], section_id=section_id): db.session.execute( text( f'DELETE FROM sis_sections WHERE section_id = {section_id}' )) _save_courses(sis_sections=courses) std_commit(allow_test_environment=True)
def create(cls, section_id, template_type, term_id, recipient, message=None, subject_line=None): course = SisSection.get_course(term_id, section_id, include_deleted=True) if not course: app.logger.error(f'Attempt to queue email for unknown course (term_id={term_id}, section_id={section_id})') return if not course['instructors']: app.logger.error(f'Attempt to queue email for course without instructors (term_id={term_id}, section_id={section_id})') return queued_email = cls( section_id=section_id, template_type=template_type, term_id=term_id, recipient=recipient, message=message, subject_line=subject_line, ) course = SisSection.get_course(term_id, queued_email.section_id, include_deleted=True) if not queued_email.is_interpolated() and not queued_email.interpolate(course): app.logger.error(f'Failed to interpolate all required values for queued email ({queued_email})') return db.session.add(queued_email) std_commit() return queued_email
def _schedule(): mock_scheduled( meeting=meeting, override_end_time='16:59', override_start_time='08:00', section_id=section_id, term_id=term_id, ) course = SisSection.get_course( section_id=section_id, term_id=term_id) scheduled = course['scheduled'] assert are_scheduled_dates_obsolete( meeting=meeting, scheduled=scheduled) is False assert are_scheduled_times_obsolete( meeting=meeting, scheduled=scheduled) is True
def queue_emails(): params = request.get_json() term_id = params.get('termId') section_id = params.get('sectionId') template_type = params.get('emailTemplateType') if not (term_id and section_id and template_type): raise BadRequestError('Required parameters are missing.') course = SisSection.get_course(term_id=term_id, section_id=section_id) for instructor in course['instructors']: if not QueuedEmail.create(section_id=section_id, recipient=instructor, template_type=template_type, term_id=term_id): raise BadRequestError(f"Failed to queue email of type '{template_type}'.") return tolerant_jsonify({ 'message': f"An email of type '{template_type}' has been queued.", })
def test_instructor_of_cross_listing(self, client, fake_auth): """If section X and Y are cross-listed then /course page of X must be reachable by instructor of Y.""" section_id = 50012 cross_listed_section_id = 50013 instructor_uid = '10010' # Confirm that cross-listed section was deleted during sis_data_refresh job assert not SisSection.get_course(section_id=cross_listed_section_id, term_id=self.term_id) # Log in as instructor of cross-listed section fake_auth.login(uid=instructor_uid) api_json = api_get_course( client, term_id=self.term_id, section_id=section_id, ) assert len(api_json['crossListings']) == 1 assert cross_listed_section_id == api_json['crossListings'][0]['sectionId'] assert instructor_uid in [i['uid'] for i in api_json['instructors']]
def test_series_description(self): """Series description for cross-listed course.""" cross_listed_section_id = 50012 term_id = app.config['CURRENT_TERM_ID'] course = SisSection.get_course( section_id=cross_listed_section_id, term_id=term_id, ) description = get_series_description( course_label=course['label'], instructors=course['instructors'], term_name=term_name_for_sis_id(term_id), ) assert 'MATH C51, LEC 001 | STAT C151, COL 001' in description assert 'Fall 2021' in description assert 'Rudolf Schündler and Arthur Storch' in description assert '2021' in description
def update_opt_out(): params = request.get_json() term_id = params.get('termId') section_id = params.get('sectionId') course = SisSection.get_course(term_id, section_id) if (term_id and section_id) else None opt_out = params.get('optOut') if not course or opt_out is None: raise BadRequestError('Required params missing or invalid') if course['scheduled']: raise BadRequestError('Cannot update opt-out on scheduled course') preferences = CoursePreference.update_opt_out( term_id=term_id, section_id=section_id, opt_out=opt_out, ) return tolerant_jsonify(preferences.to_api_json())
def run(self, args=None): term_id = app.config['CURRENT_TERM_ID'] for queued_email in QueuedEmail.get_all(term_id): template_type = queued_email.template_type course = SisSection.get_course(term_id, queued_email.section_id) if course: if course['hasOptedOut']: # Do not send email; delete the item from queue. QueuedEmail.delete(queued_email) else: if template_type in [ 'invitation', 'notify_instructor_of_changes', 'recordings_scheduled', 'room_change_no_longer_eligible', 'waiting_for_approval', ]: recipients = course['instructors'] elif template_type in [ 'admin_alert_instructor_change', 'admin_alert_room_change' ]: recipients = get_admin_alert_recipients() else: raise BackgroundJobError( f'Email template type not supported: {template_type}' ) # If send() returns False then report the error and DO NOT delete the queued item. if send_course_related_email( course=course, recipients=recipients, template_type=template_type, term_id=term_id, ): QueuedEmail.delete(queued_email) else: app.logger.error( f'Failed to send email: {queued_email}') else: app.logger.warn( f'Email will remain queued until course gets proper instructor: {queued_email}' )
def test_has_obsolete_room(self, client, admin_session): """Admins can see room changes that might disrupt scheduled recordings.""" course = SisSection.get_course(term_id=self.term_id, section_id=section_2_id) actual_room_id = course['room']['id'] obsolete_room = Room.find_room('Barker 101') assert obsolete_room assert actual_room_id != obsolete_room.id _schedule_recordings( section_id=section_2_id, term_id=self.term_id, room_id=obsolete_room.id, ) api_json = self._api_course_changes(client, term_id=self.term_id) course = _find_course(api_json=api_json, section_id=section_2_id) assert course assert course['scheduled']['hasObsoleteRoom'] is True assert course['scheduled']['hasObsoleteMeetingTimes'] is False assert course['scheduled']['hasObsoleteInstructors'] is False
def test_download_csv(self, client, admin_session): """Admin users can download courses CSV file.""" term_id = app.config['CURRENT_TERM_ID'] csv_string = self._api_courses_csv(client).decode('utf-8') reader = csv.reader(StringIO(csv_string), delimiter=',') for index, row in enumerate(reader): section_id = row[1] meeting_type = row[6] sign_up_url = row[9] instructors = row[-2] instructor_uids = row[-1] if index == 0: assert section_id == 'Section Id' assert meeting_type == 'Meeting Type' assert sign_up_url == 'Sign-up URL' assert instructors == 'Instructors' assert instructor_uids == 'Instructor UIDs' else: course = SisSection.get_course(section_id=section_id, term_id=term_id) assert int(section_id) == course['sectionId'] for snippet in [app.config['DIABLO_BASE_URL'], section_id, str(term_id)]: assert snippet in sign_up_url expected_uids = [instructor['uid'] for instructor in course['instructors']] if expected_uids: assert set(instructor_uids.split(', ')) == set(expected_uids) else: assert instructor_uids == '' for instructor in course['instructors']: assert instructor['email'] in instructors assert instructor['name'] in instructors if len(course['meetings']['eligible']) > 1: assert meeting_type == 'D' elif course['nonstandardMeetingDates']: assert meeting_type == 'C' elif len(course['meetings']['eligible'] + course['meetings']['ineligible']) > 1: assert meeting_type == 'B' else: assert meeting_type == 'A'
def _after_approval(course): section_id = course['sectionId'] term_id = course['termId'] approvals = Approval.get_approvals(section_id=section_id, term_id=term_id) if get_courses_ready_to_schedule(approvals=approvals, term_id=term_id): # Queuing course for scheduling wipes any opt-out preference. if course['hasOptedOut']: CoursePreference.update_opt_out( term_id=term_id, section_id=section_id, opt_out=False, ) if app.config['FEATURE_FLAG_SCHEDULE_RECORDINGS_SYNCHRONOUSLY']: # Feature flag intended for dev workstation ONLY. Do not enable in diablo-dev|qa|prod. schedule_recordings( all_approvals=approvals, course=course, ) return SisSection.get_course(section_id=course['sectionId'], term_id=course['termId']) else: return course
def test_are_scheduled_dates_obsolete_handles_nulls(self): with test_approvals_workflow(app): meeting = _create_meeting( days='MO', end_date=_format(datetime.now() + timedelta(days=100)), end_time='10:59', start_date=_format(datetime.now() - timedelta(days=100)), start_time='10:00', ) with override_config(app, 'CURRENT_TERM_RECORDINGS_BEGIN', meeting['startDate']): with override_config(app, 'CURRENT_TERM_RECORDINGS_END', meeting['endDate']): mock_scheduled(meeting=meeting, section_id=self.section_id, term_id=self.term_id) course = SisSection.get_course(section_id=self.section_id, term_id=self.term_id) scheduled = course['scheduled'] meeting = _create_meeting( days=None, end_date=None, end_time=None, start_date=None, start_time=None, ) assert are_scheduled_dates_obsolete(meeting, scheduled) is True
def test_invited_filter(self, client, db, admin_session): """Invited filter: Course in an eligible room, have received invitation. No approvals. Not scheduled.""" with test_approvals_workflow(app): # First, send invitations SentEmail.create( section_id=section_4_id, recipient_uids=_get_instructor_uids(section_id=section_4_id, term_id=self.term_id), template_type='invitation', term_id=self.term_id, ) section_5_instructor_uids = _get_instructor_uids(section_id=section_5_id, term_id=self.term_id) SentEmail.create( section_id=section_5_id, recipient_uids=section_5_instructor_uids, template_type='invitation', term_id=self.term_id, ) # The section with approval will NOT show up in search results Approval.create( approved_by_uid=section_5_instructor_uids[0], approver_type_='instructor', cross_listed_section_ids=[], publish_type_='canvas', recording_type_='presentation_audio', room_id=SisSection.get_course(term_id=self.term_id, section_id=section_5_id)['room']['id'], section_id=section_5_id, term_id=self.term_id, ) std_commit(allow_test_environment=True) api_json = self._api_courses(client, term_id=self.term_id, filter_='Invited') # Section with ZERO approvals will show up in search results course = _find_course(api_json=api_json, section_id=section_4_id) assert course assert course['label'] == 'CHEM C110L, LAB 001' # The section with approval will NOT show up in search results assert not _find_course(api_json=api_json, section_id=section_5_id)
def _is_course_in_enabled_room(section_id, term_id): eligible_meetings = SisSection.get_course(term_id=term_id, section_id=section_id)['meetings']['eligible'] return eligible_meetings and eligible_meetings[0]['room']['capability'] is not None