def test_recording_start_date(self): # Begin date is a Friday recordings_begin_date = '2525-09-07' with override_config(app, 'CURRENT_TERM_RECORDINGS_BEGIN', recordings_begin_date): meeting = { 'days': 'MO', 'startDate': '2525-08-26 00:00:00 UTC', } # Expect the following Monday assert get_recording_start_date(meeting) == _to_datetime('2525-09-10') meeting = { 'days': 'FR', 'startDate': '2525-09-14 00:00:00 UTC', } assert get_recording_start_date(meeting) == _to_datetime('2525-09-14')
def test_has_obsolete_instructors(self, client, admin_session): """Admins can see instructor changes that might disrupt scheduled recordings.""" with test_approvals_workflow(app): meeting = get_eligible_meeting(section_id=section_1_id, term_id=self.term_id) instructor_uids = get_instructor_uids(term_id=self.term_id, section_id=section_1_id) # Course has multiple instructors; we will schedule using only one instructor UID. assert len(instructor_uids) > 1 scheduled_with_uid = instructor_uids[0] Scheduled.create( instructor_uids=[scheduled_with_uid], kaltura_schedule_id=random.randint(1, 10), meeting_days=meeting['days'], meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date(meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_='kaltura_my_media', recording_type_='presenter_audio', room_id=Room.get_room_id(section_id=section_1_id, term_id=self.term_id), section_id=section_1_id, term_id=self.term_id, ) std_commit(allow_test_environment=True) api_json = self._api_course_changes(client, term_id=self.term_id) course = _find_course(api_json=api_json, section_id=section_1_id) assert course assert course['scheduled']['hasObsoleteRoom'] is False assert course['scheduled']['hasObsoleteDates'] is False assert course['scheduled']['hasObsoleteTimes'] is False assert course['scheduled']['hasObsoleteInstructors'] is True assert len(course['instructors']) == 2 assert len(course['scheduled']['instructors']) == 1 assert course['scheduled']['instructors'][0]['uid'] == scheduled_with_uid
def test_has_obsolete_meeting_times(self, client, admin_session): """Admins can see meeting time changes that might disrupt scheduled recordings.""" with test_approvals_workflow(app): meeting = get_eligible_meeting(section_id=section_1_id, term_id=self.term_id) obsolete_meeting_days = 'MOWE' assert meeting['days'] != obsolete_meeting_days Scheduled.create( instructor_uids=get_instructor_uids(term_id=self.term_id, section_id=section_1_id), kaltura_schedule_id=random.randint(1, 10), meeting_days=obsolete_meeting_days, meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date(meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_='kaltura_my_media', recording_type_='presentation_audio', room_id=Room.get_room_id(section_id=section_1_id, term_id=self.term_id), section_id=section_1_id, term_id=self.term_id, ) std_commit(allow_test_environment=True) api_json = self._api_course_changes(client, term_id=self.term_id) course = _find_course(api_json=api_json, section_id=section_1_id) assert course assert course['scheduled']['hasObsoleteRoom'] is False assert course['scheduled']['hasObsoleteDates'] is False assert course['scheduled']['hasObsoleteTimes'] is True assert course['scheduled']['hasObsoleteInstructors'] is False
def mock_scheduled( section_id, term_id, meeting=None, override_days=None, override_end_date=None, override_end_time=None, override_room_id=None, override_start_date=None, override_start_time=None, publish_type='kaltura_media_gallery', recording_type='presenter_presentation_audio', ): meeting = meeting or get_eligible_meeting(section_id=section_id, term_id=term_id) Scheduled.create( course_display_name=f'term_id:{term_id} section_id:{section_id}', instructor_uids=get_instructor_uids(term_id=term_id, section_id=section_id), kaltura_schedule_id=random.randint(1, 10), meeting_days=override_days or meeting['days'], meeting_end_date=override_end_date or get_recording_end_date(meeting), meeting_end_time=override_end_time or meeting['endTime'], meeting_start_date=override_start_date or get_recording_start_date(meeting, return_today_if_past_start=True), meeting_start_time=override_start_time or meeting['startTime'], publish_type_=publish_type, recording_type_=recording_type, room_id=override_room_id or Room.get_room_id(section_id=section_id, term_id=term_id), section_id=section_id, term_id=term_id, ) std_commit(allow_test_environment=True)
def test_admin_alert_multiple_meeting_patterns(self): """Emails admin if course is scheduled with weird start/end dates.""" with test_approvals_workflow(app): with enabled_job(job_key=AdminEmailsJob.key()): term_id = app.config['CURRENT_TERM_ID'] section_id = 50014 room_id = Room.find_room('Barker 101').id # The course has two instructors. instructor_uid = get_instructor_uids(section_id=section_id, term_id=term_id)[0] approval = Approval.create( approved_by_uid=instructor_uid, approver_type_='instructor', publish_type_='kaltura_my_media', recording_type_='presenter_audio', room_id=room_id, section_id=section_id, term_id=term_id, ) # Uh oh! Only one of them has been scheduled. meeting = get_eligible_meeting(section_id=section_id, term_id=term_id) Scheduled.create( instructor_uids=[instructor_uid], kaltura_schedule_id=random.randint(1, 10), meeting_days=meeting['days'], meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date( meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_=approval.publish_type, recording_type_=approval.recording_type, room_id=room_id, section_id=section_id, term_id=term_id, ) courses = SisSection.get_courses_scheduled_nonstandard_dates( term_id=term_id) course = next( (c for c in courses if c['sectionId'] == section_id), None) assert course # Message queued but not sent. admin_uid = app.config['EMAIL_DIABLO_ADMIN_UID'] AdminEmailsJob(simply_yield).run() queued_messages = QueuedEmail.query.filter_by( section_id=section_id).all() assert len(queued_messages) == 1 for queued_message in queued_messages: assert '2020-08-26 to 2020-10-02' in queued_message.message # Message sent. QueuedEmailsJob(simply_yield).run() emails_sent = SentEmail.get_emails_sent_to(uid=admin_uid) assert len(emails_sent) == 1 assert emails_sent[ 0].template_type == 'admin_alert_multiple_meeting_patterns' assert emails_sent[0].section_id == section_id
def test_alert_admin_of_instructor_change(self): """Emails admin when a scheduled course gets a new instructor.""" with test_approvals_workflow(app): with enabled_job(job_key=AdminEmailsJob.key()): term_id = app.config['CURRENT_TERM_ID'] section_id = 50005 room_id = Room.find_room('Barker 101').id # The course has two instructors. instructor_1_uid, instructor_2_uid = get_instructor_uids( section_id=section_id, term_id=term_id) approval = Approval.create( approved_by_uid=instructor_1_uid, approver_type_='instructor', course_display_name= f'term_id:{term_id} section_id:{section_id}', publish_type_='kaltura_my_media', recording_type_='presenter_audio', room_id=room_id, section_id=section_id, term_id=term_id, ) # Uh oh! Only one of them has been scheduled. meeting = get_eligible_meeting(section_id=section_id, term_id=term_id) Scheduled.create( course_display_name= f'term_id:{term_id} section_id:{section_id}', instructor_uids=[instructor_1_uid], kaltura_schedule_id=random.randint(1, 10), meeting_days=meeting['days'], meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date( meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_=approval.publish_type, recording_type_=approval.recording_type, room_id=room_id, section_id=section_id, term_id=term_id, ) admin_uid = app.config['EMAIL_DIABLO_ADMIN_UID'] email_count = _get_email_count(admin_uid) # Message queued but not sent. AdminEmailsJob(simply_yield).run() assert _get_email_count(admin_uid) == email_count queued_messages = QueuedEmail.query.filter_by( template_type='admin_alert_instructor_change').all() assert len(queued_messages) == 1 for snippet in [ 'LAW 23', 'Old instructor(s) Regan MacNeil', 'New instructor(s) Regan MacNeil, Burke Dennings' ]: assert snippet in queued_messages[0].message # Message sent. QueuedEmailsJob(simply_yield).run() assert _get_email_count(admin_uid) == email_count + 1
def test_start_date_is_in_the_past(self): df = '%Y-%m-%d' today = datetime.today() recordings_begin_date = today - timedelta(days=7) first_meeting = today - timedelta(days=3) with override_config(app, 'CURRENT_TERM_RECORDINGS_BEGIN', datetime.strftime(recordings_begin_date, df)): meeting = { 'days': ''.join(DAYS), 'startDate': f'{datetime.strftime(first_meeting, df)} 00:00:00 UTC', } start_date = get_recording_start_date(meeting, return_today_if_past_start=True) assert datetime.strftime(start_date, df) == datetime.strftime(today, df)
def test_alert_admin_of_room_change(self, db_session): """Emails admin when a scheduled course gets a room change.""" with test_approvals_workflow(app): with enabled_job(job_key=AdminEmailsJob.key()): term_id = app.config['CURRENT_TERM_ID'] section_id = 50004 approved_by_uid = '10004' the_old_room = 'Wheeler 150' scheduled_in_room = Room.find_room(the_old_room) approval = Approval.create( approved_by_uid=approved_by_uid, approver_type_='instructor', publish_type_='kaltura_media_gallery', recording_type_='presenter_audio', room_id=scheduled_in_room.id, section_id=section_id, term_id=term_id, ) meeting = get_eligible_meeting(section_id=section_id, term_id=term_id) Scheduled.create( instructor_uids=get_instructor_uids(term_id=term_id, section_id=section_id), kaltura_schedule_id=random.randint(1, 10), meeting_days=meeting['days'], meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date( meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_=approval.publish_type, recording_type_=approval.recording_type, room_id=scheduled_in_room.id, section_id=section_id, term_id=term_id, ) admin_uid = app.config['EMAIL_DIABLO_ADMIN_UID'] # Message queued, then sent. AdminEmailsJob(simply_yield).run() QueuedEmailsJob(simply_yield).run() emails_sent = SentEmail.get_emails_sent_to(uid=admin_uid) assert len(emails_sent) == 1 assert emails_sent[0].section_id == section_id assert emails_sent[ 0].template_type == 'admin_alert_room_change'
def schedule_recordings(all_approvals, course): def _report_error(subject): message = f'{subject}\n\n<pre>{course}</pre>' app.logger.error(message) send_system_error_email(message=message, subject=subject) meetings = course.get('meetings', {}).get('eligible', []) meeting = meetings[0] if len(meetings) == 1 else None if not meeting: _report_error( subject= f"{course['label']} not scheduled. Unique eligible meeting pattern not found." ) return None all_approvals.sort(key=lambda a: a.created_at.isoformat()) latest_approval = all_approvals[-1] room = Room.get_room(latest_approval.room_id) if room.location != meeting['location']: _report_error( subject= f"{course['label']} not scheduled. Room change: {room.location} to {meeting['location']}" ) return None has_admin_approval = next( (a for a in all_approvals if a.approver_type == 'admin'), None) approved_by_uids = set(a.approved_by_uid for a in all_approvals) instructor_uids = set([i['uid'] for i in course['instructors']]) if not has_admin_approval and not instructor_uids.issubset( approved_by_uids): _report_error( subject= f"{course['label']} not scheduled. We are missing instructor approval(s)." ) return None term_id = course['termId'] section_id = int(course['sectionId']) scheduled = None if room.kaltura_resource_id: try: kaltura_schedule_id = Kaltura().schedule_recording( canvas_course_site_ids=[ c['courseSiteId'] for c in course['canvasCourseSites'] ], course_label=course['label'], instructors=course['instructors'], meeting=meeting, publish_type=latest_approval.publish_type, recording_type=latest_approval.recording_type, room=room, term_id=term_id, ) scheduled = Scheduled.create( course_display_name=course['label'], instructor_uids=instructor_uids, kaltura_schedule_id=kaltura_schedule_id, meeting_days=meeting['days'], meeting_end_date=get_recording_end_date(meeting), meeting_end_time=meeting['endTime'], meeting_start_date=get_recording_start_date( meeting, return_today_if_past_start=True), meeting_start_time=meeting['startTime'], publish_type_=latest_approval.publish_type, recording_type_=latest_approval.recording_type, room_id=room.id, section_id=section_id, term_id=term_id, ) # Turn off opt-out setting if present. if section_id in CoursePreference.get_section_ids_opted_out( term_id=term_id): CoursePreference.update_opt_out( term_id=term_id, section_id=section_id, opt_out=False, ) notify_instructors_recordings_scheduled(course=course, scheduled=scheduled) uids = [approval.approved_by_uid for approval in all_approvals] app.logger.info( f'Recordings scheduled for course {section_id} per approvals: {", ".join(uids)}' ) except (KalturaClientException, KalturaException) as e: # Error codes: https://developer.kaltura.com/api-docs/Error_Codes summary = f"Failed to schedule recordings {course['label']} (section_id: {course['sectionId']})" app.logger.error(summary) app.logger.exception(e) send_system_error_email( message=f'{summary}\n\n<pre>{traceback.format_exc()}</pre>', subject=f'{summary[:50]}...' if len(summary) > 50 else summary, ) else: app.logger.warn(f""" SKIP schedule recordings because room has no 'kaltura_resource_id'. Course: {course['label']} Room: {room.location} Latest approved_by_uid: {latest_approval.approved_by_uid} """) return scheduled
def _schedule_recurring_events_in_kaltura( self, category_ids, course_label, instructors, meeting, publish_type, recording_type, room, term_id, ): # Recording starts X minutes before/after official start; it ends Y minutes before/after official end time. days = format_days(meeting['days']) start_time = _adjust_time(meeting['startTime'], app.config['KALTURA_RECORDING_OFFSET_START']) end_time = _adjust_time(meeting['endTime'], app.config['KALTURA_RECORDING_OFFSET_END']) app.logger.info(f""" Prepare to schedule recordings for {course_label}: Room: {room.location} Instructor UIDs: {[instructor['uid'] for instructor in instructors]} Schedule: {days}, {start_time} to {end_time} Recording: {recording_type}; {publish_type} """) term_name = term_name_for_sis_id(term_id) recording_start_date = get_recording_start_date( meeting, return_today_if_past_start=True) recording_end_date = get_recording_end_date(meeting) summary = f'{course_label} ({term_name})' app.logger.info(f""" {course_label} ({term_name}) meets in {room.location}, between {start_time.strftime('%H:%M')} and {end_time.strftime('%H:%M')}, on {days}. Recordings of type {recording_type} will be published to {publish_type}. """) first_day_start = get_first_matching_datetime_of_term( meeting_days=days, start_date=recording_start_date, time_hours=start_time.hour, time_minutes=start_time.minute, ) first_day_end = get_first_matching_datetime_of_term( meeting_days=days, start_date=recording_start_date, time_hours=end_time.hour, time_minutes=end_time.minute, ) description = get_series_description(course_label, instructors, term_name) base_entry = self._create_kaltura_base_entry( description=description, instructors=instructors, name=f'{summary} in {room.location}', ) for category_id in category_ids or []: self.add_to_kaltura_category(category_id=category_id, entry_id=base_entry.id) until = datetime.combine( recording_end_date, time(end_time.hour, end_time.minute), tzinfo=default_timezone(), ) recurring_event = KalturaRecordScheduleEvent( # https://developer.kaltura.com/api-docs/General_Objects/Objects/KalturaScheduleEvent classificationType=KalturaScheduleEventClassificationType. PUBLIC_EVENT, comment=f'{summary} in {room.location}', contact=','.join(instructor['uid'] for instructor in instructors), description=description, duration=(end_time - start_time).seconds, endDate=first_day_end.timestamp(), organizer=app.config['KALTURA_EVENT_ORGANIZER'], ownerId=app.config['KALTURA_KMS_OWNER_ID'], partnerId=self.kaltura_partner_id, recurrence=KalturaScheduleEventRecurrence( # https://developer.kaltura.com/api-docs/General_Objects/Objects/KalturaScheduleEventRecurrence byDay=','.join(days), frequency=KalturaScheduleEventRecurrenceFrequency.WEEKLY, # 'interval' is not documented. When scheduling manually, the value was 1 in each individual event. interval=1, name=summary, timeZone='US/Pacific', until=until.timestamp(), weekStartDay=days[0], ), recurrenceType=KalturaScheduleEventRecurrenceType.RECURRING, startDate=first_day_start.timestamp(), status=KalturaScheduleEventStatus.ACTIVE, summary=summary, tags=CREATED_BY_DIABLO_TAG, templateEntryId=base_entry.id, ) return self.kaltura_client.schedule.scheduleEvent.add(recurring_event)
def _to_api_json(term_id, rows, include_rooms=True): rows = rows.fetchall() section_ids = list(set(int(row['section_id']) for row in rows)) courses_per_id = {} # Perform bulk queries and build data structures for feed generation. section_ids_opted_out = CoursePreference.get_section_ids_opted_out( term_id=term_id) invited_uids_by_section_id = {section_id: [] for section_id in section_ids} for invite in SentEmail.get_emails_of_type(section_ids=section_ids, template_type='invitation', term_id=term_id): if invite.recipient_uid not in invited_uids_by_section_id[ invite.section_id]: invited_uids_by_section_id[invite.section_id].append( invite.recipient_uid) approval_results = Approval.get_approvals_per_section_ids( section_ids=section_ids, term_id=term_id) scheduled_results = Scheduled.get_scheduled_per_section_ids( section_ids=section_ids, term_id=term_id) room_ids = set(row['room_id'] for row in rows) room_ids.update(a.room_id for a in approval_results) room_ids.update(s.room_id for s in scheduled_results) rooms = Room.get_rooms(list(room_ids)) rooms_by_id = {room.id: room for room in rooms} approvals_by_section_id = {section_id: [] for section_id in section_ids} for approval in approval_results: approvals_by_section_id[approval.section_id].append( approval.to_api_json(rooms_by_id=rooms_by_id)) scheduled_by_section_id = { s.section_id: s.to_api_json(rooms_by_id=rooms_by_id) for s in scheduled_results } cross_listings_per_section_id, instructors_per_section_id, canvas_sites_by_section_id = _get_cross_listed_courses( section_ids=section_ids, term_id=term_id, approvals=approvals_by_section_id, invited_uids=invited_uids_by_section_id, ) # Construct course objects. # If course has multiple instructors or multiple rooms then the section_id will be represented across multiple rows. # Multiple rooms are rare, but a course is sometimes associated with both an eligible and an ineligible room. We # order rooms in SQL by capability, NULLS LAST, and use scheduling data from the first row available. for row in rows: section_id = int(row['section_id']) if section_id in courses_per_id: course = courses_per_id[section_id] else: # Approvals and scheduled (JSON) approvals = approvals_by_section_id.get(section_id) scheduled = scheduled_by_section_id.get(section_id) # Instructors per cross-listings cross_listed_courses = cross_listings_per_section_id.get( section_id, []) instructors = instructors_per_section_id.get(section_id, []) # Construct course course = { 'allowedUnits': row['allowed_units'], 'approvals': approvals, 'canvasCourseSites': canvas_sites_by_section_id.get(section_id, []), 'courseName': row['course_name'], 'courseTitle': row['course_title'], 'crossListings': cross_listed_courses, 'deletedAt': safe_strftime(row['deleted_at'], '%Y-%m-%d'), 'hasOptedOut': section_id in section_ids_opted_out, 'instructionFormat': row['instruction_format'], 'instructors': instructors, 'invitees': invited_uids_by_section_id.get(section_id), 'isPrimary': row['is_primary'], 'label': _construct_course_label( course_name=row['course_name'], instruction_format=row['instruction_format'], section_num=row['section_num'], cross_listings=cross_listed_courses, ), 'meetings': { 'eligible': [], 'ineligible': [], }, 'nonstandardMeetingDates': False, 'sectionId': section_id, 'sectionNum': row['section_num'], 'scheduled': scheduled, 'termId': row['term_id'], } courses_per_id[section_id] = course # Note: Instructors associated with cross-listings are slurped up separately. instructor_uid = row['instructor_uid'] if instructor_uid and instructor_uid not in [ i['uid'] for i in course['instructors'] ]: course['instructors'].append( _to_instructor_json( row=row, approvals=course['approvals'], invited_uids=course['invitees'], ), ) meeting = _to_meeting_json(row) eligible_meetings = course['meetings']['eligible'] ineligible_meetings = course['meetings']['ineligible'] if not next((m for m in (eligible_meetings + ineligible_meetings) if meeting.items() <= m.items()), None): room = rooms_by_id.get( row['room_id']) if 'room_id' in row.keys() else None if room and room.capability: meeting['eligible'] = True meeting.update({ 'recordingEndDate': safe_strftime(get_recording_end_date(meeting), '%Y-%m-%d'), 'recordingStartDate': safe_strftime(get_recording_start_date(meeting), '%Y-%m-%d'), }) eligible_meetings.append(meeting) eligible_meetings.sort( key=lambda m: f"{m['startDate']} {m['startTime']}") if meeting['startDate'] != app.config[ 'CURRENT_TERM_BEGIN'] or meeting[ 'endDate'] != app.config['CURRENT_TERM_END']: course['nonstandardMeetingDates'] = True else: meeting['eligible'] = False ineligible_meetings.append(meeting) ineligible_meetings.sort( key=lambda m: f"{m['startDate']} {m['startTime']}") if include_rooms: meeting['room'] = room.to_api_json() if room else None # Next, construct the feed api_json = [] for section_id, course in courses_per_id.items(): _decorate_course(course) # Add course to the feed api_json.append(course) return api_json