diff --git a/openedx/core/djangoapps/appsembler/api/sites.py b/openedx/core/djangoapps/appsembler/api/sites.py index 320ec2a1547c..540d4b24bd3c 100644 --- a/openedx/core/djangoapps/appsembler/api/sites.py +++ b/openedx/core/djangoapps/appsembler/api/sites.py @@ -1,6 +1,11 @@ +from django.contrib.auth import get_user_model from django.contrib.sites.models import Site -from organizations.models import Organization, OrganizationCourse +from organizations.models import ( + Organization, + OrganizationCourse, + UserOrganizationMapping, +) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -67,3 +72,14 @@ def course_belongs_to_site(site, course_id): def get_enrollments_for_site(site): course_keys = get_course_keys_for_site(site) return CourseEnrollment.objects.filter(course_id__in=course_keys) + + +def get_user_ids_for_site(site): + orgs = Organization.objects.filter(sites__in=[site]) + mappings = UserOrganizationMapping.objects.filter(organization__in=orgs) + return mappings.values_list('user_id', flat=True) + + +def get_users_for_site(site): + user_ids = get_user_ids_for_site(site) + return get_user_model().objects.filter(id__in=user_ids) diff --git a/openedx/core/djangoapps/appsembler/api/tests/test_enrollment_api.py b/openedx/core/djangoapps/appsembler/api/tests/test_enrollment_api.py index a4e72ceb0f67..6f3c3d6a0870 100644 --- a/openedx/core/djangoapps/appsembler/api/tests/test_enrollment_api.py +++ b/openedx/core/djangoapps/appsembler/api/tests/test_enrollment_api.py @@ -23,7 +23,9 @@ from xmodule.modulestore.tests.factories import CourseFactory from organizations.models import UserOrganizationMapping -from openedx.core.djangoapps.appsembler.api.sites import get_enrollments_for_site +from openedx.core.djangoapps.appsembler.api.sites import ( + get_enrollments_for_site, +) from openedx.core.djangoapps.appsembler.api.tests.factories import ( CourseOverviewFactory, @@ -120,6 +122,44 @@ def test_get_enrollments_for_course(self): def test_get_single_enrollment(self): pass + @ddt.data(('user_id', 'id'), ('username', 'username')) + @ddt.unpack + def test_get_enrollments_for_user(self, query_param, attr_name): + # Set up additional test data + user = UserFactory() + UserOrganizationMappingFactory(user=self.caller, + organization=self.my_site_org, + is_amc_admin=True) + courses = [CourseFactory.create() for i in range(0, 3)] + course_overviews = [] + course_enrollments = [] + for course in courses: + OrganizationCourseFactory(organization=self.my_site_org, + course_id=str(course.id)) + course_overview = CourseOverviewFactory(id=course.id) + course_enrollment = CourseEnrollmentFactory(course=course_overview, + user=user) + course_overviews.append(course_overview) + course_enrollments.append(course_enrollment) + + # Set up our request + url = reverse('tahoe-api:v1:enrollments-list') + # Need to resolve without the query parameters + view = resolve(url).func + url += '?{}={}'.format(query_param, getattr(user, attr_name)) + request = APIRequestFactory().get(url) + request.META['HTTP_HOST'] = self.my_site.domain + force_authenticate(request, user=self.caller) + response = view(request) + response.render() + results = response.data['results'] + + expected_course_ids = [str(co.id) for co in course_overviews] + found_course_ids = [obj['course_details']['course_id'] for obj in results] + assert set(found_course_ids) == set(expected_course_ids) + for result in results: + assert result['user'] == user.username + def test_invalid_enroll_data_no_learners(self): """ This does a partial test diff --git a/openedx/core/djangoapps/appsembler/api/tests/test_sites.py b/openedx/core/djangoapps/appsembler/api/tests/test_sites.py index 185ee4e2b88f..368f714ca639 100644 --- a/openedx/core/djangoapps/appsembler/api/tests/test_sites.py +++ b/openedx/core/djangoapps/appsembler/api/tests/test_sites.py @@ -24,6 +24,8 @@ SiteFactory, ) +from student.tests.factories import UserFactory + from openedx.core.djangoapps.appsembler.api import sites as aapi_sites from openedx.core.djangoapps.appsembler.api.helpers import as_course_key @@ -35,6 +37,11 @@ ) +def create_org_users(org, new_user_count): + return [UserOrganizationMappingFactory( + organization=org).user for i in xrange(new_user_count)] + + class SitesModuleTests(TestCase): def setUp(self): """ @@ -102,3 +109,17 @@ def test_course_not_belongs_to_invalid_site(self): with self.assertRaises(ValueError): aapi_sites.course_belongs_to_site(site=site, course_id=self.my_course_overviews[0]) + + def test_get_users_ids_for_site(self): + my_users = create_org_users(org=self.my_site_org, new_user_count=3) + other_users = create_org_users(org=self.other_site_org, new_user_count=2) + retrieved_user_ids = aapi_sites.get_user_ids_for_site(self.my_site) + assert set(retrieved_user_ids) == set([obj.id for obj in my_users]) + assert set(retrieved_user_ids).isdisjoint(set([obj.id for obj in other_users])) + + def test_get_users_for_site(self): + my_users = create_org_users(org=self.my_site_org, new_user_count=3) + other_users = create_org_users(org=self.other_site_org, new_user_count=2) + retrieved_users = aapi_sites.get_users_for_site(self.my_site) + assert set(retrieved_users) == set(my_users) + assert set(retrieved_users).isdisjoint(set(other_users)) diff --git a/openedx/core/djangoapps/appsembler/api/tests/test_user_api.py b/openedx/core/djangoapps/appsembler/api/tests/test_user_api.py new file mode 100644 index 000000000000..cf50d520608c --- /dev/null +++ b/openedx/core/djangoapps/appsembler/api/tests/test_user_api.py @@ -0,0 +1,110 @@ + +from unittest import skip + +from django.contrib.sites.models import Site +from django.core.urlresolvers import resolve, reverse +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from student.tests.factories import CourseEnrollmentFactory, UserFactory + +import ddt +import mock + +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory + +from openedx.core.djangoapps.appsembler.api.sites import ( + get_users_for_site, +) + +from openedx.core.djangoapps.appsembler.api.v1.serializers import UserIndexSerializer + +from openedx.core.djangoapps.appsembler.api.tests.factories import ( + CourseOverviewFactory, + OrganizationFactory, + UserOrganizationMappingFactory, +) + +APPSEMBLER_API_VIEWS_MODULE = 'openedx.core.djangoapps.appsembler.api.v1.views' + + +@ddt.ddt +@mock.patch(APPSEMBLER_API_VIEWS_MODULE + '.UserIndexViewSet.throttle_classes', []) +class UserIndexViewSetTest(TestCase): + + def setUp(self): + """ + Set up test data for site isolation + - two sites, our site and the other site + - two orgs, one per site + - set of learners in our site + - one learner in the other site + - one learner in both sites + + - caller user in our site with admin rights + + """ + super(UserIndexViewSetTest, self).setUp() + self.my_site = Site.objects.get(domain=u'example.com') + self.other_site = SiteFactory(domain='other-site.test') + self.other_site_org = OrganizationFactory(sites=[self.other_site]) + self.my_site_org = OrganizationFactory(sites=[self.my_site]) + + # Set up users and enrollments for 'my site' + self.my_site_users = [UserFactory() for i in range(3)] + for user in self.my_site_users: + UserOrganizationMappingFactory(user=user, + organization=self.my_site_org) + + self.other_site_users = [UserFactory()] + for user in self.other_site_users: + UserOrganizationMappingFactory(user=user, + organization=self.other_site_org) + + self.caller = UserFactory() + UserOrganizationMappingFactory(user=self.caller, + organization=self.my_site_org, + is_amc_admin=True) + + def test_serializer(self): + user = self.my_site_users[0] + data = UserIndexSerializer(instance=user).data + assert data['username'] == user.username + assert data['fullname'] == user.profile.name + assert data['email'] == user.email + + def test_get_all_users_for_site(self): + url = reverse('tahoe-api:v1:users-list') + request = APIRequestFactory().get(url) + request.META['HTTP_HOST'] = self.my_site.domain + force_authenticate(request, user=self.caller) + + view = resolve(url).func + response = view(request) + response.render() + results = response.data['results'] + + response_count = len(results) + expected_users = get_users_for_site(self.my_site) + + user_ids = [rec['id'] for rec in results] + assert set(user_ids) == set([obj.id for obj in expected_users]) + + @skip("Need to implement user filter") + def test_get_all_enrolled_learners_for_site(self): + + # Set up enrollment data + + url = reverse('tahoe-api:v1:users-list') + request = APIRequestFactory().get(url) + request.META['HTTP_HOST'] = self.my_site.domain + force_authenticate(request, user=self.caller) + + view = resolve(url).func + response = view(request) + response.render() + results = response.data['results'] + + response_count = len(results) + user_ids = [rec['id'] for rec in results] + assert set(user_ids) == set([obj.id for obj in self.my_site_users]) diff --git a/openedx/core/djangoapps/appsembler/api/v1/filters.py b/openedx/core/djangoapps/appsembler/api/v1/filters.py index 6c55d0e496d2..e68621905d9d 100644 --- a/openedx/core/djangoapps/appsembler/api/v1/filters.py +++ b/openedx/core/djangoapps/appsembler/api/v1/filters.py @@ -48,6 +48,8 @@ class CourseEnrollmentFilter(django_filters.FilterSet): ''' course_id = django_filters.CharFilter(method='filter_course_id') is_active = django_filters.BooleanFilter(name='is_active',) + user_id = django_filters.CharFilter(name='user__id') + username = django_filters.CharFilter(name='user__username') def filter_course_id(self, queryset, name, value): ''' @@ -65,4 +67,4 @@ def filter_course_id(self, queryset, name, value): class Meta: model = CourseEnrollment - fields = ['course_id', 'user_id', 'is_active', ] + fields = ['course_id', 'user_id', 'username', 'is_active', ] diff --git a/openedx/core/djangoapps/appsembler/api/v1/serializers.py b/openedx/core/djangoapps/appsembler/api/v1/serializers.py index ce2090c5c304..19e1d461df6f 100644 --- a/openedx/core/djangoapps/appsembler/api/v1/serializers.py +++ b/openedx/core/djangoapps/appsembler/api/v1/serializers.py @@ -1,4 +1,6 @@ +from django.contrib.auth import get_user_model + from rest_framework import serializers from opaque_keys import InvalidKeyError @@ -57,3 +59,18 @@ def validate_identifiers(self, value): 'identifiers must be a list, not a {}'.format(type(value))) # TODO: Do we want to enforce identifier type (like email, username) return value + + +class UserIndexSerializer(serializers.ModelSerializer): + """Provides a limited set of user information for summary display + """ + id = serializers.IntegerField(read_only=True) + username = serializers.CharField(read_only=True) + fullname = serializers.CharField( + source='profile.name', default=None, read_only=True) + + class Meta: + model = get_user_model() + fields = ( + 'id', 'username', 'fullname', 'email', + ) diff --git a/openedx/core/djangoapps/appsembler/api/v1/urls.py b/openedx/core/djangoapps/appsembler/api/v1/urls.py index 868d29fc2dc5..980a15a6fbf4 100644 --- a/openedx/core/djangoapps/appsembler/api/v1/urls.py +++ b/openedx/core/djangoapps/appsembler/api/v1/urls.py @@ -30,6 +30,12 @@ 'registrations', ) +router.register( + r'users', + views.UserIndexViewSet, + 'users', +) + urlpatterns = [ url(r'', include(router.urls, )), ] diff --git a/openedx/core/djangoapps/appsembler/api/v1/views.py b/openedx/core/djangoapps/appsembler/api/v1/views.py index 31d393687d77..8ad3dae02e91 100644 --- a/openedx/core/djangoapps/appsembler/api/v1/views.py +++ b/openedx/core/djangoapps/appsembler/api/v1/views.py @@ -9,7 +9,7 @@ import random import string - +from django.contrib.auth import get_user_model from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.conf import settings from django.db import transaction @@ -56,7 +56,9 @@ TahoeLimitOffsetPagination ) from openedx.core.djangoapps.appsembler.api.v1.serializers import ( - CourseOverviewSerializer, BulkEnrollmentSerializer + CourseOverviewSerializer, + BulkEnrollmentSerializer, + UserIndexSerializer, ) # TODO: Just move into v1 directory @@ -68,6 +70,7 @@ get_site_for_course, get_enrollments_for_site, course_belongs_to_site, + get_users_for_site, ) @@ -357,3 +360,27 @@ def create(self, request, *args, **kwargs): response_code = status.HTTP_400_BAD_REQUEST return Response(response_data, status=response_code) + + +class UserIndexViewSet(TahoeAuthMixin, viewsets.ReadOnlyModelViewSet): + """Provides course information + + To provide data for all learners on your site:: + + GET /tahoe/api/v1/users/ + + To provide details on a specific learner: + + GET /tahoe/api/v1/users// + + """ + model = get_user_model() + pagination_class = TahoeLimitOffsetPagination + serializer_class = UserIndexSerializer + throttle_classes = (TahoeAPIUserThrottle,) + filter_backends = (DjangoFilterBackend, ) + + def get_queryset(self): + site = django.contrib.sites.shortcuts.get_current_site(self.request) + queryset = get_users_for_site(site) + return queryset