Files
edx-platform/lms/djangoapps/mobile_api/users/tests.py
Michael Terry 74887aa216 feat: turn on schedule creation by default
This commit removes several waffle toggles that have been enabled
on edx.org for years. It's time to remove the rollout gating for
these features and enable them by default.

This doesn't directly change any behavior. But it does create new
database objects by default now and allows for enabling other
schedule based features more easily.

Specifically, the following toggles were affected.

schedules.create_schedules_for_course
- Waffle flag removed as always-enabled
- We now always create a schedule when an enrollment is created

schedules.send_updates_for_course
- Waffle flag removed as always-enabled
- Course update emails are sent as long as the ScheduleConfig
  allows it.
- This is not a change in default behavior, because ScheduleConfig
  is off by default.

dynamic_pacing.studio_course_update
- Waffle switch removed as always-enabled
- Course teams can now always edit course updates directly in Studio

ScheduleConfig.create_schedules
ScheduleConfig.hold_back_ratio
- Model fields for rolling out the schedules feature
- Schedules are now always created
- This commit only removes references to these fields, they still
  exist in the database. A future commit will remove them entirely

This commit also adds a new has_highlights field to CourseOverview.
This is used to cache whether a course has highlights, used to
decide which course update email behavior they get. Previously every
enrollment had to dig into the modulestore to determine that.
2021-02-23 12:34:02 -05:00

610 lines
25 KiB
Python

