EDUCATOR-4539 update program access so global staff and course staff related cases can be handled
This commit is contained in:
committed by
Alex Dusenbery
parent
16a62de344
commit
1b9cba5cf1
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user