Commit 06172e74 by Andy Armstrong

Merge pull request #7137 from edx/andya/profile-api

Implement profile API
parents 984f8732 dfe0057b
...@@ -5,6 +5,8 @@ from django.conf.urls import patterns, include, url ...@@ -5,6 +5,8 @@ from django.conf.urls import patterns, include, url
from ratelimitbackend import admin from ratelimitbackend import admin
admin.autodiscover() admin.autodiscover()
# pylint: disable=bad-continuation
# Pattern to match a course key or a library key # Pattern to match a course key or a library key
COURSELIKE_KEY_PATTERN = r'(?P<course_key_string>({}|{}))'.format( COURSELIKE_KEY_PATTERN = r'(?P<course_key_string>({}|{}))'.format(
r'[^/]+/[^/]+/[^/]+', r'[^/:]+:[^/+]+\+[^/+]+(\+[^/]+)?' r'[^/]+/[^/]+/[^/]+', r'[^/:]+:[^/+]+\+[^/+]+(\+[^/]+)?'
...@@ -47,7 +49,7 @@ urlpatterns = patterns( ...@@ -47,7 +49,7 @@ urlpatterns = patterns(
url(r'^xmodule/', include('pipeline_js.urls')), url(r'^xmodule/', include('pipeline_js.urls')),
url(r'^heartbeat$', include('heartbeat.urls')), url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')), url(r'^user_api/', include('openedx.core.djangoapps.user_api.legacy_urls')),
url(r'^lang_pref/', include('lang_pref.urls')), url(r'^lang_pref/', include('lang_pref.urls')),
) )
...@@ -87,7 +89,8 @@ urlpatterns += patterns( ...@@ -87,7 +89,8 @@ urlpatterns += patterns(
name='course_search_index_handler' name='course_search_index_handler'
), ),
url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'), url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'),
url(r'^course_notifications/{}/(?P<action_state_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'), url(r'^course_notifications/{}/(?P<action_state_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN),
'course_notifications_handler'),
url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'), url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
url(r'^container/{}$'.format(settings.USAGE_KEY_PATTERN), 'container_handler'), url(r'^container/{}$'.format(settings.USAGE_KEY_PATTERN), 'container_handler'),
url(r'^checklists/{}/(?P<checklist_index>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'checklists_handler'), url(r'^checklists/{}/(?P<checklist_index>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'checklists_handler'),
...@@ -115,7 +118,7 @@ urlpatterns += patterns( ...@@ -115,7 +118,7 @@ urlpatterns += patterns(
url(r'^api/val/v0/', include('edxval.urls')), url(r'^api/val/v0/', include('edxval.urls')),
) )
js_info_dict = { JS_INFO_DICT = {
'domain': 'djangojs', 'domain': 'djangojs',
# We need to explicitly include external Django apps that are not in LOCALE_PATHS. # We need to explicitly include external Django apps that are not in LOCALE_PATHS.
'packages': ('openassessment',), 'packages': ('openassessment',),
...@@ -124,7 +127,7 @@ js_info_dict = { ...@@ -124,7 +127,7 @@ js_info_dict = {
urlpatterns += patterns( urlpatterns += patterns(
'', '',
# Serve catalog of localized strings to be rendered by Javascript # Serve catalog of localized strings to be rendered by Javascript
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', JS_INFO_DICT),
) )
if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'):
......
...@@ -20,7 +20,7 @@ from django.contrib import messages ...@@ -20,7 +20,7 @@ from django.contrib import messages
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core import mail from django.core import mail
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError from django.core.validators import validate_email, ValidationError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
Http404) Http404)
...@@ -84,7 +84,6 @@ from external_auth.login_and_register import ( ...@@ -84,7 +84,6 @@ from external_auth.login_and_register import (
from bulk_email.models import Optout, CourseAuthorization from bulk_email.models import Optout, CourseAuthorization
import shoppingcart import shoppingcart
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
from notification_prefs.views import enable_notifications from notification_prefs.views import enable_notifications
...@@ -113,7 +112,6 @@ from student.helpers import ( ...@@ -113,7 +112,6 @@ from student.helpers import (
) )
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import DonationConfiguration, CourseRegistrationCode from shoppingcart.models import DonationConfiguration, CourseRegistrationCode
from openedx.core.djangoapps.user_api.api import profile as profile_api
from embargo import api as embargo_api from embargo import api as embargo_api
...@@ -649,6 +647,9 @@ def dashboard(request): ...@@ -649,6 +647,9 @@ def dashboard(request):
# Re-alphabetize language options # Re-alphabetize language options
language_options.sort() language_options.sort()
# TODO: remove circular dependency on openedx from common
from openedx.core.djangoapps.user_api.models import UserPreference
# try to get the prefered language for the user # try to get the prefered language for the user
cur_pref_lang_code = UserPreference.get_preference(request.user, LANGUAGE_KEY) cur_pref_lang_code = UserPreference.get_preference(request.user, LANGUAGE_KEY)
# try and get the current language of the user # try and get the current language of the user
...@@ -813,6 +814,10 @@ def try_change_enrollment(request): ...@@ -813,6 +814,10 @@ def try_change_enrollment(request):
def _update_email_opt_in(request, username, org): def _update_email_opt_in(request, username, org):
"""Helper function used to hit the profile API if email opt-in is enabled.""" """Helper function used to hit the profile API if email opt-in is enabled."""
# TODO: remove circular dependency on openedx from common
from openedx.core.djangoapps.user_api.api import profile as profile_api
email_opt_in = request.POST.get('email_opt_in') email_opt_in = request.POST.get('email_opt_in')
if email_opt_in is not None: if email_opt_in is not None:
email_opt_in_boolean = email_opt_in == 'true' email_opt_in_boolean = email_opt_in == 'true'
...@@ -1401,6 +1406,9 @@ def _do_create_account(form): ...@@ -1401,6 +1406,9 @@ def _do_create_account(form):
log.exception("UserProfile creation failed for user {id}.".format(id=user.id)) log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
raise raise
# TODO: remove circular dependency on openedx from common
from openedx.core.djangoapps.user_api.models import UserPreference
UserPreference.set_preference(user, LANGUAGE_KEY, get_language()) UserPreference.set_preference(user, LANGUAGE_KEY, get_language())
return (user, profile, registration) return (user, profile, registration)
......
...@@ -103,8 +103,6 @@ from . import provider ...@@ -103,8 +103,6 @@ from . import provider
# `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student # `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student
# is trying to enroll in, used to generate analytics events # is trying to enroll in, used to generate analytics events
# and auto-enroll students. # and auto-enroll students.
from openedx.core.djangoapps.user_api.api import profile
AUTH_ENTRY_KEY = 'auth_entry' AUTH_ENTRY_KEY = 'auth_entry'
AUTH_REDIRECT_KEY = 'next' AUTH_REDIRECT_KEY = 'next'
AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id' AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id'
...@@ -671,6 +669,8 @@ def change_enrollment(strategy, user=None, is_dashboard=False, *args, **kwargs): ...@@ -671,6 +669,8 @@ def change_enrollment(strategy, user=None, is_dashboard=False, *args, **kwargs):
# If the email opt in parameter is found, set the preference. # If the email opt in parameter is found, set the preference.
email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY) email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY)
if email_opt_in: if email_opt_in:
# TODO: remove circular dependency on openedx from common
from openedx.core.djangoapps.user_api.api import profile
opt_in = email_opt_in.lower() == 'true' opt_in = email_opt_in.lower() == 'true'
profile.update_email_opt_in(user.username, course_id.org, opt_in) profile.update_email_opt_in(user.username, course_id.org, opt_in)
......
...@@ -13,15 +13,14 @@ from datetime import timedelta, datetime ...@@ -13,15 +13,14 @@ from datetime import timedelta, datetime
import ddt import ddt
from django.test.client import Client from django.test.client import Client
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core import mail from django.core import mail
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from openedx.core.djangoapps.user_api.api import profile as profile_api from openedx.core.djangoapps.user_api.accounts.views import AccountView
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
...@@ -1057,7 +1056,7 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1057,7 +1056,7 @@ class TestSubmitPhotosForVerification(TestCase):
self.assertEqual(attempt.status, "submitted") self.assertEqual(attempt.status, "submitted")
# Verify that the user's name wasn't changed # Verify that the user's name wasn't changed
self._assert_full_name(self.user.profile.name) self._assert_user_name(self.user.profile.name)
def test_submit_photos_and_change_name(self): def test_submit_photos_and_change_name(self):
# Submit the photos, along with a name change # Submit the photos, along with a name change
...@@ -1068,7 +1067,7 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1068,7 +1067,7 @@ class TestSubmitPhotosForVerification(TestCase):
) )
# Check that the user's name was changed in the database # Check that the user's name was changed in the database
self._assert_full_name(self.FULL_NAME) self._assert_user_name(self.FULL_NAME)
@ddt.data('face_image', 'photo_id_image') @ddt.data('face_image', 'photo_id_image')
def test_invalid_image_data(self, invalid_param): def test_invalid_image_data(self, invalid_param):
...@@ -1140,8 +1139,8 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1140,8 +1139,8 @@ class TestSubmitPhotosForVerification(TestCase):
return response return response
def _assert_full_name(self, full_name): def _assert_user_name(self, full_name):
"""Check the user's full name. """Check the user's name.
Arguments: Arguments:
full_name (unicode): The user's full name. full_name (unicode): The user's full name.
...@@ -1150,8 +1149,8 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1150,8 +1149,8 @@ class TestSubmitPhotosForVerification(TestCase):
AssertionError AssertionError
""" """
info = profile_api.profile_info(self.user.username) account_settings = AccountView.get_serialized_account(self.user.username)
self.assertEqual(info['full_name'], full_name) self.assertEqual(account_settings['name'], full_name)
class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): class TestPhotoVerificationResultsCallback(ModuleStoreTestCase):
......
...@@ -27,7 +27,6 @@ from django.utils.translation import ugettext as _, ugettext_lazy ...@@ -27,7 +27,6 @@ from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail from django.core.mail import send_mail
from openedx.core.djangoapps.user_api.api import profile as profile_api
from openedx.core.djangoapps.user_api.accounts.views import AccountView from openedx.core.djangoapps.user_api.accounts.views import AccountView
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError
...@@ -744,20 +743,20 @@ def submit_photos_for_verification(request): ...@@ -744,20 +743,20 @@ def submit_photos_for_verification(request):
attempt.mark_ready() attempt.mark_ready()
attempt.submit() attempt.submit()
profile_dict = profile_api.profile_info(username) account_settings = AccountView.get_serialized_account(username)
if profile_dict:
# Send a confirmation email to the user # Send a confirmation email to the user
context = { context = {
'full_name': profile_dict.get('full_name'), 'full_name': account_settings['name'],
'platform_name': settings.PLATFORM_NAME 'platform_name': settings.PLATFORM_NAME
} }
subject = _("Verification photos received") subject = _("Verification photos received")
message = render_to_string('emails/photo_submission_confirmation.txt', context) message = render_to_string('emails/photo_submission_confirmation.txt', context)
from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL) from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
to_address = profile_dict.get('email') to_address = account_settings['email']
send_mail(subject, message, from_address, [to_address], fail_silently=False) send_mail(subject, message, from_address, [to_address], fail_silently=False)
return HttpResponse(200) return HttpResponse(200)
......
...@@ -2047,3 +2047,26 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12 ...@@ -2047,3 +2047,26 @@ PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12
SEARCH_ENGINE = None SEARCH_ENGINE = None
# Use the LMS specific result processor # Use the LMS specific result processor
SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor" SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor"
# The configuration for learner profiles
PROFILE_CONFIGURATION = {
# Default visibility level for accounts without a specified value
# The value is one of: 'all_users', 'private'
"default_visibility": 'all_users',
# The list of all fields that can be shown on a learner's profile
"all_fields": [
'username',
'profile_image',
'country',
'time_zone',
'languages',
'bio',
],
# The list of fields that are always public on a learner's profile
"public_fields": [
'username',
'profile_image',
],
}
...@@ -60,9 +60,11 @@ urlpatterns = ( ...@@ -60,9 +60,11 @@ urlpatterns = (
url(r'^heartbeat$', include('heartbeat.urls')), url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')), url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
url(r'^api/user/', include('openedx.core.djangoapps.user_api.accounts.urls')), # Note: these are older versions of the User API that will eventually be
# subsumed by api/user.
url(r'^user_api/', include('openedx.core.djangoapps.user_api.legacy_urls')),
url(r'^notifier_api/', include('notifier_api.urls')), url(r'^notifier_api/', include('notifier_api.urls')),
......
...@@ -2,32 +2,81 @@ ...@@ -2,32 +2,81 @@
import unittest import unittest
import ddt import ddt
import json import json
from datetime import datetime
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase, APIClient from rest_framework.test import APITestCase, APIClient
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import UserProfile, PendingEmailChange from student.models import UserProfile, PendingEmailChange
from student.views import confirm_email_change
TEST_PASSWORD = "test" TEST_PASSWORD = "test"
@ddt.ddt class UserAPITestCase(APITestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') """
class TestAccountAPI(APITestCase): The base class for all tests of the User API
"""
def setUp(self): def setUp(self):
super(TestAccountAPI, self).setUp() super(UserAPITestCase, self).setUp()
self.anonymous_client = APIClient() self.anonymous_client = APIClient()
self.different_user = UserFactory.create(password=TEST_PASSWORD) self.different_user = UserFactory.create(password=TEST_PASSWORD)
self.different_client = APIClient() self.different_client = APIClient()
self.staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD) self.staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD)
self.staff_client = APIClient() self.staff_client = APIClient()
self.user = UserFactory.create(password=TEST_PASSWORD) self.user = UserFactory.create(password=TEST_PASSWORD)
def login_client(self, api_client, user):
"""Helper method for getting the client and user and logging in. Returns client. """
client = getattr(self, api_client)
user = getattr(self, user)
client.login(username=user.username, password=TEST_PASSWORD)
return client
def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=204):
"""
Helper method for sending a patch to the server, defaulting to application/merge-patch+json content_type.
Verifies the expected status and returns the response.
"""
# pylint: disable=no-member
response = client.patch(self.url, data=json.dumps(json_data), content_type=content_type)
self.assertEqual(expected_status, response.status_code)
return response
def send_get(self, client, query_parameters=None, expected_status=200):
"""
Helper method for sending a GET to the server. Verifies the expected status and returns the response.
"""
url = self.url + '?' + query_parameters if query_parameters else self.url # pylint: disable=no-member
response = client.get(url)
self.assertEqual(expected_status, response.status_code)
return response
def create_mock_profile(self, user):
"""
Helper method that creates a mock profile for the specified user
:return:
"""
legacy_profile = UserProfile.objects.get(id=user.id)
legacy_profile.country = "US"
legacy_profile.level_of_education = "m"
legacy_profile.year_of_birth = 1900
legacy_profile.goals = "world peace"
legacy_profile.mailing_address = "Park Ave"
legacy_profile.save()
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountAPI(UserAPITestCase):
"""
Unit tests for the Account API.
"""
def setUp(self):
super(TestAccountAPI, self).setUp()
self.url = reverse("accounts_api", kwargs={'username': self.user.username}) self.url = reverse("accounts_api", kwargs={'username': self.user.username})
def test_get_account_anonymous_user(self): def test_get_account_anonymous_user(self):
...@@ -87,15 +136,7 @@ class TestAccountAPI(APITestCase): ...@@ -87,15 +136,7 @@ class TestAccountAPI(APITestCase):
Test that a client (logged in) can get her own account information. Also verifies that a "is_staff" Test that a client (logged in) can get her own account information. Also verifies that a "is_staff"
user can get the account information for other users. user can get the account information for other users.
""" """
# Create some test profile values. self.create_mock_profile(self.user)
legacy_profile = UserProfile.objects.get(id=self.user.id)
legacy_profile.country = "US"
legacy_profile.level_of_education = "m"
legacy_profile.year_of_birth = 1900
legacy_profile.goals = "world peace"
legacy_profile.mailing_address = "Park Ave"
legacy_profile.save()
client = self.login_client(api_client, user) client = self.login_client(api_client, user)
response = self.send_get(client) response = self.send_get(client)
data = response.data data = response.data
...@@ -343,27 +384,3 @@ class TestAccountAPI(APITestCase): ...@@ -343,27 +384,3 @@ class TestAccountAPI(APITestCase):
error_response.data["developer_message"] error_response.data["developer_message"]
) )
self.assertEqual("Valid e-mail address required.", error_response.data["user_message"]) self.assertEqual("Valid e-mail address required.", error_response.data["user_message"])
def login_client(self, api_client, user):
"""Helper method for getting the client and user and logging in. Returns client. """
client = getattr(self, api_client)
user = getattr(self, user)
client.login(username=user.username, password=TEST_PASSWORD)
return client
def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=204):
"""
Helper method for sending a patch to the server, defaulting to application/merge-patch+json content_type.
Verifies the expected status and returns the response.
"""
response = client.patch(self.url, data=json.dumps(json_data), content_type=content_type)
self.assertEqual(expected_status, response.status_code)
return response
def send_get(self, client, expected_status=200):
"""
Helper method for sending a GET to the server. Verifies the expected status and returns the response.
"""
response = client.get(self.url)
self.assertEqual(expected_status, response.status_code)
return response
from .views import AccountView
from django.conf.urls import include, patterns, url
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(
r'^v0/accounts/' + USERNAME_PATTERN + '$',
AccountView.as_view(),
name="accounts_api"
)
)
...@@ -15,15 +15,13 @@ from rest_framework.response import Response ...@@ -15,15 +15,13 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework import permissions from rest_framework import permissions
from rest_framework import parsers
from student.models import UserProfile
from student.views import do_email_change_request
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer, AccountUserSerializer from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer, AccountUserSerializer
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
from student.models import UserProfile
from student.views import do_email_change_request
class AccountView(APIView): class AccountView(APIView):
...@@ -89,14 +87,34 @@ class AccountView(APIView): ...@@ -89,14 +87,34 @@ class AccountView(APIView):
GET /api/user/v0/accounts/{username}/ GET /api/user/v0/accounts/{username}/
""" """
try: try:
existing_user, existing_user_profile = self._get_user_and_profile(username) account_settings = AccountView.get_serialized_account(username)
except AccountUserNotFound: except AccountUserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
return Response(account_settings)
@staticmethod
def get_serialized_account(username):
"""Returns the user's account information serialized as JSON.
Note:
This method does not perform authentication so it is up to the caller
to ensure that only the user themselves or staff can access the account.
Args:
username (str): The username for the desired account.
Returns:
A dict containing each of the account's fields.
Raises:
AccountUserNotFound: raised if there is no account for the specified username.
"""
existing_user, existing_user_profile = AccountView._get_user_and_profile(username)
user_serializer = AccountUserSerializer(existing_user) user_serializer = AccountUserSerializer(existing_user)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile) legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile)
return Response(dict(user_serializer.data, **legacy_profile_serializer.data)) return dict(user_serializer.data, **legacy_profile_serializer.data)
def patch(self, request, username): def patch(self, request, username):
""" """
......
...@@ -14,7 +14,8 @@ from pytz import UTC ...@@ -14,7 +14,8 @@ from pytz import UTC
import analytics import analytics
from eventtracking import tracker from eventtracking import tracker
from ..models import User, UserProfile, UserPreference, UserOrgTag from ..accounts.views import AccountView
from ..models import User, UserPreference, UserOrgTag
from ..helpers import intercept_errors from ..helpers import intercept_errors
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -30,63 +31,12 @@ class ProfileUserNotFound(ProfileRequestError): ...@@ -30,63 +31,12 @@ class ProfileUserNotFound(ProfileRequestError):
pass pass
class ProfileInvalidField(ProfileRequestError):
""" The proposed value for a field is not in a valid format. """
def __init__(self, field, value):
super(ProfileInvalidField, self).__init__()
self.field = field
self.value = value
def __str__(self):
return u"Invalid value '{value}' for profile field '{field}'".format(
value=self.value,
field=self.field
)
class ProfileInternalError(Exception): class ProfileInternalError(Exception):
""" An error occurred in an API call. """ """ An error occurred in an API call. """
pass pass
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError]) @intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
def profile_info(username):
"""Retrieve a user's profile information.
Searches either by username or email.
At least one of the keyword args must be provided.
Arguments:
username (unicode): The username of the account to retrieve.
Returns:
dict: If profile information was found.
None: If the provided username did not match any profiles.
"""
try:
profile = UserProfile.objects.get(user__username=username)
except UserProfile.DoesNotExist:
return None
profile_dict = {
"username": profile.user.username,
"email": profile.user.email,
"full_name": profile.name,
"level_of_education": profile.level_of_education,
"mailing_address": profile.mailing_address,
"year_of_birth": profile.year_of_birth,
"goals": profile.goals,
"city": profile.city,
"country": unicode(profile.country),
}
return profile_dict
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
def preference_info(username): def preference_info(username):
"""Retrieve information about a user's preferences. """Retrieve information about a user's preferences.
...@@ -151,22 +101,19 @@ def update_email_opt_in(username, org, optin): ...@@ -151,22 +101,19 @@ def update_email_opt_in(username, org, optin):
None None
Raises: Raises:
ProfileUserNotFound: Raised when the username specified is not associated with a user. AccountUserNotFound: Raised when the username specified is not associated with a user.
""" """
try: account_settings = AccountView.get_serialized_account(username)
user = User.objects.get(username=username) year_of_birth = account_settings['year_of_birth']
except User.DoesNotExist:
raise ProfileUserNotFound
profile = UserProfile.objects.get(user=user)
of_age = ( of_age = (
profile.year_of_birth is None or # If year of birth is not set, we assume user is of age. year_of_birth is None or # If year of birth is not set, we assume user is of age.
datetime.datetime.now(UTC).year - profile.year_of_birth > # pylint: disable=maybe-no-member datetime.datetime.now(UTC).year - year_of_birth > # pylint: disable=maybe-no-member
getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13) getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13)
) )
try: try:
user = User.objects.get(username=username)
preference, _ = UserOrgTag.objects.get_or_create( preference, _ = UserOrgTag.objects.get_or_create(
user=user, org=org, key='email-optin' user=user, org=org, key='email-optin'
) )
......
"""
Defines the URL routes for this app.
"""
from django.conf import settings
from django.conf.urls import include, patterns, url
from rest_framework import routers
from . import views as user_api_views
from .models import UserPreference
USER_API_ROUTER = routers.DefaultRouter()
USER_API_ROUTER.register(r'users', user_api_views.UserViewSet)
USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
urlpatterns = patterns(
'',
url(r'^v1/', include(USER_API_ROUTER.urls)),
url(
r'^v1/preferences/(?P<pref_key>{})/users/$'.format(UserPreference.KEY_REGEX),
user_api_views.PreferenceUsersListView.as_view()
),
url(
r'^v1/forum_roles/(?P<name>[a-zA-Z]+)/users/$',
user_api_views.ForumRoleUsersListView.as_view()
),
url(
r'^v1/preferences/email_opt_in/$',
user_api_views.UpdateEmailOptInPreference.as_view(),
name="preferences_email_opt_in"
),
)
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns(
'',
url(r'^v1/account/login_session/$', user_api_views.LoginSessionView.as_view(),
name="user_api_login_session"),
url(r'^v1/account/registration/$', user_api_views.RegistrationView.as_view(),
name="user_api_registration"),
url(r'^v1/account/password_reset/$', user_api_views.PasswordResetView.as_view(),
name="user_api_password_reset"),
)
"""
Profile constants
"""
PROFILE_VISIBILITY_PREF_KEY = 'profile_privacy'
# Indicates the user's preference that all users can view their profile.
ALL_USERS_VISIBILITY = 'all_users'
# Indicates the user's preference that their profile be private.
PRIVATE_VISIBILITY = 'private'
"""
Unit tests for profile APIs.
"""
import ddt
import unittest
from django.conf import settings
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api.accounts.tests.test_views import UserAPITestCase
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.profiles import PROFILE_VISIBILITY_PREF_KEY
from .. import PRIVATE_VISIBILITY
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile APIs are only supported in LMS')
class TestProfileAPI(UserAPITestCase):
"""
Unit tests for the profile API.
"""
def setUp(self):
super(TestProfileAPI, self).setUp()
self.url = reverse("profiles_api", kwargs={'username': self.user.username})
def test_get_profile_anonymous_user(self):
"""
Test that an anonymous client (not logged in) cannot call get.
"""
self.send_get(self.anonymous_client, expected_status=401)
def _verify_full_profile_response(self, response):
"""
Verify that all of the profile's fields are returned
"""
data = response.data
self.assertEqual(6, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"])
self.assertIsNone(data["profile_image"])
self.assertIsNone(data["time_zone"])
self.assertIsNone(data["languages"])
self.assertIsNone(data["bio"])
def _verify_private_profile_response(self, response):
"""
Verify that only the public fields are returned for a private user's profile
"""
data = response.data
self.assertEqual(2, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertIsNone(data["profile_image"])
@ddt.data(
("client", "user"),
("different_client", "different_user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_default_profile(self, api_client, username):
"""
Test that any logged in user can get the main test user's public profile information.
"""
client = self.login_client(api_client, username)
self.create_mock_profile(self.user)
response = self.send_get(client)
self._verify_full_profile_response(response)
@ddt.data(
("client", "user"),
("different_client", "different_user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_private_profile(self, api_client, requesting_username):
"""
Test that private profile information is only available to the test user themselves.
"""
client = self.login_client(api_client, requesting_username)
# Verify that a user with a private profile only returns the public fields
UserPreference.set_preference(self.user, PROFILE_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY)
self.create_mock_profile(self.user)
response = self.send_get(client)
self._verify_private_profile_response(response)
# Verify that only the public fields are returned if 'include_all' parameter is specified as false
response = self.send_get(client, query_parameters='include_all=false')
self._verify_private_profile_response(response)
# Verify that all fields are returned for the user themselves if
# the 'include_all' parameter is specified as true.
response = self.send_get(client, query_parameters='include_all=true')
if requesting_username == "user":
self._verify_full_profile_response(response)
else:
self._verify_private_profile_response(response)
@ddt.data(
("client", "user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_profile_unknown_user(self, api_client, username):
"""
Test that requesting a user who does not exist returns a 404.
"""
client = self.login_client(api_client, username)
response = client.get(reverse("profiles_api", kwargs={'username': "does_not_exist"}))
self.assertEqual(404, response.status_code)
"""
NOTE: this API is WIP and has not yet been approved. Do not use this API without talking to Christina or Andy.
For more information, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
from django.conf import settings
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework import permissions
from ..accounts.views import AccountView
from ..api.account import AccountUserNotFound
from ..models import UserPreference
from . import PROFILE_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY
class ProfileView(APIView):
"""
**Use Cases**
Get the user's public profile information.
**Example Requests**:
GET /api/user/v0/profiles/{username}/[?include_all={true | false}]
**Response Values for GET**
Returns the same responses as for the AccountView API for the
subset of fields that have been configured to be in a profile.
The fields are additionally filtered based upon the user's
specified privacy permissions.
If the parameter 'include_all' is passed as 'true' then a user
can receive all fields for their own account, ignoring
any field visibility preferences. If the parameter is not
specified or if the user is requesting information for a
different account then the privacy filtering will be applied.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, username):
"""
GET /api/user/v0/profiles/{username}/[?include_all={true | false}]
Note:
The include_all query parameter will only be honored if the user is making
the request for their own username. It defaults to false, but if true
then all the profile fields will be returned even for a user with
a private profile.
"""
if request.user.username == username:
include_all_fields = self.request.QUERY_PARAMS.get('include_all') == 'true'
else:
include_all_fields = False
try:
profile_settings = ProfileView.get_serialized_profile(
username,
settings.PROFILE_CONFIGURATION,
include_all_fields=include_all_fields,
)
except AccountUserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(profile_settings)
@staticmethod
def get_serialized_profile(username, configuration=None, include_all_fields=False):
"""Returns the user's public profile settings serialized as JSON.
The fields returned are by default governed by the user's privacy preference.
If the user has a private profile, then only the fields that are always
public are returned. If the user is sharing their profile with all users
then all profile fields are returned.
Note:
This method does not perform authentication so it is up to the caller
to ensure that only edX users can access the profile. In addition, only
the user themselves should be able to access all fields of a private
profile through 'include_all_fields' being true.
Args:
username (str): The username for the desired account.
configuration (dict): A dictionary specifying three profile configuration settings:
default_visibility: the default visibility level for user's with no preference
all_fields: the list of all fields that can be shown on a profile
public_fields: the list of profile fields that are public
include_all_fields (bool): If true, ignores the user's privacy setting.
Returns:
A dict containing each of the user's profile fields.
Raises:
AccountUserNotFound: raised if there is no account for the specified username.
"""
if not configuration:
configuration = settings.PROFILE_CONFIGURATION
account_settings = AccountView.get_serialized_account(username)
profile = {}
privacy_setting = ProfileView._get_user_profile_privacy(username, configuration)
if include_all_fields or privacy_setting == ALL_USERS_VISIBILITY:
field_names = configuration.get('all_fields')
else:
field_names = configuration.get('public_fields')
for field_name in field_names:
profile[field_name] = account_settings.get(field_name, None)
return profile
@staticmethod
def _get_user_profile_privacy(username, configuration):
"""
Returns the profile privacy preference for the specified user.
"""
user = User.objects.get(username=username)
profile_privacy = UserPreference.get_preference(user, PROFILE_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else configuration.get('default_visibility')
...@@ -11,6 +11,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -11,6 +11,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
import datetime import datetime
from ..accounts.views import AccountView
from ..api import account as account_api from ..api import account as account_api
from ..api import profile as profile_api from ..api import profile as profile_api
from ..models import UserProfile, UserOrgTag from ..models import UserProfile, UserOrgTag
...@@ -27,24 +28,27 @@ class ProfileApiTest(ModuleStoreTestCase): ...@@ -27,24 +28,27 @@ class ProfileApiTest(ModuleStoreTestCase):
# Create a new account, which should have an empty profile by default. # Create a new account, which should have an empty profile by default.
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
# Retrieve the profile, expecting default values # Retrieve the account settings
profile = profile_api.profile_info(username=self.USERNAME) account_settings = AccountView.get_serialized_account(self.USERNAME)
self.assertEqual(profile, {
# Expect a date joined field but remove it to simplify the following comparison
self.assertIsNotNone(account_settings['date_joined'])
del account_settings['date_joined']
# Expect all the values to be defaulted
self.assertEqual(account_settings, {
'username': self.USERNAME, 'username': self.USERNAME,
'email': self.EMAIL, 'email': self.EMAIL,
'full_name': u'', 'name': u'',
'gender': None,
'language': u'',
'goals': None, 'goals': None,
'level_of_education': None, 'level_of_education': None,
'mailing_address': None, 'mailing_address': None,
'year_of_birth': None, 'year_of_birth': None,
'country': '', 'country': None,
'city': None,
}) })
def test_retrieve_profile_no_user(self):
profile = profile_api.profile_info('does not exist')
self.assertIs(profile, None)
def test_update_and_retrieve_preference_info(self): def test_update_and_retrieve_preference_info(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
......
...@@ -10,7 +10,7 @@ from django.core.urlresolvers import reverse ...@@ -10,7 +10,7 @@ from django.core.urlresolvers import reverse
from django.core import mail from django.core import mail
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from unittest import SkipTest, skipUnless from unittest import skipUnless
import ddt import ddt
from pytz import UTC from pytz import UTC
import mock import mock
...@@ -23,6 +23,7 @@ from django_comment_common import models ...@@ -23,6 +23,7 @@ from django_comment_common import models
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from third_party_auth.tests.testutil import simulate_running_pipeline from third_party_auth.tests.testutil import simulate_running_pipeline
from ..accounts.views import AccountView
from ..api import account as account_api, profile as profile_api from ..api import account as account_api, profile as profile_api
from ..models import UserOrgTag from ..models import UserOrgTag
from ..tests.factories import UserPreferenceFactory from ..tests.factories import UserPreferenceFactory
...@@ -1247,8 +1248,8 @@ class RegistrationViewTest(ApiTestCase): ...@@ -1247,8 +1248,8 @@ class RegistrationViewTest(ApiTestCase):
) )
# Verify that the user's full name is set # Verify that the user's full name is set
profile_info = profile_api.profile_info(self.USERNAME) account_settings = AccountView.get_serialized_account(self.USERNAME)
self.assertEqual(profile_info["full_name"], self.NAME) self.assertEqual(account_settings["name"], self.NAME)
# Verify that we've been logged in # Verify that we've been logged in
# by trying to access a page that requires authentication # by trying to access a page that requires authentication
...@@ -1261,7 +1262,6 @@ class RegistrationViewTest(ApiTestCase): ...@@ -1261,7 +1262,6 @@ class RegistrationViewTest(ApiTestCase):
"year_of_birth": "optional", "year_of_birth": "optional",
"mailing_address": "optional", "mailing_address": "optional",
"goals": "optional", "goals": "optional",
"city": "optional",
"country": "required", "country": "required",
}) })
def test_register_with_profile_info(self): def test_register_with_profile_info(self):
...@@ -1275,20 +1275,18 @@ class RegistrationViewTest(ApiTestCase): ...@@ -1275,20 +1275,18 @@ class RegistrationViewTest(ApiTestCase):
"mailing_address": self.ADDRESS, "mailing_address": self.ADDRESS,
"year_of_birth": self.YEAR_OF_BIRTH, "year_of_birth": self.YEAR_OF_BIRTH,
"goals": self.GOALS, "goals": self.GOALS,
"city": self.CITY,
"country": self.COUNTRY, "country": self.COUNTRY,
"honor_code": "true", "honor_code": "true",
}) })
self.assertHttpOK(response) self.assertHttpOK(response)
# Verify the profile information # Verify the user's account
profile_info = profile_api.profile_info(self.USERNAME) account_settings = AccountView.get_serialized_account(self.USERNAME)
self.assertEqual(profile_info["level_of_education"], self.EDUCATION) self.assertEqual(account_settings["level_of_education"], self.EDUCATION)
self.assertEqual(profile_info["mailing_address"], self.ADDRESS) self.assertEqual(account_settings["mailing_address"], self.ADDRESS)
self.assertEqual(profile_info["year_of_birth"], int(self.YEAR_OF_BIRTH)) self.assertEqual(account_settings["year_of_birth"], int(self.YEAR_OF_BIRTH))
self.assertEqual(profile_info["goals"], self.GOALS) self.assertEqual(account_settings["goals"], self.GOALS)
self.assertEqual(profile_info["city"], self.CITY) self.assertEqual(account_settings["country"], self.COUNTRY)
self.assertEqual(profile_info["country"], self.COUNTRY)
def test_activation_email(self): def test_activation_email(self):
# Register, which should trigger an activation email # Register, which should trigger an activation email
......
...@@ -2,40 +2,23 @@ ...@@ -2,40 +2,23 @@
Defines the URL routes for this app. Defines the URL routes for this app.
""" """
from django.conf import settings from .accounts.views import AccountView
from django.conf.urls import include, patterns, url from .profiles.views import ProfileView
from rest_framework import routers
from . import views as user_api_views from django.conf.urls import patterns, url
from .models import UserPreference
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
USER_API_ROUTER = routers.DefaultRouter()
USER_API_ROUTER.register(r'users', user_api_views.UserViewSet)
USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^v1/', include(USER_API_ROUTER.urls)),
url( url(
r'^v1/preferences/(?P<pref_key>{})/users/$'.format(UserPreference.KEY_REGEX), r'^v0/accounts/' + USERNAME_PATTERN + '$',
user_api_views.PreferenceUsersListView.as_view() AccountView.as_view(),
name="accounts_api"
), ),
url( url(
r'^v1/forum_roles/(?P<name>[a-zA-Z]+)/users/$', r'^v0/profiles/' + USERNAME_PATTERN + '$',
user_api_views.ForumRoleUsersListView.as_view() ProfileView.as_view(),
), name="profiles_api"
url(
r'^v1/preferences/email_opt_in/$',
user_api_views.UpdateEmailOptInPreference.as_view(),
name="preferences_email_opt_in"
), ),
) )
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns(
'',
url(r'^v1/account/login_session/$', user_api_views.LoginSessionView.as_view(), name="user_api_login_session"),
url(r'^v1/account/registration/$', user_api_views.RegistrationView.as_view(), name="user_api_registration"),
url(r'^v1/account/password_reset/$', user_api_views.PasswordResetView.as_view(), name="user_api_password_reset"),
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment