Example #1
0
    def test_course_has_opted_out(self):
        """Do not send email to courses that have opted out."""
        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 = 50000
        CoursePreference.update_opt_out(term_id=term_id,
                                        section_id=section_id,
                                        opt_out=True)
        email_template_type = 'invitation'
        recipient = {
            'name': 'William Peter Blatty',
            'uid': '10001',
        }
        QueuedEmail.create(section_id,
                           email_template_type,
                           term_id,
                           recipient=recipient)
        std_commit(allow_test_environment=True)

        emails_sent_before = _emails_sent()
        # Run the job
        QueuedEmailsJob(simply_yield).run()
        std_commit(allow_test_environment=True)

        # Expect no emails sent
        emails_sent_after = _emails_sent()
        assert len(emails_sent_after) == len(emails_sent_before)
        assert list(map(lambda e: e.id, emails_sent_before)) == list(
            map(lambda e: e.id, emails_sent_after))
    def test_course_has_opted_out(self):
        """Do not send email to courses that have opted out."""
        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 = 28602
        CoursePreference.update_opt_out(term_id=term_id,
                                        section_id=section_id,
                                        opt_out=True)
        email_template_type = 'invitation'

        QueuedEmail.create(section_id, email_template_type, term_id)
        std_commit(allow_test_environment=True)

        before = utc_now()
        emails_sent_before = _emails_sent()
        # Run the job
        QueuedEmailsJob(app.app_context).run()
        std_commit(allow_test_environment=True)

        # Expect no emails sent
        emails_sent_after = _emails_sent()
        assert len(emails_sent_after) == len(emails_sent_before)
        assert not next(
            (e for e in emails_sent_after
             if e.section_id == section_id and e.sent_at > before), None)
Example #3
0
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 test_opt_out_cross_listings(self, client, admin_session):
        """If a section opts out then its cross-listings are automatically opted out."""
        with test_approvals_workflow(app):
            # First, opt out
            cross_listed_section_ids = [28475, 27950, 32827]
            self._api_opt_out_update(
                client,
                term_id=self.term_id,
                section_id=cross_listed_section_ids[-1],
                opt_out=True,
            )
            section_ids_opted_out = CoursePreference.get_section_ids_opted_out(term_id=self.term_id)
            for section_id in cross_listed_section_ids:
                assert section_id in section_ids_opted_out
            std_commit(allow_test_environment=True)

            # Opt back in
            self._api_opt_out_update(
                client,
                term_id=self.term_id,
                section_id=cross_listed_section_ids[0],
                opt_out=False,
            )
            section_ids_opted_out = CoursePreference.get_section_ids_opted_out(term_id=self.term_id)
            for section_id in cross_listed_section_ids:
                assert section_id not in section_ids_opted_out
    def test_do_not_email_filter(self, client, db, admin_session):
        """Do Not Email filter: Courses in eligible room; "opt out" is true; all stages of approval; not scheduled."""
        with test_approvals_workflow(app):
            # Send invites them opt_out.
            for section_id in (section_1_id, section_in_ineligible_room, section_3_id, section_4_id):
                CoursePreference.update_opt_out(section_id=section_id, term_id=self.term_id, opt_out=True)

                in_enabled_room = _is_course_in_enabled_room(section_id=section_id, term_id=self.term_id)
                if section_id == section_in_ineligible_room:
                    # Courses in ineligible rooms will be excluded from the feed.
                    assert not in_enabled_room
                else:
                    assert in_enabled_room
                    SentEmail.create(
                        section_id=section_id,
                        recipient_uids=_get_instructor_uids(section_id=section_id, term_id=self.term_id),
                        template_type='invitation',
                        term_id=self.term_id,
                    )

            # If course has approvals but not scheduled then it will show up in the feed.
            Approval.create(
                approved_by_uid=_get_instructor_uids(section_id=section_1_id, term_id=self.term_id)[0],
                approver_type_='instructor',
                cross_listed_section_ids=[],
                publish_type_='canvas',
                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,
            )
            # Feed will exclude scheduled.
            _schedule_recordings(
                section_id=section_3_id,
                term_id=self.term_id,
            )
            std_commit(allow_test_environment=True)

            api_json = self._api_courses(client, term_id=self.term_id, filter_='Do Not Email')
            for section_id in (section_1_id, section_4_id):
                # The 'Do Not Email' course is in the feed
                assert _find_course(api_json=api_json, section_id=section_id)

            for section_id in (section_3_id, section_in_ineligible_room):
                # Excluded courses
                assert not _find_course(api_json=api_json, section_id=section_id)
    def test_authorized(self, client, admin_session):
        """Only admins can toggle the do-not-email preference of any given course."""
        section_ids_opted_out = CoursePreference.get_section_ids_opted_out(term_id=self.term_id)
        previously_opted_out = section_1_id not in section_ids_opted_out
        opt_out = not previously_opted_out
        self._api_opt_out_update(
            client,
            term_id=self.term_id,
            section_id=section_1_id,
            opt_out=opt_out,
        )
        std_commit(allow_test_environment=True)

        section_ids_opted_out = CoursePreference.get_section_ids_opted_out(term_id=self.term_id)
        if opt_out:
            assert section_1_id in section_ids_opted_out
        else:
            assert section_1_id not in section_ids_opted_out