"""
Tests for users API
"""
import datetime
from unittest.mock import patch
from urllib.parse import parse_qs
import ddt
import pytz
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
from django.conf import settings
from django.template import defaultfilters
from django.test import RequestFactory, override_settings
from django.utils import timezone
from django.utils.timezone import now
from milestones.tests.utils import MilestonesTestCaseMixin
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory
from common.djangoapps.util.milestones_helpers import set_prerequisite_courses
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.certificates.api import generate_user_certificates
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from lms.djangoapps.mobile_api.testutils import (
MobileAPITestCase,
MobileAuthTestMixin,
MobileAuthUserTestMixin,
MobileCourseAccessTestMixin
)
from lms.djangoapps.mobile_api.utils import API_V1, API_V05
from openedx.core.lib.courses import course_image_url
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from .. import errors
from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05
@ddt.ddt
class TestUserDetailApi(MobileAPITestCase, MobileAuthUserTestMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>...
"""
REVERSE_INFO = {'name': 'user-detail', 'params': ['username', 'api_version']}
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
self.login()
response = self.api_response(api_version=api_version)
assert response.data['username'] == self.user.username
assert response.data['email'] == self.user.email
@ddt.ddt
class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin):
"""
Tests for /api/mobile/{api_version}/my_user_info
"""
REVERSE_INFO = {'name': 'user-info', 'params': ['api_version']}
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
"""Verify the endpoint redirects to the user detail endpoint"""
self.login()
response = self.api_response(expected_response_code=302, api_version=api_version)
assert self.username in response['location']
@ddt.data(API_V05, API_V1)
def test_last_loggedin_updated(self, api_version):
"""Verify that a user's last logged in value updates after hitting the my_user_info endpoint"""
self.login()
self.user.refresh_from_db()
last_login_before = self.user.last_login
# just hit the api endpoint; we don't care about the response here (tested previously)
self.api_response(expected_response_code=302, api_version=api_version)
self.user.refresh_from_db()
last_login_after = self.user.last_login
assert last_login_after > last_login_before
@ddt.ddt
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>/course_enrollments/
"""
REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username', 'api_version']}
ALLOW_ACCESS_TO_UNRELEASED_COURSE = True
ALLOW_ACCESS_TO_MILESTONE_COURSE = True
ALLOW_ACCESS_TO_NON_VISIBLE_COURSE = True
NEXT_WEEK = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=7)
LAST_WEEK = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=7)
THREE_YEARS_AGO = now() - datetime.timedelta(days=(365 * 3))
ADVERTISED_START = "Spring 2016"
ENABLED_SIGNALS = ['course_published']
DATES = {
'next_week': NEXT_WEEK,
'last_week': LAST_WEEK,
'default_start_date': DEFAULT_START_DATE,
}
@patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
def verify_success(self, response):
"""
Verifies user course enrollment response for success
"""
super().verify_success(response)
courses = response.data
assert len(courses) == 1
found_course = courses[0]['course']
assert 'courses/{}/about'.format(self.course.id) in found_course['course_about']
assert 'course_info/{}/updates'.format(self.course.id) in found_course['course_updates']
assert 'course_info/{}/handouts'.format(self.course.id) in found_course['course_handouts']
assert found_course['id'] == str(self.course.id)
assert courses[0]['mode'] == CourseMode.DEFAULT_MODE_SLUG
assert courses[0]['course']['subscription_id'] == self.course.clean_id(padding_char='_')
expected_course_image_url = course_image_url(self.course)
assert expected_course_image_url is not None
assert expected_course_image_url in found_course['course_image']
assert expected_course_image_url in found_course['media']['course_image']['uri']
def verify_failure(self, response, error_type=None):
assert response.status_code == 200
courses = response.data
assert len(courses) == 0
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@ddt.data(API_V05, API_V1)
def test_sort_order(self, api_version):
self.login()
num_courses = 3
courses = []
for course_index in range(num_courses):
courses.append(CourseFactory.create(mobile_available=True))
self.enroll(courses[course_index].id)
# verify courses are returned in the order of enrollment, with most recently enrolled first.
response = self.api_response(api_version=api_version)
for course_index in range(num_courses):
assert response.data[course_index]['course']['id'] ==\
str(courses[((num_courses - course_index) - 1)].id)
@ddt.data(API_V05, API_V1)
@patch.dict(settings.FEATURES, {
'ENABLE_PREREQUISITE_COURSES': True,
'DISABLE_START_DATES': False,
'ENABLE_MKTG_SITE': True,
})
def test_courseware_access(self, api_version):
self.login()
course_with_prereq = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True)
prerequisite_course = CourseFactory.create()
set_prerequisite_courses(course_with_prereq.id, [str(prerequisite_course.id)])
# Create list of courses with various expected courseware_access responses and corresponding expected codes
courses = [
course_with_prereq,
CourseFactory.create(start=self.NEXT_WEEK, mobile_available=True),
CourseFactory.create(visible_to_staff_only=True, mobile_available=True),
CourseFactory.create(start=self.LAST_WEEK, mobile_available=True, visible_to_staff_only=False),
]
expected_error_codes = [
MilestoneAccessError().error_code, # 'unfulfilled_milestones'
StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started'
VisibilityError().error_code, # 'not_visible_to_user'
None,
]
# Enroll in all the courses
for course in courses:
self.enroll(course.id)
# Verify courses have the correct response through error code. Last enrolled course is first course in response
response = self.api_response(api_version=api_version)
for course_index in range(len(courses)):
result = response.data[course_index]['course']['courseware_access']
assert result['error_code'] == expected_error_codes[::(- 1)][course_index]
if result['error_code'] is not None:
assert not result['has_access']
@ddt.data(
('next_week', ADVERTISED_START, ADVERTISED_START, "string", API_V05),
('next_week', ADVERTISED_START, ADVERTISED_START, "string", API_V1),
('next_week', None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V05),
('next_week', None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V1),
('next_week', '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V05),
('next_week', '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V1),
('default_start_date', ADVERTISED_START, ADVERTISED_START, "string", API_V05),
('default_start_date', ADVERTISED_START, ADVERTISED_START, "string", API_V1),
('default_start_date', '', None, "empty", API_V05),
('default_start_date', '', None, "empty", API_V1),
('default_start_date', None, None, "empty", API_V05),
('default_start_date', None, None, "empty", API_V1),
)
@ddt.unpack
@patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False, 'ENABLE_MKTG_SITE': True})
def test_start_type_and_display(self, start, advertised_start, expected_display, expected_type, api_version):
"""
Tests that the correct start_type and start_display are returned in the
case the course has not started
"""
self.login()
course = CourseFactory.create(start=self.DATES[start], advertised_start=advertised_start, mobile_available=True)
self.enroll(course.id)
response = self.api_response(api_version=api_version)
assert response.data[0]['course']['start_type'] == expected_type
assert response.data[0]['course']['start_display'] == expected_display
@ddt.data(API_V05, API_V1)
@patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True, 'ENABLE_MKTG_SITE': True})
def test_discussion_url(self, api_version):
self.login_and_enroll()
response = self.api_response(api_version=api_version)
response_discussion_url = response.data[0]['course']['discussion_url']
assert '/api/discussion/v1/courses/{}'.format(self.course.id) in response_discussion_url
@ddt.data(API_V05, API_V1)
def test_org_query(self, api_version):
self.login()
# Create list of courses with various organizations
courses = [
CourseFactory.create(org='edX', mobile_available=True),
CourseFactory.create(org='edX', mobile_available=True),
CourseFactory.create(org='edX', mobile_available=True, visible_to_staff_only=True),
CourseFactory.create(org='Proversity.org', mobile_available=True),
CourseFactory.create(org='MITx', mobile_available=True),
CourseFactory.create(org='HarvardX', mobile_available=True),
]
# Enroll in all the courses
for course in courses:
self.enroll(course.id)
response = self.api_response(data={'org': 'edX'}, api_version=api_version)
# Test for 3 expected courses
assert len(response.data) == 3
# Verify only edX courses are returned
for entry in response.data:
assert entry['course']['org'] == 'edX'
def create_enrollment(self, expired):
"""
Create an enrollment
"""
if expired:
course = CourseFactory.create(start=self.THREE_YEARS_AGO, mobile_available=True)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=course.id
)
enrollment.created = self.THREE_YEARS_AGO + datetime.timedelta(days=1)
enrollment.save()
else:
course = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True)
self.enroll(course.id)
add_course_mode(course, mode_slug=CourseMode.AUDIT)
add_course_mode(course)
def _get_enrollment_data(self, api_version, expired):
self.login()
self.create_enrollment(expired)
return self.api_response(api_version=api_version).data
def _assert_enrollment_results(self, api_version, courses, num_courses_returned, gating_enabled=True): # lint-amnesty, pylint: disable=missing-function-docstring
assert len(courses) == num_courses_returned
if api_version == API_V05:
if num_courses_returned:
assert 'audit_access_expires' not in courses[0]
else:
assert 'audit_access_expires' in courses[0]
if gating_enabled:
assert courses[0].get('audit_access_expires') is not None
@ddt.data(
(API_V05, True, 0),
(API_V05, False, 1),
(API_V1, True, 1),
(API_V1, False, 1),
)
@ddt.unpack
def test_enrollment_with_gating(self, api_version, expired, num_courses_returned):
'''
Test that expired courses are only returned in v1 of API
when waffle flag enabled, and un-expired courses always returned
'''
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2015, 1, 1))
courses = self._get_enrollment_data(api_version, expired)
self._assert_enrollment_results(api_version, courses, num_courses_returned, True)
@ddt.data(
(API_V05, True, 1),
(API_V05, False, 1),
(API_V1, True, 1),
(API_V1, False, 1),
)
@ddt.unpack
def test_enrollment_no_gating(self, api_version, expired, num_courses_returned):
'''
Test that expired and non-expired courses returned if waffle flag is disabled
regarless of version of API
'''
CourseDurationLimitConfig.objects.create(enabled=False)
courses = self._get_enrollment_data(api_version, expired)
self._assert_enrollment_results(api_version, courses, num_courses_returned, False)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>/course_enrollments/
"""
REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username', 'api_version']}
ENABLED_SIGNALS = ['course_published']
def verify_pdf_certificate(self):
"""
Verifies the correct URL is returned in the response
for PDF certificates.
"""
self.login_and_enroll()
certificate_url = "http://test_certificate_url"
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url=certificate_url,
)
response = self.api_response()
certificate_data = response.data[0]['certificate']
assert certificate_data['url'] == certificate_url
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
def test_no_certificate(self):
self.login_and_enroll()
response = self.api_response()
certificate_data = response.data[0]['certificate']
self.assertDictEqual(certificate_data, {})
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False, 'ENABLE_MKTG_SITE': True})
def test_pdf_certificate_with_html_cert_disabled(self):
"""
Tests PDF certificates with CERTIFICATES_HTML_VIEW set to True.
"""
self.verify_pdf_certificate()
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
def test_pdf_certificate_with_html_cert_enabled(self):
"""
Tests PDF certificates with CERTIFICATES_HTML_VIEW set to True.
"""
self.verify_pdf_certificate()
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
def test_web_certificate(self):
CourseMode.objects.create(
course_id=self.course.id,
mode_display_name="Honor",
mode_slug=CourseMode.HONOR,
)
self.login_and_enroll()
self.course.cert_html_view_enabled = True
self.store.update_item(self.course, self.user.id)
with mock_passing_grade():
generate_user_certificates(self.user, self.course.id)
response = self.api_response()
certificate_data = response.data[0]['certificate']
self.assertRegex(
certificate_data['url'],
r'http.*/certificates/[0-9a-f]{32}'
)
class CourseStatusAPITestCase(MobileAPITestCase):
"""
Base test class for /api/mobile/{api_version}/users/<user_name>/course_status_info/{course_id}
"""
REVERSE_INFO = {'name': 'user-course-status', 'params': ['username', 'course_id', 'api_version']}
def setUp(self):
"""
Creates a basic course structure for our course
"""
super().setUp()
self.section = ItemFactory.create(
parent=self.course,
category='chapter',
)
self.sub_section = ItemFactory.create(
parent=self.section,
category='sequential',
)
self.unit = ItemFactory.create(
parent=self.sub_section,
category='vertical',
)
self.other_sub_section = ItemFactory.create(
parent=self.section,
category='sequential',
)
self.other_unit = ItemFactory.create(
parent=self.other_sub_section,
category='vertical',
)
class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin, CompletionWaffleTestMixin):
"""
Tests for GET of /api/mobile/v<version_number>/users/<user_name>/course_status_info/{course_id}
"""
def test_success_v0(self):
self.login_and_enroll()
response = self.api_response(api_version=API_V05)
assert response.data['last_visited_module_id'] == str(self.sub_section.location)
assert response.data['last_visited_module_path'] == [str(module.location) for module in
[self.sub_section, self.section, self.course]]
def test_success_v1(self):
self.override_waffle_switch(True)
self.login_and_enroll()
submit_completions_for_testing(self.user, [self.unit.location])
response = self.api_response(api_version=API_V1)
assert response.data['last_visited_block_id'] == str(self.unit.location)
class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
"""
Tests for PATCH of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
"""
def url_method(self, url, **kwargs): # pylint: disable=arguments-differ
# override implementation to use PATCH method.
return self.client.patch(url, data=kwargs.get('data', None))
def test_success(self):
self.login_and_enroll()
response = self.api_response(data={"last_visited_module_id": str(self.other_unit.location)})
assert response.data['last_visited_module_id'] == str(self.other_sub_section.location)
def test_invalid_module(self):
self.login_and_enroll()
response = self.api_response(data={"last_visited_module_id": "abc"}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODULE_ID
def test_nonexistent_module(self):
self.login_and_enroll()
non_existent_key = self.course.id.make_usage_key('video', 'non-existent')
response = self.api_response(data={"last_visited_module_id": non_existent_key}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODULE_ID
def test_no_timezone(self):
self.login_and_enroll()
past_date = datetime.datetime.now()
response = self.api_response(
data={
"last_visited_module_id": str(self.other_unit.location),
"modification_date": past_date.isoformat()
},
expected_response_code=400
)
assert response.data == errors.ERROR_INVALID_MODIFICATION_DATE
def _date_sync(self, date, initial_unit, update_unit, expected_subsection):
"""
Helper for test cases that use a modification to decide whether
to update the course status
"""
self.login_and_enroll()
# save something so we have an initial date
self.api_response(data={"last_visited_module_id": str(initial_unit.location)})
# now actually update it
response = self.api_response(
data={
"last_visited_module_id": str(update_unit.location),
"modification_date": date.isoformat()
}
)
assert response.data['last_visited_module_id'] == str(expected_subsection.location)
def test_old_date(self):
self.login_and_enroll()
date = timezone.now() + datetime.timedelta(days=-100)
self._date_sync(date, self.unit, self.other_unit, self.sub_section)
def test_new_date(self):
self.login_and_enroll()
date = timezone.now() + datetime.timedelta(days=100)
self._date_sync(date, self.unit, self.other_unit, self.other_sub_section)
def test_no_initial_date(self):
self.login_and_enroll()
response = self.api_response(
data={
"last_visited_module_id": str(self.other_unit.location),
"modification_date": timezone.now().isoformat()
}
)
assert response.data['last_visited_module_id'] == str(self.other_sub_section.location)
def test_invalid_date(self):
self.login_and_enroll()
response = self.api_response(data={"modification_date": "abc"}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODIFICATION_DATE
@ddt.ddt
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestCourseEnrollmentSerializer(MobileAPITestCase, MilestonesTestCaseMixin):
"""
Test the course enrollment serializer
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super().setUp()
self.login_and_enroll()
self.request = RequestFactory().get('/')
self.request.user = self.user
def get_serialized_data(self, api_version):
'''
Return data from CourseEnrollmentSerializer
'''
if api_version == API_V05:
serializer = CourseEnrollmentSerializerv05
else:
serializer = CourseEnrollmentSerializer
return serializer(
CourseEnrollment.enrollments_for_user(self.user)[0],
context={'request': self.request, 'api_version': api_version},
).data
def _expiration_in_response(self, response, api_version):
'''
Assert that audit_access_expires field in present in response
based on version of api being used
'''
if api_version != API_V05:
assert 'audit_access_expires' in response
else:
assert 'audit_access_expires' not in response
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
serialized = self.get_serialized_data(api_version)
assert serialized['course']['name'] == self.course.display_name
assert serialized['course']['number'] == self.course.id.course
assert serialized['course']['org'] == self.course.id.org
self._expiration_in_response(serialized, api_version)
# Assert utm parameters
qstwitter = parse_qs('utm_campaign=social-sharing-db&utm_medium=social&utm_source=twitter')
qsfacebook = parse_qs('utm_campaign=social-sharing-db&utm_medium=social&utm_source=facebook')
self.assertDictEqual(qsfacebook, parse_qs(serialized['course']['course_sharing_utm_parameters']['facebook']))
self.assertDictEqual(qstwitter, parse_qs(serialized['course']['course_sharing_utm_parameters']['twitter']))
@ddt.data(API_V05, API_V1)
def test_with_display_overrides(self, api_version):
self.course.display_coursenumber = "overridden_number"
self.course.display_organization = "overridden_org"
self.store.update_item(self.course, self.user.id)
serialized = self.get_serialized_data(api_version)
assert serialized['course']['number'] == self.course.display_coursenumber
assert serialized['course']['org'] == self.course.display_organization
self._expiration_in_response(serialized, api_version)