EDUCATOR-4539 update program access so global staff and course staff related cases can be handled

This commit is contained in:
Simon Chen
2019-07-30 16:13:25 -04:00
committed by Alex Dusenbery
parent 16a62de344
commit 1b9cba5cf1
3 changed files with 208 additions and 43 deletions

View File

@@ -3,9 +3,9 @@ Unit tests for ProgramEnrollment views.
"""
from __future__ import absolute_import, unicode_literals
from datetime import datetime, timedelta
import json
from uuid import uuid4
from datetime import datetime, timedelta
from uuid import UUID, uuid4
import ddt
import mock
@@ -14,6 +14,7 @@ from django.core.cache import cache
from django.urls import reverse
from freezegun import freeze_time
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
@@ -21,18 +22,15 @@ from six.moves import range, zip
from bulk_email.models import BulkEmailFlag, Optout
from course_modes.models import CourseMode
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
from lms.djangoapps.program_enrollments.api.v1.constants import (
CourseEnrollmentResponseStatuses as CourseStatuses,
CourseRunProgressStatuses,
MAX_ENROLLMENT_RECORDS,
ProgramEnrollmentResponseStatuses as ProgramStatuses,
REQUEST_STUDENT_KEY,
)
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory
from lms.djangoapps.program_enrollments.api.v1.constants import MAX_ENROLLMENT_RECORDS, REQUEST_STUDENT_KEY
from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses
from lms.djangoapps.program_enrollments.api.v1.constants import CourseRunProgressStatuses
from lms.djangoapps.program_enrollments.api.v1.constants import ProgramEnrollmentResponseStatuses as ProgramStatuses
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment
from lms.djangoapps.program_enrollments.utils import ProviderDoesNotExistException
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory
@@ -41,9 +39,11 @@ from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangolib.testing.utils import CacheIsolationMixin
from student.roles import CourseStaffRole
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCourseFactory
from xmodule.modulestore.tests.factories import ItemFactory
class ListViewTestMixin(object):
@@ -81,35 +81,121 @@ class ListViewTestMixin(object):
return reverse(self.view_name, kwargs=kwargs)
class LearnerProgramEnrollmentTest(ListViewTestMixin, APITestCase):
@ddt.ddt
class UserProgramReadOnlyAccessViewTest(ListViewTestMixin, APITestCase):
"""
Tests for the LearnerProgramEnrollment view class
Tests for the UserProgramReadonlyAccess view class
"""
view_name = 'programs_api:v1:learner_program_enrollments'
view_name = 'programs_api:v1:user_program_readonly_access'
@classmethod
def setUpClass(cls):
super(UserProgramReadOnlyAccessViewTest, cls).setUpClass()
cls.mock_program_data = [
{'uuid': cls.program_uuid_tmpl.format(11), 'marketing_slug': 'garbage-program', 'type': 'masters'},
{'uuid': cls.program_uuid_tmpl.format(22), 'marketing_slug': 'garbage-study', 'type': 'micromaster'},
{'uuid': cls.program_uuid_tmpl.format(33), 'marketing_slug': 'garbage-life', 'type': 'masters'},
]
cls.course_staff = InstructorFactory.create(password=cls.password, course_key=cls.course_id)
cls.date = datetime(2013, 1, 22, tzinfo=UTC)
CourseEnrollmentFactory(
course_id=cls.course_id,
user=cls.course_staff,
created=cls.date,
)
def test_401_if_anonymous(self):
response = self.client.get(reverse(self.view_name))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
@ddt.data(
('masters', 2),
('micromaster', 1)
)
@ddt.unpack
def test_global_staff(self, program_type, expected_data_size):
self.client.login(username=self.global_staff.username, password=self.password)
mock_return_value = [program for program in self.mock_program_data if program['type'] == program_type]
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs_by_type',
autospec=True,
return_value=mock_return_value
) as mock_get_programs_by_type:
response = self.client.get(reverse(self.view_name) + '?type=' + program_type)
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == expected_data_size
mock_get_programs_by_type.assert_called_once_with(response.wsgi_request.site, program_type)
def test_course_staff(self):
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
return_value=[self.mock_program_data[0]]
) as mock_get_programs:
response = self.client.get(reverse(self.view_name) + '?type=masters')
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 1
mock_get_programs.assert_called_once_with(course=self.course_id)
def test_course_staff_of_multiple_courses(self):
other_course_key = CourseKey.from_string('course-v1:edX+ToyX+Other_Course')
CourseEnrollmentFactory.create(course_id=other_course_key, user=self.course_staff)
CourseStaffRole(other_course_key).add_users(self.course_staff)
self.client.login(username=self.course_staff.username, password=self.password)
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
) as mock_get_programs:
response = self.client.get(reverse(self.view_name) + '?type=masters')
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 2
mock_get_programs.assert_has_calls([
mock.call(course=self.course_id),
mock.call(course=other_course_key),
])
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
def test_200_if_no_programs_enrolled(self, mock_get_programs):
def test_learner_200_if_no_programs_enrolled(self, mock_get_programs):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(reverse(self.view_name))
assert status.HTTP_200_OK == response.status_code
assert response.data == []
assert mock_get_programs.call_count == 1
mock_get_programs.assert_called_once_with(uuids=[])
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=[
{'uuid': 'boop', 'marketing_slug': 'garbage-program'},
{'uuid': 'boop-boop', 'marketing_slug': 'garbage-study'},
{'uuid': 'boop-boop-boop', 'marketing_slug': 'garbage-life'},
])
def test_200_many_programs(self, mock_get_programs):
def test_learner_200_many_programs(self):
for program in self.mock_program_data:
ProgramEnrollmentFactory.create(
program_uuid=program['uuid'],
curriculum_uuid=self.curriculum_uuid,
user=self.student,
status='pending',
external_user_key='user-{}'.format(self.student.id),
)
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(reverse(self.view_name))
with mock.patch(
'lms.djangoapps.program_enrollments.api.v1.views.get_programs',
autospec=True,
return_value=self.mock_program_data
) as mock_get_programs:
response = self.client.get(reverse(self.view_name))
assert status.HTTP_200_OK == response.status_code
assert len(response.data) == 3
assert mock_get_programs.call_count == 1
mock_get_programs.assert_called_once_with(uuids=[UUID(item['uuid']) for item in self.mock_program_data])
class ProgramEnrollmentListTest(ListViewTestMixin, APITestCase):

View File

@@ -8,7 +8,7 @@ from lms.djangoapps.program_enrollments.api.v1.views import (
ProgramEnrollmentsView,
ProgramCourseEnrollmentsView,
ProgramCourseEnrollmentOverviewView,
LearnerProgramEnrollmentsView,
UserProgramReadOnlyAccessView,
)
from openedx.core.constants import COURSE_ID_PATTERN
@@ -17,9 +17,14 @@ app_name = 'lms.djangoapps.program_enrollments'
urlpatterns = [
url(
r'^programs/enrollments/$',
LearnerProgramEnrollmentsView.as_view(),
UserProgramReadOnlyAccessView.as_view(),
name='learner_program_enrollments'
),
url(
r'^programs/readonly_access/$',
UserProgramReadOnlyAccessView.as_view(),
name='user_program_readonly_access'
),
url(
r'^programs/{program_uuid}/enrollments/$'.format(program_uuid=PROGRAM_UUID_PATTERN),
ProgramEnrollmentsView.as_view(),

View File

@@ -26,6 +26,7 @@ from rest_framework.response import Response
from six import iteritems
from ccx_keys.locator import CCXLocator
from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
from course_modes.models import CourseMode
from edx_when.api import get_dates_for_course
@@ -48,8 +49,14 @@ from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, P
from lms.djangoapps.program_enrollments.utils import get_user_by_program_id, ProviderDoesNotExistException
from student.helpers import get_resume_urls_for_enrollments
from student.models import CourseEnrollment
from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole
from xmodule.modulestore.django import modulestore
from openedx.core.djangoapps.catalog.utils import get_programs, course_run_keys_for_program
from openedx.core.djangoapps.catalog.utils import (
course_run_keys_for_program,
get_programs,
get_programs_by_type,
normalize_program_type,
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, verify_course_exists
@@ -513,18 +520,29 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
)
class LearnerProgramEnrollmentsView(DeveloperErrorViewMixin, APIView):
class UserProgramReadOnlyAccessView(DeveloperErrorViewMixin, PaginatedAPIView):
"""
A view for checking the currently logged-in learner's program enrollments
A view for checking the currently logged-in user's program read only access
There are three major categories of users this API is differentiating. See the table below.
--------------------------------------------------------------------------------------------
| User Type | API Returns |
--------------------------------------------------------------------------------------------
| edX staff | All programs |
--------------------------------------------------------------------------------------------
| course staff | All programs containing the courses of which the user is course staff |
--------------------------------------------------------------------------------------------
| learner | All programs the learner is enrolled in |
--------------------------------------------------------------------------------------------
Path: `/api/program_enrollments/v1/programs/enrollments/`
Returns:
* 200: OK - Contains a list of all programs in which the learner is enrolled.
* 200: OK - Contains a list of all programs in which the user has read only acccess to.
* 401: The requesting user is not authenticated.
The list will be a list of objects with the following keys:
* `uuid` - the identifier of the program in which the learner is enrolled.
* `uuid` - the identifier of the program in which the user has read only access to.
* `slug` - the string from which a link to the corresponding program page can be constructed.
Example:
@@ -546,23 +564,79 @@ class LearnerProgramEnrollmentsView(DeveloperErrorViewMixin, APIView):
)
permission_classes = (IsAuthenticated,)
DEFAULT_PROGRAM_TYPE = 'masters'
def get(self, request):
"""
How to respond to a GET request to this endpoint
"""
program_enrollments = ProgramEnrollment.objects.filter(
user=request.user,
status__in=('enrolled', 'pending')
)
uuids = [enrollment.program_uuid for enrollment in program_enrollments]
request_user = request.user
catalog_data_of_programs = get_programs(uuids=uuids) or []
programs_in_which_learner_is_enrolled = [{'uuid': program['uuid'], 'slug': program['marketing_slug']}
for program
in catalog_data_of_programs]
programs = []
requested_program_type = normalize_program_type(request.GET.get('type', self.DEFAULT_PROGRAM_TYPE))
return Response(programs_in_which_learner_is_enrolled, status.HTTP_200_OK)
if request_user.is_staff:
programs = get_programs_by_type(request.site, requested_program_type)
elif self.is_course_staff(request_user):
programs = self.get_programs_user_is_course_staff_for(request_user, requested_program_type)
else:
program_enrollments = ProgramEnrollment.objects.filter(
user=request.user,
status__in=('enrolled', 'pending')
)
uuids = [enrollment.program_uuid for enrollment in program_enrollments]
programs = get_programs(uuids=uuids) or []
programs_in_which_user_has_access = [
{'uuid': program['uuid'], 'slug': program['marketing_slug']}
for program in programs
]
return Response(programs_in_which_user_has_access, status.HTTP_200_OK)
def is_course_staff(self, user):
"""
Returns true if the user is a course_staff member of any course within a program
"""
staff_course_keys = self.get_course_keys_user_is_staff_for(user)
return len(staff_course_keys)
def get_course_keys_user_is_staff_for(self, user):
"""
Return all the course keys the user is course instructor or course staff role for
"""
# Get all the courses of which the user is course staff for. If None, return false
def filter_ccx(course_access):
""" CCXs cannot be edited in Studio and should not be filtered """
return not isinstance(course_access.course_id, CCXLocator)
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
all_courses = list(filter(filter_ccx, instructor_courses | staff_courses))
course_keys = {}
for course_access in all_courses:
if course_access.course_id is not None:
course_keys[course_access.course_id] = course_access.course_id
return list(course_keys.values())
def get_programs_user_is_course_staff_for(self, user, program_type_filter):
"""
Return a list of programs the user is course staff for.
This function would take a list of course runs the user is staff of, and then
try to get the Masters program associated with each course_runs.
"""
program_list = []
for course_key in self.get_course_keys_user_is_staff_for(user):
course_run_programs = get_programs(course=course_key)
for course_run_program in course_run_programs:
if course_run_program and course_run_program.get('type').lower() == program_type_filter:
program_list.append(course_run_program)
return program_list
class ProgramSpecificViewMixin(object):