Example #7
0
def update_opt_out():
    params = request.get_json()
    term_id = params.get('termId')
    section_id = params.get('sectionId')
    opt_out = params.get('optOut')
    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 test_course_opted_out(self, app):
        """Do not send email to courses that have opted out."""
        term_id = app.config['CURRENT_TERM_ID']
        with test_approvals_workflow(app):
            section_id = 50006
            CoursePreference.update_opt_out(term_id=term_id,
                                            section_id=section_id,
                                            opt_out=True)
            std_commit(allow_test_environment=True)

            timestamp = utc_now()
            # Emails are queued but not sent.
            InvitationJob(simply_yield).run()
            assert len(_get_invitations_since(term_id, timestamp)) == 0
            # Emails are sent.
            QueuedEmailsJob(simply_yield).run()
            invitations = _get_invitations_since(term_id, timestamp)
            assert len(invitations) == 15
            assert not next(
                (e for e in invitations if e.section_id == section_id), None)
Example #9
0
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
Example #10
0
    def test_do_not_email_filter(self, client, admin_session):
        """Do Not Email filter: Courses in eligible room; "opt out" is true; all stages of approval; not scheduled."""
        with test_approvals_workflow(app):
            # Send invites them opt_out.
            for section_id in (section_1_id, section_in_ineligible_room, section_3_id, section_4_id):
                CoursePreference.update_opt_out(section_id=section_id, term_id=self.term_id, opt_out=True)

                in_enabled_room = _is_course_in_enabled_room(section_id=section_id, term_id=self.term_id)
                if section_id == section_in_ineligible_room:
                    # Courses in ineligible rooms will be excluded from the feed.
                    assert not in_enabled_room
                else:
                    assert in_enabled_room
                    self._send_invitation_email(section_id)

            # If course has approvals but not scheduled then it will show up in the feed.
            self._create_approval(section_4_id)
            # Feed will exclude scheduled.
            mock_scheduled(
                section_id=section_3_id,
                term_id=self.term_id,
            )
            std_commit(allow_test_environment=True)

            api_json = self._api_courses(client, term_id=self.term_id, filter_='Do Not Email')

            # Opted-out courses are in the feed, whether approved or not
            course_1 = _find_course(api_json=api_json, section_id=section_1_id)
            assert course_1['approvalStatus'] == 'Invited'
            assert course_1['schedulingStatus'] == 'Not Scheduled'
            course_4 = _find_course(api_json=api_json, section_id=section_4_id)
            assert course_4['approvalStatus'] == 'Approved'
            assert course_4['schedulingStatus'] == 'Queued for Scheduling'

            for section_id in (section_3_id, section_in_ineligible_room):
                # Excluded courses
                assert not _find_course(api_json=api_json, section_id=section_id)
