From c375f666d28dbc03df85aa6d22e8ed8926871330 Mon Sep 17 00:00:00 2001 From: Lee Newton Date: Tue, 24 Feb 2015 15:06:42 -0500 Subject: [PATCH] bnotions contributions to mobile api --- .../mobile_api/social_facebook/__init__.py | 42 +++ .../social_facebook/courses/__init__.py | 3 + .../social_facebook/courses/models.py | 3 + .../social_facebook/courses/serializers.py | 11 + .../social_facebook/courses/tests.py | 147 ++++++++ .../social_facebook/courses/urls.py | 15 + .../social_facebook/courses/views.py | 64 ++++ .../social_facebook/friends/__init__.py | 3 + .../social_facebook/friends/models.py | 3 + .../social_facebook/friends/serializers.py | 11 + .../social_facebook/friends/tests.py | 318 ++++++++++++++++++ .../social_facebook/friends/urls.py | 16 + .../social_facebook/friends/views.py | 71 ++++ .../social_facebook/groups/__init__.py | 3 + .../social_facebook/groups/models.py | 3 + .../social_facebook/groups/serializers.py | 30 ++ .../social_facebook/groups/tests.py | 199 +++++++++++ .../mobile_api/social_facebook/groups/urls.py | 20 ++ .../social_facebook/groups/views.py | 143 ++++++++ .../mobile_api/social_facebook/models.py | 3 + .../social_facebook/preferences/__init__.py | 3 + .../social_facebook/preferences/models.py | 3 + .../preferences/serializers.py | 11 + .../social_facebook/preferences/tests.py | 68 ++++ .../social_facebook/preferences/urls.py | 14 + .../social_facebook/preferences/views.py | 52 +++ .../mobile_api/social_facebook/test_utils.py | 184 ++++++++++ .../mobile_api/social_facebook/urls.py | 11 + .../mobile_api/social_facebook/utils.py | 68 ++++ lms/djangoapps/mobile_api/urls.py | 8 +- lms/envs/aws.py | 5 + lms/envs/common.py | 1 + lms/envs/test.py | 5 + requirements/edx/base.txt | 1 + 34 files changed, 1541 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/mobile_api/social_facebook/__init__.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/__init__.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/models.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/serializers.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/tests.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/urls.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/courses/views.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/__init__.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/models.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/serializers.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/tests.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/urls.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/friends/views.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/__init__.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/models.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/serializers.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/tests.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/urls.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/groups/views.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/models.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/__init__.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/models.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/serializers.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/tests.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/urls.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/preferences/views.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/test_utils.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/urls.py create mode 100644 lms/djangoapps/mobile_api/social_facebook/utils.py diff --git a/lms/djangoapps/mobile_api/social_facebook/__init__.py b/lms/djangoapps/mobile_api/social_facebook/__init__.py new file mode 100644 index 0000000000..3a45c5076a --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/__init__.py @@ -0,0 +1,42 @@ +""" +Social Facebook API +""" + +# TODO +# There are still some performance and scalability issues that should be +# addressed for the various endpoints in this social_facebook djangoapp. +# +# For the Courses and Friends API: +# For both endpoints, we are retrieving the same data from the Facebook server. +# We are then simply organizing and filtering that data differently for each endpoint. +# +# Here are 3 ideas that can be explored further: +# +# Option 1. The app can just call one endpoint that provides a mapping between CourseIDs and Friends, +# and then cache that data once. The reverse map from Friends to CourseIDs can then be created on the app side. +# +# Option 2. The app once again calls just one endpoint (since the same data is computed for both), +# and caches the data once. The difference from #1 is that the server does the computation of the reverse-map and +# sends both maps down to the client. It's a tradeoff between bandwidth and client-side computation. So the payload +# could be something like: +# +# { +# courses: [ +# {course_id: "c/ourse/1", friend_indices: [1, 2, 3]}, +# {course_id: "c/ourse/2", friend_indices: [3, 4, 5]}, +# .. +# ], +# friends: [ +# {username: "friend1", facebook_id: "xxx", course_indices: [2, 7, 9]}, +# {username: "friend2", facebook_id: "yyy", course_indices: [1, 4, 3]}, +# ... +# ] +# } +# +# Option 3. Alternatively, continue to have separate endpoints, but have both endpoints call the same underlying method +# with a built-in cache. +# +# All 3 options can make use of a common cache of results from FB. +# +# At a minimum, some performance/load testing would need to be done +# so we have an idea of these endpoints' limitations and thresholds. diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/__init__.py b/lms/djangoapps/mobile_api/social_facebook/courses/__init__.py new file mode 100644 index 0000000000..738aa67827 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/__init__.py @@ -0,0 +1,3 @@ +""" +Courses API +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/models.py b/lms/djangoapps/mobile_api/social_facebook/courses/models.py new file mode 100644 index 0000000000..d2e8572729 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/models.py @@ -0,0 +1,3 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/serializers.py b/lms/djangoapps/mobile_api/social_facebook/courses/serializers.py new file mode 100644 index 0000000000..bccd9c3fd9 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/serializers.py @@ -0,0 +1,11 @@ +""" +Serializer for courses API +""" +from rest_framework import serializers + + +class CoursesWithFriendsSerializer(serializers.Serializer): + """ + Serializes oauth token for facebook groups request + """ + oauth_token = serializers.CharField(required=True) diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/tests.py b/lms/djangoapps/mobile_api/social_facebook/courses/tests.py new file mode 100644 index 0000000000..26155b33eb --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/tests.py @@ -0,0 +1,147 @@ +# pylint: disable=E1101, W0201 +""" +Tests for Courses +""" +import httpretty +import json +from django.core.urlresolvers import reverse + +from xmodule.modulestore.tests.factories import CourseFactory +from opaque_keys.edx.keys import CourseKey +from ..test_utils import SocialFacebookTestCase + + +class TestCourses(SocialFacebookTestCase): + """ + Tests for /api/mobile/v0.5/courses/... + """ + def setUp(self): + super(TestCourses, self).setUp() + self.course = CourseFactory.create(mobile_available=True) + + @httpretty.activate + def test_one_course_with_friends(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + self.set_facebook_interceptor_for_friends( + {'data': [{'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}]} + ) + self.enroll_in_course(self.users[1], self.course) + url = reverse('courses-with-friends') + response = self.client.get(url, {'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.course.id, CourseKey.from_string(response.data[0]['course']['id'])) # pylint: disable=E1101 + + @httpretty.activate + def test_two_courses_with_friends(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + self.enroll_in_course(self.users[1], self.course) + self.course_2 = CourseFactory.create(mobile_available=True) + self.enroll_in_course(self.users[1], self.course_2) + self.set_facebook_interceptor_for_friends( + {'data': [{'name': self.USERS[2]['USERNAME'], 'id': self.USERS[1]['FB_ID']}]} + ) + url = reverse('courses-with-friends') + response = self.client.get(url, {'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.course.id, CourseKey.from_string(response.data[0]['course']['id'])) # pylint: disable=E1101 + self.assertEqual(self.course_2.id, CourseKey.from_string(response.data[1]['course']['id'])) # pylint: disable=E1101 + + @httpretty.activate + def test_three_courses_but_only_two_unique(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + self.course_2 = CourseFactory.create(mobile_available=True) + self.enroll_in_course(self.users[1], self.course_2) + self.enroll_in_course(self.users[1], self.course) + self.user_create_and_signin(2) + self.link_edx_account_to_social(self.users[2], self.BACKEND, self.USERS[2]['FB_ID']) + self.set_sharing_preferences(self.users[2], True) + # Enroll another user in course_2 + self.enroll_in_course(self.users[2], self.course_2) + self.set_facebook_interceptor_for_friends( + {'data': [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + ]} + ) + url = reverse('courses-with-friends') + response = self.client.get(url, {'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.course.id, CourseKey.from_string(response.data[0]['course']['id'])) # pylint: disable=E1101 + self.assertEqual(self.course_2.id, CourseKey.from_string(response.data[1]['course']['id'])) # pylint: disable=E1101 + # Assert that only two courses are returned + self.assertEqual(len(response.data), 2) # pylint: disable=E1101 + + @httpretty.activate + def test_two_courses_with_two_friends_on_different_paged_results(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + self.enroll_in_course(self.users[1], self.course) + + self.user_create_and_signin(2) + self.link_edx_account_to_social(self.users[2], self.BACKEND, self.USERS[2]['FB_ID']) + self.set_sharing_preferences(self.users[2], True) + self.course_2 = CourseFactory.create(mobile_available=True) + self.enroll_in_course(self.users[2], self.course_2) + self.set_facebook_interceptor_for_friends( + { + 'data': [{'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}], + "paging": {"next": "https://graph.facebook.com/v2.2/me/friends/next"}, + "summary": {"total_count": 652} + } + ) + # Set the interceptor for the paged + httpretty.register_uri( + httpretty.GET, + "https://graph.facebook.com/v2.2/me/friends/next", + body=json.dumps( + { + "data": [{'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}], + "paging": { + "previous": + "https://graph.facebook.com/v2.2/10154805434030300/friends?limit=25&offset=25" + }, + "summary": {"total_count": 652} + } + ), + status=201 + ) + + url = reverse('courses-with-friends') + response = self.client.get(url, {'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.course.id, CourseKey.from_string(response.data[0]['course']['id'])) # pylint: disable=E1101 + self.assertEqual(self.course_2.id, CourseKey.from_string(response.data[1]['course']['id'])) # pylint: disable=E1101 + + @httpretty.activate + def test_no_courses_with_friends_because_sharing_pref_off(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], False) + self.set_facebook_interceptor_for_friends( + {'data': [{'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}]} + ) + self.enroll_in_course(self.users[1], self.course) + url = reverse('courses-with-friends') + response = self.client.get(url, {'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 0) + + @httpretty.activate + def test_no_courses_with_friends_because_no_auth_token(self): + self.user_create_and_signin(1) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], False) + self.set_facebook_interceptor_for_friends( + {'data': [{'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}]} + ) + self.enroll_in_course(self.users[1], self.course) + url = reverse('courses-with-friends') + response = self.client.get(url) + self.assertEqual(response.status_code, 400) diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/urls.py b/lms/djangoapps/mobile_api/social_facebook/courses/urls.py new file mode 100644 index 0000000000..8e0b93093c --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/urls.py @@ -0,0 +1,15 @@ +""" +URLs for courses API +""" +from django.conf.urls import patterns, url + +from .views import CoursesWithFriends + +urlpatterns = patterns( + 'mobile_api.social_facebook.courses.views', + url( + r'^friends$', + CoursesWithFriends.as_view(), + name='courses-with-friends' + ), +) diff --git a/lms/djangoapps/mobile_api/social_facebook/courses/views.py b/lms/djangoapps/mobile_api/social_facebook/courses/views.py new file mode 100644 index 0000000000..08693b3e01 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/courses/views.py @@ -0,0 +1,64 @@ +""" + Views for courses info API +""" + +from rest_framework import generics, status +from rest_framework.response import Response +from courseware.access import is_mobile_available_for_user +from student.models import CourseEnrollment +from lms.djangoapps.mobile_api.social_facebook.courses import serializers +from ...users.serializers import CourseEnrollmentSerializer +from ...utils import mobile_view +from ..utils import get_friends_from_facebook, get_linked_edx_accounts, share_with_facebook_friends + + +@mobile_view() +class CoursesWithFriends(generics.ListAPIView): + """ + **Use Case** + + API endpoint for retrieving all the courses that a user's friends are in. + Note that only friends that allow their courses to be shared will be included. + + **Example request** + + GET /api/mobile/v0.5/social/facebook/courses/friends + + **Response Values** + + See UserCourseEnrollmentsList in lms/djangoapps/mobile_api/users for the structure of the response values. + """ + serializer_class = serializers.CoursesWithFriendsSerializer + + def list(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.GET, files=request.FILES) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Get friends from Facebook + result = get_friends_from_facebook(serializer) + if type(result) != list: + return result + + friends_that_are_edx_users = get_linked_edx_accounts(result) + + # Filter by sharing preferences + users_with_sharing = [ + friend for friend in friends_that_are_edx_users if share_with_facebook_friends(friend) + ] + + # Get unique enrollments + enrollments = [] + for friend in users_with_sharing: + query_set = CourseEnrollment.objects.filter( + user_id=friend['edX_id'] + ).exclude(course_id__in=[enrollment.course_id for enrollment in enrollments]) + enrollments.extend(query_set) + + # Get course objects + courses = [ + enrollment for enrollment in enrollments if enrollment.course + and is_mobile_available_for_user(self.request.user, enrollment.course) + ] + + return Response(CourseEnrollmentSerializer(courses, context={'request': request}).data) diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/__init__.py b/lms/djangoapps/mobile_api/social_facebook/friends/__init__.py new file mode 100644 index 0000000000..378fd53a74 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/__init__.py @@ -0,0 +1,3 @@ +""" +Friends API +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/models.py b/lms/djangoapps/mobile_api/social_facebook/friends/models.py new file mode 100644 index 0000000000..d2e8572729 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/models.py @@ -0,0 +1,3 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/serializers.py b/lms/djangoapps/mobile_api/social_facebook/friends/serializers.py new file mode 100644 index 0000000000..449bbbced3 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/serializers.py @@ -0,0 +1,11 @@ +""" +Serializer for Friends API +""" +from rest_framework import serializers + + +class FriendsInCourseSerializer(serializers.Serializer): + """ + Serializes oauth token for facebook groups request + """ + oauth_token = serializers.CharField(required=True) diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/tests.py b/lms/djangoapps/mobile_api/social_facebook/friends/tests.py new file mode 100644 index 0000000000..3db80aae6f --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/tests.py @@ -0,0 +1,318 @@ +# pylint: disable=E1101 +""" +Tests for friends +""" +import json +import httpretty +from django.core.urlresolvers import reverse +from xmodule.modulestore.tests.factories import CourseFactory +from ..test_utils import SocialFacebookTestCase + + +class TestFriends(SocialFacebookTestCase): + """ + Tests for /api/mobile/v0.5/friends/... + """ + + def setUp(self): + super(TestFriends, self).setUp() + self.course = CourseFactory.create() + + @httpretty.activate + def test_no_friends_enrolled(self): + # User 1 set up + self.user_create_and_signin(1) + # Link user_1's edX account to FB + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + # Set the interceptor + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + course_id = unicode(self.course.id) + url = reverse('friends-in-course', kwargs={"course_id": course_id}) + response = self.client.get(url, {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN}) + # Assert that no friends are returned + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data and len(response.data['friends']) == 0) + + @httpretty.activate + def test_no_friends_on_facebook(self): + # User 1 set up + self.user_create_and_signin(1) + # Enroll user_1 in the course + self.enroll_in_course(self.users[1], self.course) + self.set_sharing_preferences(self.users[1], True) + # Link user_1's edX account to FB + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + # Set the interceptor + self.set_facebook_interceptor_for_friends({'data': []}) + course_id = unicode(self.course.id) + url = reverse('friends-in-course', kwargs={"course_id": course_id}) + response = self.client.get( + url, {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN} + ) + # Assert that no friends are returned + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data and len(response.data['friends']) == 0) + + @httpretty.activate + def test_no_friends_linked_to_edx(self): + # User 1 set up + self.user_create_and_signin(1) + # Enroll user_1 in the course + self.enroll_in_course(self.users[1], self.course) + self.set_sharing_preferences(self.users[1], True) + # User 2 set up + self.user_create_and_signin(2) + # Enroll user_2 in the course + self.enroll_in_course(self.users[2], self.course) + self.set_sharing_preferences(self.users[2], True) + # User 3 set up + self.user_create_and_signin(3) + # Enroll user_3 in the course + self.enroll_in_course(self.users[3], self.course) + self.set_sharing_preferences(self.users[3], True) + + # Set the interceptor + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + course_id = unicode(self.course.id) + url = reverse('friends-in-course', kwargs={"course_id": course_id}) + response = self.client.get( + url, + {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN} + ) + # Assert that no friends are returned + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data and len(response.data['friends']) == 0) + + @httpretty.activate + def test_no_friends_share_settings_false(self): + # User 1 set up + self.user_create_and_signin(1) + self.enroll_in_course(self.users[1], self.course) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], False) + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + url = reverse('friends-in-course', kwargs={"course_id": unicode(self.course.id)}) + response = self.client.get(url, {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN}) + + # Assert that USERNAME_1 is returned + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data) + self.assertTrue('friends' in response.data and len(response.data['friends']) == 0) + + @httpretty.activate + def test_no_friends_no_oauth_token(self): + # User 1 set up + self.user_create_and_signin(1) + self.enroll_in_course(self.users[1], self.course) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], False) + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + url = reverse('friends-in-course', kwargs={"course_id": unicode(self.course.id)}) + response = self.client.get(url, {'format': 'json'}) + # Assert that USERNAME_1 is returned + self.assertEqual(response.status_code, 400) + + @httpretty.activate + def test_one_friend_in_course(self): + # User 1 set up + self.user_create_and_signin(1) + self.enroll_in_course(self.users[1], self.course) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + url = reverse('friends-in-course', kwargs={"course_id": unicode(self.course.id)}) + response = self.client.get(url, {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN}) + + # Assert that USERNAME_1 is returned + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data) + self.assertTrue('id' in response.data['friends'][0]) + self.assertTrue(response.data['friends'][0]['id'] == self.USERS[1]['FB_ID']) + self.assertTrue('name' in response.data['friends'][0]) + self.assertTrue(response.data['friends'][0]['name'] == self.USERS[1]['USERNAME']) + + @httpretty.activate + def test_three_friends_in_course(self): + # User 1 set up + self.user_create_and_signin(1) + self.enroll_in_course(self.users[1], self.course) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + + # User 2 set up + self.user_create_and_signin(2) + self.enroll_in_course(self.users[2], self.course) + self.link_edx_account_to_social(self.users[2], self.BACKEND, self.USERS[2]['FB_ID']) + self.set_sharing_preferences(self.users[2], True) + + # User 3 set up + self.user_create_and_signin(3) + self.enroll_in_course(self.users[3], self.course) + self.link_edx_account_to_social(self.users[3], self.BACKEND, self.USERS[3]['FB_ID']) + self.set_sharing_preferences(self.users[3], True) + self.set_facebook_interceptor_for_friends( + { + 'data': + [ + {'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}, + {'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}, + {'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}, + ] + } + ) + url = reverse('friends-in-course', kwargs={"course_id": unicode(self.course.id)}) + response = self.client.get( + url, + {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN} + ) + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data) + # Assert that USERNAME_1 is returned + self.assertTrue( + 'id' in response.data['friends'][0] and + response.data['friends'][0]['id'] == self.USERS[1]['FB_ID'] + ) + self.assertTrue( + 'name' in response.data['friends'][0] and + response.data['friends'][0]['name'] == self.USERS[1]['USERNAME'] + ) + # Assert that USERNAME_2 is returned + self.assertTrue( + 'id' in response.data['friends'][1] and + response.data['friends'][1]['id'] == self.USERS[2]['FB_ID'] + ) + self.assertTrue( + 'name' in response.data['friends'][1] and + response.data['friends'][1]['name'] == self.USERS[2]['USERNAME'] + ) + # Assert that USERNAME_3 is returned + self.assertTrue( + 'id' in response.data['friends'][2] and + response.data['friends'][2]['id'] == self.USERS[3]['FB_ID'] + ) + self.assertTrue( + 'name' in response.data['friends'][2] and + response.data['friends'][2]['name'] == self.USERS[3]['USERNAME'] + ) + + @httpretty.activate + def test_three_friends_in_paged_response(self): + # User 1 set up + self.user_create_and_signin(1) + self.enroll_in_course(self.users[1], self.course) + self.link_edx_account_to_social(self.users[1], self.BACKEND, self.USERS[1]['FB_ID']) + self.set_sharing_preferences(self.users[1], True) + + # User 2 set up + self.user_create_and_signin(2) + self.enroll_in_course(self.users[2], self.course) + self.link_edx_account_to_social(self.users[2], self.BACKEND, self.USERS[2]['FB_ID']) + self.set_sharing_preferences(self.users[2], True) + + # User 3 set up + self.user_create_and_signin(3) + self.enroll_in_course(self.users[3], self.course) + self.link_edx_account_to_social(self.users[3], self.BACKEND, self.USERS[3]['FB_ID']) + self.set_sharing_preferences(self.users[3], True) + + self.set_facebook_interceptor_for_friends( + { + 'data': [{'name': self.USERS[1]['USERNAME'], 'id': self.USERS[1]['FB_ID']}], + "paging": {"next": "https://graph.facebook.com/v2.2/me/friends/next_1"}, + "summary": {"total_count": 652} + } + ) + # Set the interceptor for the first paged content + httpretty.register_uri( + httpretty.GET, + "https://graph.facebook.com/v2.2/me/friends/next_1", + body=json.dumps( + { + "data": [{'name': self.USERS[2]['USERNAME'], 'id': self.USERS[2]['FB_ID']}], + "paging": {"next": "https://graph.facebook.com/v2.2/me/friends/next_2"}, + "summary": {"total_count": 652} + } + ), + status=201 + ) + # Set the interceptor for the last paged content + httpretty.register_uri( + httpretty.GET, + "https://graph.facebook.com/v2.2/me/friends/next_2", + body=json.dumps( + { + "data": [{'name': self.USERS[3]['USERNAME'], 'id': self.USERS[3]['FB_ID']}], + "paging": { + "previous": + "https://graph.facebook.com/v2.2/10154805434030300/friends?limit=25&offset=25" + }, + "summary": {"total_count": 652} + } + ), + status=201 + ) + url = reverse('friends-in-course', kwargs={"course_id": unicode(self.course.id)}) + response = self.client.get(url, {'format': 'json', 'oauth_token': self._FB_USER_ACCESS_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertTrue('friends' in response.data) + # Assert that USERNAME_1 is returned + self.assertTrue('id' in response.data['friends'][0]) + self.assertTrue(response.data['friends'][0]['id'] == self.USERS[1]['FB_ID']) + self.assertTrue('name' in response.data['friends'][0]) + self.assertTrue(response.data['friends'][0]['name'] == self.USERS[1]['USERNAME']) + # Assert that USERNAME_2 is returned + self.assertTrue('id' in response.data['friends'][1]) + self.assertTrue(response.data['friends'][1]['id'] == self.USERS[2]['FB_ID']) + self.assertTrue('name' in response.data['friends'][1]) + self.assertTrue(response.data['friends'][1]['name'] == self.USERS[2]['USERNAME']) + # Assert that USERNAME_3 is returned + self.assertTrue('id' in response.data['friends'][2]) + self.assertTrue(response.data['friends'][2]['id'] == self.USERS[3]['FB_ID']) + self.assertTrue('name' in response.data['friends'][2]) + self.assertTrue(response.data['friends'][2]['name'] == self.USERS[3]['USERNAME']) diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/urls.py b/lms/djangoapps/mobile_api/social_facebook/friends/urls.py new file mode 100644 index 0000000000..e6e5f9141c --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/urls.py @@ -0,0 +1,16 @@ +""" +URLs for friends API +""" +from django.conf.urls import patterns, url +from django.conf import settings + +from .views import FriendsInCourse + +urlpatterns = patterns( + 'mobile_api.social_facebook.friends.views', + url( + r'^course/{}$'.format(settings.COURSE_ID_PATTERN), + FriendsInCourse.as_view(), + name='friends-in-course' + ), +) diff --git a/lms/djangoapps/mobile_api/social_facebook/friends/views.py b/lms/djangoapps/mobile_api/social_facebook/friends/views.py new file mode 100644 index 0000000000..ba864dbed1 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/friends/views.py @@ -0,0 +1,71 @@ +""" + Views for friends info API +""" + +from rest_framework import generics, status +from rest_framework.response import Response +from opaque_keys.edx.keys import CourseKey +from student.models import CourseEnrollment +from ...utils import mobile_view +from ..utils import get_friends_from_facebook, get_linked_edx_accounts, share_with_facebook_friends +from lms.djangoapps.mobile_api.social_facebook.friends import serializers + + +@mobile_view() +class FriendsInCourse(generics.ListAPIView): + """ + **Use Case** + + API endpoint that returns all the users friends that are in the course specified. + Note that only friends that allow their courses to be shared will be included. + + **Example request**: + + GET /api/mobile/v0.5/social/facebook/friends/course/ + + where course_id is in the form of /edX/DemoX/Demo_Course + + **Response Values** + + { + "friends": [ + { + "name": "test", + "id": "12345", + }, + ... + ] + } + """ + serializer_class = serializers.FriendsInCourseSerializer + + def list(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.GET, files=request.FILES) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Get all the user's FB friends + result = get_friends_from_facebook(serializer) + if type(result) != list: + return result + + def is_member(friend, course_key): + """ + Return true if friend is a member of the course specified by the course_key + """ + return CourseEnrollment.objects.filter( + course_id=course_key, + user_id=friend['edX_id'] + ).count() == 1 + + # For each friend check if they are a linked edX user + friends_with_edx_users = get_linked_edx_accounts(result) + + # Filter by sharing preferences and enrollment in course + course_key = CourseKey.from_string(kwargs['course_id']) + friends_with_sharing_in_course = [ + {'id': friend['id'], 'name': friend['name']} + for friend in friends_with_edx_users + if share_with_facebook_friends(friend) and is_member(friend, course_key) + ] + return Response({'friends': friends_with_sharing_in_course}) diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/__init__.py b/lms/djangoapps/mobile_api/social_facebook/groups/__init__.py new file mode 100644 index 0000000000..ca42614299 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/__init__.py @@ -0,0 +1,3 @@ +""" +Groups API +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/models.py b/lms/djangoapps/mobile_api/social_facebook/groups/models.py new file mode 100644 index 0000000000..d2e8572729 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/models.py @@ -0,0 +1,3 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/serializers.py b/lms/djangoapps/mobile_api/social_facebook/groups/serializers.py new file mode 100644 index 0000000000..068d1a04b4 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/serializers.py @@ -0,0 +1,30 @@ +""" + Serializer for user API +""" +from rest_framework import serializers +from django.core.validators import RegexValidator + + +class GroupSerializer(serializers.Serializer): + """ + Serializes facebook groups request + """ + name = serializers.CharField(max_length=150) + description = serializers.CharField(max_length=200, required=False) + privacy = serializers.ChoiceField(choices=[("open", "open"), ("closed", "closed")], required=False) + + +class GroupsMembersSerializer(serializers.Serializer): + """ + Serializes facebook invitations request + """ + member_ids = serializers.CharField( + required=True, + validators=[ + RegexValidator( + regex=r'^([\d]+,?)*$', + message='A comma separated list of member ids must be provided', + code='member_ids error' + ), + ] + ) diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/tests.py b/lms/djangoapps/mobile_api/social_facebook/groups/tests.py new file mode 100644 index 0000000000..31aa354166 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/tests.py @@ -0,0 +1,199 @@ +""" +Tests for groups +""" + +import httpretty +from ddt import ddt, data +from django.conf import settings +from django.core.urlresolvers import reverse +from courseware.tests.factories import UserFactory +from ..test_utils import SocialFacebookTestCase + + +@ddt +class TestGroups(SocialFacebookTestCase): + """ + Tests for /api/mobile/v0.5/social/facebook/groups/... + """ + def setUp(self): + super(TestGroups, self).setUp() + self.user = UserFactory.create() + self.client.login(username=self.user.username, password='test') + + # Group Creation and Deletion Tests + @httpretty.activate + def test_create_new_open_group(self): + group_id = '12345678' + status_code = 200 + self.set_facebook_interceptor_for_access_token() + self.set_facebook_interceptor_for_groups({'id': group_id}, status_code) + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post( + url, + { + 'name': 'TheBestGroup', + 'description': 'The group for the best people', + 'privacy': 'open' + } + ) + self.assertEqual(response.status_code, status_code) + self.assertTrue('id' in response.data) # pylint: disable=E1103 + self.assertEqual(response.data['id'], group_id) # pylint: disable=E1103 + + @httpretty.activate + def test_create_new_closed_group(self): + group_id = '12345678' + status_code = 200 + self.set_facebook_interceptor_for_access_token() + self.set_facebook_interceptor_for_groups({'id': group_id}, status_code) + # Create new group + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post( + url, + { + 'name': 'TheBestGroup', + 'description': 'The group for the best people', + 'privacy': 'closed' + } + ) + self.assertEqual(response.status_code, status_code) + self.assertTrue('id' in response.data) # pylint: disable=E1103 + self.assertEqual(response.data['id'], group_id) # pylint: disable=E1103 + + def test_create_new_group_no_name(self): + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 400) + + def test_create_new_group_with_invalid_name(self): + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post(url, {'invalid_name': 'TheBestGroup'}) + self.assertEqual(response.status_code, 400) + + def test_create_new_group_with_invalid_privacy(self): + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post( + url, + {'name': 'TheBestGroup', 'privacy': 'half_open_half_closed'} + ) + self.assertEqual(response.status_code, 400) + + @httpretty.activate + def test_delete_group_that_exists(self): + # Create new group + group_id = '12345678' + status_code = 200 + self.set_facebook_interceptor_for_access_token() + self.set_facebook_interceptor_for_groups({'id': group_id}, status_code) + url = reverse('create-delete-group', kwargs={'group_id': ''}) + response = self.client.post( + url, + { + 'name': 'TheBestGroup', + 'description': 'The group for the best people', + 'privacy': 'open' + } + ) + self.assertEqual(response.status_code, status_code) + self.assertTrue('id' in response.data) # pylint: disable=E1103 + # delete group + httpretty.register_uri( + httpretty.POST, + 'https://graph.facebook.com/{}/{}/groups/{}?access_token=FakeToken&method=delete'.format( + settings.FACEBOOK_API_VERSION, + settings.FACEBOOK_APP_ID, + group_id + ), + body='{"success": "true"}', + status=status_code + ) + response = self.delete_group(response.data['id']) # pylint: disable=E1101 + self.assertTrue(response.status_code, status_code) # pylint: disable=E1101 + + @httpretty.activate + def test_delete(self): + group_id = '12345678' + status_code = 400 + httpretty.register_uri( + httpretty.GET, + 'https://graph.facebook.com/oauth/access_token?client_secret={}&grant_type=client_credentials&client_id={}' + .format( + settings.FACEBOOK_APP_SECRET, + settings.FACEBOOK_APP_ID + ), + body='FakeToken=FakeToken', + status=200 + ) + httpretty.register_uri( + httpretty.POST, + 'https://graph.facebook.com/{}/{}/groups/{}?access_token=FakeToken&method=delete'.format( + settings.FACEBOOK_API_VERSION, + settings.FACEBOOK_APP_ID, + group_id + ), + body='{"error": {"message": "error message"}}', + status=status_code + ) + response = self.delete_group(group_id) + self.assertTrue(response.status_code, status_code) + + # Member addition and Removal tests + @data('1234,,,,5678,,', 'this00is00not00a00valid00id', '1234,abc,5678', '') + def test_invite_single_member_malformed_member_id(self, member_id): + group_id = '111111111111111' + response = self.invite_to_group(group_id, member_id) + self.assertEqual(response.status_code, 400) + + @httpretty.activate + def test_invite_single_member(self): + group_id = '111111111111111' + member_id = '44444444444444444' + status_code = 200 + self.set_facebook_interceptor_for_access_token() + self.set_facebook_interceptor_for_members({'success': 'True'}, status_code, group_id, member_id) + response = self.invite_to_group(group_id, member_id) + self.assertEqual(response.status_code, status_code) + self.assertTrue('success' in response.data[member_id]) # pylint: disable=E1103 + + @httpretty.activate + def test_invite_multiple_members_successfully(self): + member_ids = '222222222222222,333333333333333,44444444444444444' + group_id = '111111111111111' + status_code = 200 + self.set_facebook_interceptor_for_access_token() + for member_id in member_ids.split(','): + self.set_facebook_interceptor_for_members({'success': 'True'}, status_code, group_id, member_id) + response = self.invite_to_group(group_id, member_ids) + self.assertEqual(response.status_code, status_code) + for member_id in member_ids.split(','): + self.assertTrue('success' in response.data[member_id]) # pylint: disable=E1103 + + @httpretty.activate + def test_invite_single_member_unsuccessfully(self): + group_id = '111111111111111' + member_id = '44444444444444444' + status_code = 400 + self.set_facebook_interceptor_for_access_token() + self.set_facebook_interceptor_for_members( + {'error': {'message': 'error message'}}, + status_code, group_id, member_id + ) + response = self.invite_to_group(group_id, member_id) + self.assertEqual(response.status_code, 200) + self.assertTrue('error message' in response.data[member_id]) # pylint: disable=E1103 + + @httpretty.activate + def test_invite_multiple_members_unsuccessfully(self): + member_ids = '222222222222222,333333333333333,44444444444444444' + group_id = '111111111111111' + status_code = 400 + self.set_facebook_interceptor_for_access_token() + for member_id in member_ids.split(','): + self.set_facebook_interceptor_for_members( + {'error': {'message': 'error message'}}, + status_code, group_id, member_id + ) + response = self.invite_to_group(group_id, member_ids) + self.assertEqual(response.status_code, 200) + for member_id in member_ids.split(','): + self.assertTrue('error message' in response.data[member_id]) # pylint: disable=E1103 diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/urls.py b/lms/djangoapps/mobile_api/social_facebook/groups/urls.py new file mode 100644 index 0000000000..a1cbcfc19c --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/urls.py @@ -0,0 +1,20 @@ +""" +URLs for groups API +""" +from django.conf.urls import patterns, url +from .views import Groups, GroupsMembers + + +urlpatterns = patterns( + 'mobile_api.social_facebook.groups.views', + url( + r'^(?P[\d]*)$', + Groups.as_view(), + name='create-delete-group' + ), + url( + r'^(?P[\d]+)/member/(?P[\d]*,*)$', + GroupsMembers.as_view(), + name='add-remove-member' + ) +) diff --git a/lms/djangoapps/mobile_api/social_facebook/groups/views.py b/lms/djangoapps/mobile_api/social_facebook/groups/views.py new file mode 100644 index 0000000000..463e466aad --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/groups/views.py @@ -0,0 +1,143 @@ +""" +Views for groups info API +""" + +from rest_framework import generics, status, mixins +from rest_framework.response import Response +from django.conf import settings +import facebook + +from ...utils import mobile_view +from . import serializers + + +@mobile_view() +class Groups(generics.CreateAPIView, mixins.DestroyModelMixin): + """ + **Use Case** + + An API to Create or Delete course groups. + + Note: The Delete is not invoked from the current version of the app + and is used only for testing with facebook dependencies. + + **Creation Example request**: + + POST /api/mobile/v0.5/social/facebook/groups/ + + Parameters: name : string, + description : string, + privacy : open/closed + + **Creation Response Values** + + {"id": group_id} + + **Deletion Example request**: + + DELETE /api/mobile/v0.5/social/facebook/groups/ + + **Deletion Response Values** + + {"success" : "true"} + + """ + serializer_class = serializers.GroupSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.DATA, files=request.FILES) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + try: + app_groups_response = facebook_graph_api().request( + settings.FACEBOOK_API_VERSION + '/' + settings.FACEBOOK_APP_ID + "/groups", + post_args=request.POST.dict() + ) + return Response(app_groups_response) + except facebook.GraphAPIError, ex: + return Response({'error': ex.result['error']['message']}, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, *args, **kwargs): # pylint: disable=unused-argument + """ + Deletes the course group. + """ + try: + return Response( + facebook_graph_api().request( + settings.FACEBOOK_API_VERSION + '/' + settings.FACEBOOK_APP_ID + "/groups/" + kwargs['group_id'], + post_args={'method': 'delete'} + ) + ) + except facebook.GraphAPIError, ex: + return Response({'error': ex.result['error']['message']}, status=status.HTTP_400_BAD_REQUEST) + + +@mobile_view() +class GroupsMembers(generics.CreateAPIView, mixins.DestroyModelMixin): + """ + **Use Case** + + An API to Invite and Remove members to a group + + Note: The Remove is not invoked from the current version + of the app and is used only for testing with facebook dependencies. + + **Invite Example request**: + + POST /api/mobile/v0.5/social/facebook/groups//member/ + + Parameters: members : int,int,int... + + + **Invite Response Values** + + {"member_id" : success/error_message} + A response with each member_id and whether or not the member was added successfully. + If the member was not added successfully the Facebook error message is provided. + + **Remove Example request**: + + DELETE /api/mobile/v0.5/social/facebook/groups//member/ + + **Remove Response Values** + + {"success" : "true"} + """ + serializer_class = serializers.GroupsMembersSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.DATA, files=request.FILES) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + graph = facebook_graph_api() + url = settings.FACEBOOK_API_VERSION + '/' + kwargs['group_id'] + "/members" + member_ids = serializer.object['member_ids'].split(',') + response = {} + for member_id in member_ids: + try: + if 'success' in graph.request(url, post_args={'member': member_id}): + response[member_id] = 'success' + except facebook.GraphAPIError, ex: + response[member_id] = ex.result['error']['message'] + return Response(response, status=status.HTTP_200_OK) + + def delete(self, request, *args, **kwargs): # pylint: disable=unused-argument + """ + Deletes the member from the course group. + """ + try: + return Response( + facebook_graph_api().request( + settings.FACEBOOK_API_VERSION + '/' + kwargs['group_id'] + "/members", + post_args={'method': 'delete', 'member': kwargs['member_id']} + ) + ) + except facebook.GraphAPIError, ex: + return Response({'error': ex.result['error']['message']}, status=status.HTTP_400_BAD_REQUEST) + + +def facebook_graph_api(): + """ + Returns the result from calling Facebook's Graph API with the app's access token. + """ + return facebook.GraphAPI(facebook.get_app_access_token(settings.FACEBOOK_APP_ID, settings.FACEBOOK_APP_SECRET)) diff --git a/lms/djangoapps/mobile_api/social_facebook/models.py b/lms/djangoapps/mobile_api/social_facebook/models.py new file mode 100644 index 0000000000..d2e8572729 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/models.py @@ -0,0 +1,3 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/__init__.py b/lms/djangoapps/mobile_api/social_facebook/preferences/__init__.py new file mode 100644 index 0000000000..7a7241c5ad --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/__init__.py @@ -0,0 +1,3 @@ +""" +Users Sharing preferences API +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/models.py b/lms/djangoapps/mobile_api/social_facebook/preferences/models.py new file mode 100644 index 0000000000..d2e8572729 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/models.py @@ -0,0 +1,3 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) +""" diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/serializers.py b/lms/djangoapps/mobile_api/social_facebook/preferences/serializers.py new file mode 100644 index 0000000000..12eb03f0cb --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/serializers.py @@ -0,0 +1,11 @@ +""" +Serializer for Share Settings API +""" +from rest_framework import serializers + + +class UserSharingSerializar(serializers.Serializer): + """ + Serializes user social settings + """ + share_with_facebook_friends = serializers.BooleanField(required=True, default=False) diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/tests.py b/lms/djangoapps/mobile_api/social_facebook/preferences/tests.py new file mode 100644 index 0000000000..94865e28c0 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/tests.py @@ -0,0 +1,68 @@ +# pylint: disable=no-member +""" +Tests for users sharing preferences +""" +from django.core.urlresolvers import reverse +from ..test_utils import SocialFacebookTestCase + + +class StudentProfileViewTest(SocialFacebookTestCase): + """ Tests for the student profile views. """ + + USERNAME = u'bnotions' + PASSWORD = u'horse' + EMAIL = u'horse@bnotions.com' + FULL_NAME = u'bnotions horse' + + def setUp(self): + super(StudentProfileViewTest, self).setUp() + self.user_create_and_signin(1) + + def assert_shared_value(self, response, expected_value='True'): + """ + Tests whether the response is successful and whether the + share_with_facebook_friends value is set to the expected value. + """ + self.assertEqual(response.status_code, 200) + self.assertTrue('share_with_facebook_friends' in response.data) + self.assertTrue(expected_value in response.data['share_with_facebook_friends']) + + def test_set_preferences_to_true(self): + url = reverse('preferences') + response = self.client.post(url, {'share_with_facebook_friends': 'True'}) + self.assert_shared_value(response) + + def test_set_preferences_to_false(self): + url = reverse('preferences') + response = self.client.post(url, {'share_with_facebook_friends': 'False'}) + self.assert_shared_value(response, 'False') + + def test_set_preferences_no_parameters(self): + # Note that if no value is given it will default to False + url = reverse('preferences') + response = self.client.post(url, {}) + self.assert_shared_value(response, 'False') + + def test_set_preferences_invalid_parameters(self): + # Note that if no value is given it will default to False + # also in the case of invalid parameters + url = reverse('preferences') + response = self.client.post(url, {'bad_param': 'False'}) + self.assert_shared_value(response, 'False') + + def test_get_preferences_after_setting_them(self): + url = reverse('preferences') + + for boolean in ['True', 'False']: + # Set the preference + response = self.client.post(url, {'share_with_facebook_friends': boolean}) + self.assert_shared_value(response, boolean) + # Get the preference + response = self.client.get(url) + self.assert_shared_value(response, boolean) + + def test_get_preferences_without_setting_them(self): + url = reverse('preferences') + # Get the preference + response = self.client.get(url) + self.assert_shared_value(response, 'False') diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/urls.py b/lms/djangoapps/mobile_api/social_facebook/preferences/urls.py new file mode 100644 index 0000000000..70f95bc10c --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/urls.py @@ -0,0 +1,14 @@ +""" +URLs for users sharing preferences +""" +from django.conf.urls import patterns, url +from .views import UserSharing + +urlpatterns = patterns( + 'mobile_api.social_facebook.preferences.views', + url( + r'^preferences/$', + UserSharing.as_view(), + name='preferences' + ), +) diff --git a/lms/djangoapps/mobile_api/social_facebook/preferences/views.py b/lms/djangoapps/mobile_api/social_facebook/preferences/views.py new file mode 100644 index 0000000000..5994f07875 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/preferences/views.py @@ -0,0 +1,52 @@ +""" +Views for users sharing preferences +""" + +from rest_framework import generics, status +from rest_framework.response import Response + +from openedx.core.djangoapps.user_api.api.profile import preference_info, update_preferences +from ...utils import mobile_view +from . import serializers + + +@mobile_view() +class UserSharing(generics.ListCreateAPIView): + """ + **Use Case** + + An API to retrieve or update the users social sharing settings + + **GET Example request**: + + GET /api/mobile/v0.5/settings/preferences/ + + **GET Response Values** + + {'share_with_facebook_friends': 'True'} + + **POST Example request**: + + POST /api/mobile/v0.5/settings/preferences/ + + paramters: share_with_facebook_friends : True + + **POST Response Values** + + {'share_with_facebook_friends': 'True'} + + """ + serializer_class = serializers.UserSharingSerializar + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.DATA, files=request.FILES) + if serializer.is_valid(): + value = serializer.object['share_with_facebook_friends'] + update_preferences(request.user.username, share_with_facebook_friends=value) + return self.get(request, *args, **kwargs) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, *args, **kwargs): + preferences = preference_info(request.user.username) + response = {'share_with_facebook_friends': preferences.get('share_with_facebook_friends', 'False')} + return Response(response) diff --git a/lms/djangoapps/mobile_api/social_facebook/test_utils.py b/lms/djangoapps/mobile_api/social_facebook/test_utils.py new file mode 100644 index 0000000000..462d33910b --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/test_utils.py @@ -0,0 +1,184 @@ +""" + Test utils for Facebook functionality +""" + +import httpretty +import json + +from rest_framework.test import APITestCase +from django.conf import settings +from django.core.urlresolvers import reverse +from social.apps.django_app.default.models import UserSocialAuth + +from student.models import CourseEnrollment +from student.views import login_oauth_token +from openedx.core.djangoapps.user_api.api.profile import preference_info, update_preferences + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from courseware.tests.factories import UserFactory + + +class SocialFacebookTestCase(ModuleStoreTestCase, APITestCase): + """ + Base Class for social test cases + """ + + USERS = { + 1: {'USERNAME': "TestUser One", + 'EMAIL': "test_one@ebnotions.com", + 'PASSWORD': "edx", + 'FB_ID': "11111111111111111"}, + 2: {'USERNAME': "TestUser Two", + 'EMAIL': "test_two@ebnotions.com", + 'PASSWORD': "edx", + 'FB_ID': "22222222222222222"}, + 3: {'USERNAME': "TestUser Three", + 'EMAIL': "test_three@ebnotions.com", + 'PASSWORD': "edx", + 'FB_ID': "33333333333333333"} + } + + BACKEND = "facebook" + USER_URL = "https://graph.facebook.com/me" + UID_FIELD = "id" + + _FB_USER_ACCESS_TOKEN = 'ThisIsAFakeFacebookToken' + + users = {} + + def setUp(self): + super(SocialFacebookTestCase, self).setUp() + + def set_facebook_interceptor_for_access_token(self): + """ + Facebook interceptor for groups access_token + """ + httpretty.register_uri( + httpretty.GET, + 'https://graph.facebook.com/oauth/access_token?client_secret=' + + settings.FACEBOOK_APP_SECRET + '&grant_type=client_credentials&client_id=' + + settings.FACEBOOK_APP_ID, + body='FakeToken=FakeToken', + status=200 + ) + + def set_facebook_interceptor_for_groups(self, data, status): + """ + Facebook interceptor for groups test + """ + httpretty.register_uri( + httpretty.POST, + 'https://graph.facebook.com/' + settings.FACEBOOK_API_VERSION + + '/' + settings.FACEBOOK_APP_ID + '/groups', + body=json.dumps(data), + status=status + ) + + def set_facebook_interceptor_for_members(self, data, status, group_id, member_id): + """ + Facebook interceptor for group members tests + """ + httpretty.register_uri( + httpretty.POST, + 'https://graph.facebook.com/' + settings.FACEBOOK_API_VERSION + + '/' + group_id + '/members?member=' + member_id + + '&access_token=FakeToken', + body=json.dumps(data), + status=status + ) + + def set_facebook_interceptor_for_friends(self, data): + """ + Facebook interceptor for friends tests + """ + httpretty.register_uri( + httpretty.GET, + "https://graph.facebook.com/v2.2/me/friends", + body=json.dumps(data), + status=201 + ) + + def delete_group(self, group_id): + """ + Invoke the delete groups view + """ + url = reverse('create-delete-group', kwargs={'group_id': group_id}) + response = self.client.delete(url) + return response + + def invite_to_group(self, group_id, member_ids): + """ + Invoke the invite to group view + """ + url = reverse('add-remove-member', kwargs={'group_id': group_id, 'member_id': ''}) + return self.client.post(url, {'member_ids': member_ids}) + + def remove_from_group(self, group_id, member_id): + """ + Invoke the remove from group view + """ + url = reverse('add-remove-member', kwargs={'group_id': group_id, 'member_id': member_id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, 200) + + def link_edx_account_to_social(self, user, backend, social_uid): + """ + Register the user to the social auth backend + """ + reverse(login_oauth_token, kwargs={"backend": backend}) + UserSocialAuth.objects.create(user=user, provider=backend, uid=social_uid) + + def set_sharing_preferences(self, user, boolean_value): + """ + Sets self.user's share settings to boolean_value + """ + update_preferences(user.username, share_with_facebook_friends=boolean_value) + self.assertEqual(preference_info(user.username)['share_with_facebook_friends'], unicode(boolean_value)) + + def _change_enrollment(self, action, course_id=None, email_opt_in=None): + """ + Change the student's enrollment status in a course. + + Args: + action (string): The action to perform (either "enroll" or "unenroll") + + Keyword Args: + course_id (unicode): If provided, use this course ID. Otherwise, use the + course ID created in the setup for this test. + email_opt_in (unicode): If provided, pass this value along as + an additional GET parameter. + """ + if course_id is None: + course_id = unicode(self.course.id) + + params = { + 'enrollment_action': action, + 'course_id': course_id + } + + if email_opt_in: + params['email_opt_in'] = email_opt_in + + return self.client.post(reverse('change_enrollment'), params) + + def user_create_and_signin(self, user_number): + """ + Create a user and sign them in + """ + self.users[user_number] = UserFactory.create( + username=self.USERS[user_number]['USERNAME'], + email=self.USERS[user_number]['EMAIL'], + password=self.USERS[user_number]['PASSWORD'] + ) + self.client.login(username=self.USERS[user_number]['USERNAME'], password=self.USERS[user_number]['PASSWORD']) + + def enroll_in_course(self, user, course): + """ + Enroll a user in the course + """ + resp = self._change_enrollment('enroll', course_id=course.id) + self.assertEqual(resp.status_code, 200) + self.assertTrue(CourseEnrollment.is_enrolled(user, course.id)) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, 'honor') diff --git a/lms/djangoapps/mobile_api/social_facebook/urls.py b/lms/djangoapps/mobile_api/social_facebook/urls.py new file mode 100644 index 0000000000..6118f592a6 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/urls.py @@ -0,0 +1,11 @@ +""" +URLs for Social Facebook +""" +from django.conf.urls import patterns, url, include + +urlpatterns = patterns( + '', + url(r'^courses/', include('mobile_api.social_facebook.courses.urls')), + url(r'^friends/', include('mobile_api.social_facebook.friends.urls')), + url(r'^groups/', include('mobile_api.social_facebook.groups.urls')), +) diff --git a/lms/djangoapps/mobile_api/social_facebook/utils.py b/lms/djangoapps/mobile_api/social_facebook/utils.py new file mode 100644 index 0000000000..d65f281f61 --- /dev/null +++ b/lms/djangoapps/mobile_api/social_facebook/utils.py @@ -0,0 +1,68 @@ +""" +Common utility methods and decorators for Social Facebook APIs. +""" +import json +import urllib2 +import facebook +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response +from social.apps.django_app.default.models import UserSocialAuth +from openedx.core.djangoapps.user_api.api.profile import preference_info + + +# TODO +# The pagination strategy needs to be further flushed out. +# What is the default page size for the facebook Graph API? 25? Is the page size a parameter that can be tweaked? +# If a user has a large number of friends, we would be calling the FB API num_friends/page_size times. +# +# However, on the app, we don't plan to display all those friends anyway. +# If we do, for scalability, the endpoints themselves would need to be paginated. +def get_pagination(friends): + """ + Get paginated data from FaceBook response + """ + data = friends['data'] + while 'paging' in friends and 'next' in friends['paging']: + response = urllib2.urlopen(friends['paging']['next']) + friends = json.loads(response.read()) + data = data + friends['data'] + return data + + +def get_friends_from_facebook(serializer): + """ + Return a list with the result of a facebook /me/friends call + using the oauth_token contained within the serializer object. + If facebook returns an error, return a response object containing + the error message. + """ + try: + graph = facebook.GraphAPI(serializer.object['oauth_token']) + friends = graph.request(settings.FACEBOOK_API_VERSION + "/me/friends") + return get_pagination(friends) + except facebook.GraphAPIError, ex: + return Response({'error': ex.result['error']['message']}, status=status.HTTP_400_BAD_REQUEST) + + +def get_linked_edx_accounts(data): + """ + Return a list of friends from the input that are edx users with the + additional attributes of edX_id and edX_username + """ + friends_that_are_edx_users = [] + for friend in data: + query_set = UserSocialAuth.objects.filter(uid=unicode(friend['id'])) + if query_set.count() == 1: + friend['edX_id'] = query_set[0].user_id + friend['edX_username'] = query_set[0].user.username + friends_that_are_edx_users.append(friend) + return friends_that_are_edx_users + + +def share_with_facebook_friends(friend): + """ + Return true if the user's share_with_facebook_friends preference is set to true. + """ + share_fb_friends_settings = preference_info(friend['edX_username']) + return share_fb_friends_settings.get('share_with_facebook_friends', None) == 'True' diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index 16044de6f4..103aa3babb 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -1,11 +1,11 @@ """ URLs for mobile API """ +from django.conf import settings from django.conf.urls import patterns, url, include from .users.views import my_user_info -# Additionally, we include login URLs for the browseable API. urlpatterns = patterns( '', url(r'^users/', include('mobile_api.users.urls')), @@ -13,3 +13,9 @@ urlpatterns = patterns( url(r'^video_outlines/', include('mobile_api.video_outlines.urls')), url(r'^course_info/', include('mobile_api.course_info.urls')), ) + +if settings.FEATURES["ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES"]: + urlpatterns += ( + url(r'^social/facebook/', include('mobile_api.social_facebook.urls')), + url(r'^settings/', include('mobile_api.social_facebook.preferences.urls')), + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 37326c847e..0b1a766f80 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -517,3 +517,8 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get( if FEATURES.get('ENABLE_COURSEWARE_SEARCH'): # Use ElasticSearch as the search engine herein SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" + +# Facebook app +FACEBOOK_API_VERSION = AUTH_TOKENS.get("FACEBOOK_API_VERSION") +FACEBOOK_APP_SECRET = AUTH_TOKENS.get("FACEBOOK_APP_SECRET") +FACEBOOK_APP_ID = AUTH_TOKENS.get("FACEBOOK_APP_ID") diff --git a/lms/envs/common.py b/lms/envs/common.py index fdf7b6aaef..c773f32370 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -312,6 +312,7 @@ FEATURES = { # Expose Mobile REST API. Note that if you use this, you must also set # ENABLE_OAUTH2_PROVIDER to True 'ENABLE_MOBILE_REST_API': False, + 'ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES': False, # Enable the combined login/registration form 'ENABLE_COMBINED_LOGIN_REGISTRATION': False, diff --git a/lms/envs/test.py b/lms/envs/test.py index e08f7e9bbe..9de7b2071a 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -263,6 +263,7 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True ########################### External REST APIs ################################# FEATURES['ENABLE_MOBILE_REST_API'] = True +FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True ###################### Payment ##############################3 @@ -460,3 +461,7 @@ FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENABLE_COURSEWARE_SEARCH'] = True # Use MockSearchEngine as the search engine for test scenario SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" + +FACEBOOK_APP_SECRET = "Test" +FACEBOOK_APP_ID = "Test" +FACEBOOK_API_VERSION = "v2.2" diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1f5eb3f5f8..539b6259b7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -36,6 +36,7 @@ django-method-override==0.1.0 djangorestframework==2.3.14 django==1.4.18 elasticsearch==0.4.5 +facebook-sdk==0.4.0 feedparser==5.1.3 firebase-token-generator==1.3.2 # Master pyfs has a bug working with VPC auth. This is a fix. We should switch