Example #11
0
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())
Example #12
0
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
Example #13
0
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
Example #14
0
def _to_api_json(term_id, rows, include_rooms=True):
    courses_per_id = {}
    instructors_per_section_id = {}
    section_ids_opted_out = CoursePreference.get_section_ids_opted_out(
        term_id=term_id)
    # If course has multiple instructors then the section_id will be represented across multiple rows.
    for row in rows:
        approvals = []
        section_id = int(row['section_id'])
        if section_id not in courses_per_id:
            # Construct new course
            instructors_per_section_id[section_id] = []
            has_opted_out = section_id in section_ids_opted_out
            cross_listings = _get_cross_listed_courses(section_id=section_id,
                                                       term_id=term_id)
            approvals, scheduled = _get_approvals_and_scheduled(
                section_ids=[section_id] +
                [c['sectionId'] for c in cross_listings],
                term_id=term_id,
            )
            course_name = row['course_name']
            instruction_format = row['instruction_format']
            section_num = row['section_num']
            course = {
                'allowedUnits': row['allowed_units'],
                'canvasCourseSites': _canvas_course_sites(term_id, section_id),
                'courseName': course_name,
                'courseTitle': row['course_title'],
                'crossListings': cross_listings,
                'hasOptedOut': has_opted_out,
                'instructionFormat': instruction_format,
                'instructors': [],
                'isPrimary': row['is_primary'],
                'label': f'{course_name}, {instruction_format} {section_num}',
                'meetingDays': format_days(row['meeting_days']),
                'meetingEndDate': row['meeting_end_date'],
                'meetingEndTime': format_time(row['meeting_end_time']),
                'meetingLocation': row['meeting_location'],
                'meetingStartDate': row['meeting_start_date'],
                'meetingStartTime': format_time(row['meeting_start_time']),
                'sectionId': section_id,
                'sectionNum': section_num,
                'termId': row['term_id'],
                'approvals': approvals,
                'scheduled': scheduled,
            }
            invites = SentEmail.get_emails_of_type(
                section_id=section_id,
                template_type='invitation',
                term_id=term_id,
            )
            course['invitees'] = []
            for invite in invites:
                course['invitees'].extend(invite.recipient_uids)

            if scheduled:
                course['status'] = 'Scheduled'
            elif approvals:
                course['status'] = 'Partially Approved'
            else:
                course['status'] = 'Invited' if invites else 'Not Invited'

            if include_rooms:
                room = Room.get_room(
                    row['room_id']).to_api_json() if 'room_id' in row else None
                course['room'] = room
            courses_per_id[section_id] = course

        # Build upon course object with one instructor per row.
        instructor_uid = row['instructor_uid']
        if instructor_uid not in [
                i['uid'] for i in instructors_per_section_id[section_id]
        ]:
            instructors_per_section_id[section_id].append({
                'approval':
                next((a for a in approvals
                      if a['approvedBy']['uid'] == instructor_uid), False),
                'deptCode':
                row['instructor_dept_code'],
                'email':
                row['instructor_email'],
                'name':
                row['instructor_name'],
                'roleCode':
                row['instructor_role_code'],
                'uid':
                instructor_uid,
                'wasSentInvite':
                instructor_uid in courses_per_id[section_id]['invitees'],
            })

    api_json = []
    for section_id, course in courses_per_id.items():
        room_id = course.get('room', {}).get('id')

        def _add_and_verify_room(approval_or_scheduled):
            action_room_id = approval_or_scheduled.get('room', {}).get('id')
            is_obsolete_room = not room_id or room_id != action_room_id
            approval_or_scheduled['hasObsoleteRoom'] = is_obsolete_room

        course['instructors'] = instructors_per_section_id[section_id]
        course['hasNecessaryApprovals'] = _has_necessary_approvals(course)
        scheduled = course['scheduled']
        # Check for course changes w.r.t. room, meeting times, and instructors.
        if scheduled:

            def _meeting(obj):
                return f'{obj["meetingDays"]}-{obj["meetingStartTime"]}-{obj["meetingEndTime"]}'

            instructor_uids = set(
                [instructor['uid'] for instructor in course['instructors']])
            scheduled['hasObsoleteInstructors'] = instructor_uids != set(
                scheduled['instructorUids'])
            scheduled['hasObsoleteMeetingTimes'] = _meeting(
                course) != _meeting(scheduled)
            _add_and_verify_room(scheduled)

        for approval in course['approvals']:
            _add_and_verify_room(approval)

        # Add course to the feed
        api_json.append(course)
    return api_json