diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 7f26eac7b1..30b8cde045 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -282,33 +282,6 @@ class UserProfile(models.Model):
self.set_meta(meta)
self.save()
- @transaction.commit_on_success
- def update_name(self, new_name):
- """Update the user's name, storing the old name in the history.
-
- Implicitly saves the model.
- If the new name is not the same as the old name, do nothing.
-
- Arguments:
- new_name (unicode): The new full name for the user.
-
- Returns:
- None
-
- """
- if self.name == new_name:
- return
-
- if self.name:
- meta = self.get_meta()
- if 'old_names' not in meta:
- meta['old_names'] = []
- meta['old_names'].append([self.name, u"", datetime.now(UTC).isoformat()])
- self.set_meta(meta)
-
- self.name = new_name
- self.save()
-
class UserSignupSource(models.Model):
"""
diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py
index 3112e4c24c..25783feb87 100644
--- a/common/djangoapps/student/tests/test_email.py
+++ b/common/djangoapps/student/tests/test_email.py
@@ -4,7 +4,9 @@ import django.db
import unittest
from student.tests.factories import UserFactory, RegistrationFactory, PendingEmailChangeFactory
-from student.views import reactivation_email_for_user, change_email_request, confirm_email_change
+from student.views import (
+ reactivation_email_for_user, change_email_request, do_email_change_request, confirm_email_change
+)
from student.models import UserProfile, PendingEmailChange
from django.contrib.auth.models import User, AnonymousUser
from django.test import TestCase, TransactionTestCase
@@ -174,6 +176,11 @@ class EmailChangeRequestTests(TestCase):
self.request.POST['new_email'] = email
self.assertFailedRequest(self.run_request(), 'Valid e-mail address required.')
+ def test_change_email_to_existing_value(self):
+ """ Test the error message if user attempts to change email to the existing value. """
+ self.request.POST['new_email'] = self.user.email
+ self.assertFailedRequest(self.run_request(), 'Old email is the same as the new email.')
+
def check_duplicate_email(self, email):
"""Test that a request to change a users email to `email` fails"""
request = self.req_factory.post('unused_url', data={
@@ -192,7 +199,33 @@ class EmailChangeRequestTests(TestCase):
UserFactory.create(email=self.new_email)
self.check_duplicate_email(self.new_email.capitalize())
- # TODO: Finish testing the rest of change_email_request
+ @patch('django.core.mail.send_mail')
+ @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ def test_email_failure(self, send_mail):
+ """ Test the return value if sending the email for the user to click fails. """
+ send_mail.side_effect = [Exception, None]
+ self.request.POST['new_email'] = "valid@email.com"
+ self.assertFailedRequest(self.run_request(), 'Unable to send email activation link. Please try again later.')
+
+ @patch('django.core.mail.send_mail')
+ @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ def test_email_success(self, send_mail):
+ """ Test email was sent if no errors encountered. """
+ old_email = self.user.email
+ new_email = "valid@example.com"
+ registration_key = "test registration key"
+ do_email_change_request(self.user, new_email, registration_key)
+ context = {
+ 'key': registration_key,
+ 'old_email': old_email,
+ 'new_email': new_email
+ }
+ send_mail.assert_called_with(
+ mock_render_to_string('emails/email_change_subject.txt', context),
+ mock_render_to_string('emails/email_change.txt', context),
+ settings.DEFAULT_FROM_EMAIL,
+ [new_email]
+ )
@patch('django.contrib.auth.models.User.email_user')
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index bbb3482a9b..d2649b60c4 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -18,7 +18,7 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
from django.contrib import messages
from django.core.context_processors import csrf
-from django.core.mail import send_mail
+from django.core import mail
from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError
from django.db import IntegrityError, transaction
@@ -1546,7 +1546,7 @@ def create_account_with_params(request, params):
dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
'-' * 80 + '\n\n' + message)
- send_mail(subject, message, from_address, [dest_addr], fail_silently=False)
+ mail.send_mail(subject, message, from_address, [dest_addr], fail_silently=False)
else:
user.email_user(subject, message, from_address)
except Exception: # pylint: disable=broad-except
@@ -1916,6 +1916,7 @@ def reactivation_email_for_user(user):
return JsonResponse({"success": True})
+# TODO: delete this method and redirect unit tests to do_email_change_request after accounts page work is done.
@ensure_csrf_cookie
def change_email_request(request):
""" AJAX call from the profile page. User wants a new e-mail.
@@ -1933,39 +1934,44 @@ def change_email_request(request):
}) # TODO: this should be status code 400 # pylint: disable=fixme
new_email = request.POST['new_email']
+ try:
+ do_email_change_request(request.user, new_email)
+ except ValueError as err:
+ return JsonResponse({
+ "success": False,
+ "error": err.message,
+ })
+ return JsonResponse({"success": True})
+
+
+def do_email_change_request(user, new_email, activation_key=uuid.uuid4().hex):
+ """
+ Given a new email for a user, does some basic verification of the new address and sends an activation message
+ to the new address. If any issues are encountered with verification or sending the message, a ValueError will
+ be thrown.
+ """
try:
validate_email(new_email)
except ValidationError:
- return JsonResponse({
- "success": False,
- "error": _('Valid e-mail address required.'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
+ raise ValueError(_('Valid e-mail address required.'))
+
+ if new_email == user.email:
+ raise ValueError(_('Old email is the same as the new email.'))
if User.objects.filter(email=new_email).count() != 0:
- ## CRITICAL TODO: Handle case sensitivity for e-mails
- return JsonResponse({
- "success": False,
- "error": _('An account with this e-mail already exists.'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
+ raise ValueError(_('An account with this e-mail already exists.'))
- pec_list = PendingEmailChange.objects.filter(user=request.user)
+ pec_list = PendingEmailChange.objects.filter(user=user)
if len(pec_list) == 0:
pec = PendingEmailChange()
pec.user = user
else:
pec = pec_list[0]
- pec.new_email = request.POST['new_email']
- pec.activation_key = uuid.uuid4().hex
+ pec.new_email = new_email
+ pec.activation_key = activation_key
pec.save()
- if pec.new_email == user.email:
- pec.delete()
- return JsonResponse({
- "success": False,
- "error": _('Old email is the same as the new email.'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
context = {
'key': pec.activation_key,
'old_email': user.email,
@@ -1982,15 +1988,10 @@ def change_email_request(request):
settings.DEFAULT_FROM_EMAIL
)
try:
- send_mail(subject, message, from_address, [pec.new_email])
+ mail.send_mail(subject, message, from_address, [pec.new_email])
except Exception: # pylint: disable=broad-except
log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
- return JsonResponse({
- "success": False,
- "error": _('Unable to send email activation link. Please try again later.')
- })
-
- return JsonResponse({"success": True})
+ raise ValueError(_('Unable to send email activation link. Please try again later.'))
@ensure_csrf_cookie
@@ -2059,6 +2060,7 @@ def confirm_email_change(request, key): # pylint: disable=unused-argument
raise
+# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
@ensure_csrf_cookie
@require_POST
def change_name_request(request):
@@ -2087,45 +2089,7 @@ def change_name_request(request):
return JsonResponse({"success": True})
-@ensure_csrf_cookie
-def pending_name_changes(request):
- """ Web page which allows staff to approve or reject name changes. """
- if not request.user.is_staff:
- raise Http404
-
- students = []
- for change in PendingNameChange.objects.all():
- profile = UserProfile.objects.get(user=change.user)
- students.append({
- "new_name": change.new_name,
- "rationale": change.rationale,
- "old_name": profile.name,
- "email": change.user.email,
- "uid": change.user.id,
- "cid": change.id,
- })
-
- return render_to_response("name_changes.html", {"students": students})
-
-
-@ensure_csrf_cookie
-def reject_name_change(request):
- """ JSON: Name change process. Course staff clicks 'reject' on a given name change """
- if not request.user.is_staff:
- raise Http404
-
- try:
- pnc = PendingNameChange.objects.get(id=int(request.POST['id']))
- except PendingNameChange.DoesNotExist:
- return JsonResponse({
- "success": False,
- "error": _('Invalid ID'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
- pnc.delete()
- return JsonResponse({"success": True})
-
-
+# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
def accept_name_change_by_id(uid):
"""
Accepts the pending name change request for the user represented
@@ -2156,20 +2120,6 @@ def accept_name_change_by_id(uid):
return JsonResponse({"success": True})
-@ensure_csrf_cookie
-def accept_name_change(request):
- """ JSON: Name change process. Course staff clicks 'accept' on a given name change
-
- We used this during the prototype but now we simply record name changes instead
- of manually approving them. Still keeping this around in case we want to go
- back to this approval method.
- """
- if not request.user.is_staff:
- raise Http404
-
- return accept_name_change_by_id(int(request.POST['id']))
-
-
@require_POST
@login_required
@ensure_csrf_cookie
diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py
index 1a8f6537bf..0feb69944a 100644
--- a/lms/djangoapps/verify_student/views.py
+++ b/lms/djangoapps/verify_student/views.py
@@ -28,6 +28,9 @@ from django.contrib.auth.decorators import login_required
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 import NAME_MIN_LENGTH
+from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError
from course_modes.models import CourseMode
from student.models import CourseEnrollment
@@ -718,16 +721,13 @@ def submit_photos_for_verification(request):
# then try to do that before creating the attempt.
if request.POST.get('full_name'):
try:
- profile_api.update_profile(
- username,
- full_name=request.POST.get('full_name')
- )
- except profile_api.ProfileUserNotFound:
+ AccountView.update_account(request.user, username, {"name": request.POST.get('full_name')})
+ except AccountUserNotFound:
return HttpResponseBadRequest(_("No profile found for user"))
- except profile_api.ProfileInvalidField:
+ except AccountUpdateError:
msg = _(
"Name must be at least {min_length} characters long."
- ).format(min_length=profile_api.FULL_NAME_MIN_LENGTH)
+ ).format(min_length=NAME_MIN_LENGTH)
return HttpResponseBadRequest(msg)
# Create the attempt
diff --git a/lms/templates/name_changes.html b/lms/templates/name_changes.html
deleted file mode 100644
index 72d55d3ef0..0000000000
--- a/lms/templates/name_changes.html
+++ /dev/null
@@ -1,45 +0,0 @@
-<%! from django.utils.translation import ugettext as _ %>
-
-<%inherit file="main.html" />
-
-
-
-
-
-
${_("Pending name changes")}
-
- % for s in students:
-
-
${s['old_name']}
-
${s['new_name']|h}
-
${s['email']|h}
-
${s['rationale']|h}
-
[${_("Confirm")}]
- [${_("Reject")}]
- % endfor
-
-
-
-
diff --git a/lms/urls.py b/lms/urls.py
index 0b324ca9aa..acb33a4284 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -28,9 +28,6 @@ urlpatterns = (
url(r'^change_email$', 'student.views.change_email_request', name="change_email"),
url(r'^email_confirm/(?P[^/]*)$', 'student.views.confirm_email_change'),
url(r'^change_name$', 'student.views.change_name_request', name="change_name"),
- url(r'^accept_name_change$', 'student.views.accept_name_change'),
- url(r'^reject_name_change$', 'student.views.reject_name_change'),
- url(r'^pending_name_changes$', 'student.views.pending_name_changes'),
url(r'^event$', 'track.views.user_track'),
url(r'^segmentio/event$', 'track.views.segmentio.segmentio_event'),
url(r'^t/(?P[^/]*)$', 'static_template_view.views.index'), # TODO: Is this used anymore? What is STATIC_GRAB?
diff --git a/openedx/core/djangoapps/user_api/accounts/__init__.py b/openedx/core/djangoapps/user_api/accounts/__init__.py
index e69de29bb2..1a655b2835 100644
--- a/openedx/core/djangoapps/user_api/accounts/__init__.py
+++ b/openedx/core/djangoapps/user_api/accounts/__init__.py
@@ -0,0 +1,2 @@
+# The minimum acceptable length for the name account field
+NAME_MIN_LENGTH = 2
diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py
index 6583599ff1..a44f4864c7 100644
--- a/openedx/core/djangoapps/user_api/accounts/serializers.py
+++ b/openedx/core/djangoapps/user_api/accounts/serializers.py
@@ -1,6 +1,7 @@
from rest_framework import serializers
from django.contrib.auth.models import User
from student.models import UserProfile
+from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
class AccountUserSerializer(serializers.HyperlinkedModelSerializer):
@@ -22,7 +23,20 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer):
fields = (
"name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", "mailing_address"
)
- read_only_fields = ("name",)
+ # Currently no read-only field, but keep this so view code doesn't need to know.
+ read_only_fields = ()
+
+ def validate_name(self, attrs, source):
+ """ Enforce minimum length for name. """
+ if source in attrs:
+ new_name = attrs[source].strip()
+ if len(new_name) < NAME_MIN_LENGTH:
+ raise serializers.ValidationError(
+ "The name field must be at least {} characters long.".format(NAME_MIN_LENGTH)
+ )
+ attrs[source] = new_name
+
+ return attrs
def transform_gender(self, obj, value):
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index c3335c183f..d25dda8dda 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
import unittest
import ddt
import json
@@ -9,7 +10,8 @@ from django.conf import settings
from rest_framework.test import APITestCase, APIClient
from student.tests.factories import UserFactory
-from student.models import UserProfile
+from student.models import UserProfile, PendingEmailChange
+from student.views import confirm_email_change
TEST_PASSWORD = "test"
@@ -41,6 +43,19 @@ class TestAccountAPI(APITestCase):
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.send_get(self.different_client, expected_status=404)
+ @ddt.data(
+ ("client", "user"),
+ ("staff_client", "staff_user"),
+ )
+ @ddt.unpack
+ def test_get_account_unknown_user(self, api_client, user):
+ """
+ Test that requesting a user who does not exist returns a 404.
+ """
+ client = self.login_client(api_client, user)
+ response = client.get(reverse("accounts_api", kwargs={'username': "does_not_exist"}))
+ self.assertEqual(404, response.status_code)
+
def test_get_account_default(self):
"""
Test that a client (logged in) can get her own account information (using default legacy profile information,
@@ -112,6 +127,35 @@ class TestAccountAPI(APITestCase):
for empty_field in ("level_of_education", "gender", "country"):
self.assertIsNone(response.data[empty_field])
+ def test_patch_account_anonymous_user(self):
+ """
+ Test that an anonymous client (not logged in) cannot call patch.
+ """
+ self.send_patch(self.anonymous_client, {}, expected_status=401)
+
+ def test_patch_account_different_user(self):
+ """
+ Test that a client (logged in) cannot update the account information for a different client.
+ """
+ self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
+ self.send_patch(self.different_client, {}, expected_status=404)
+
+ @ddt.data(
+ ("client", "user"),
+ ("staff_client", "staff_user"),
+ )
+ @ddt.unpack
+ def test_patch_account_unknown_user(self, api_client, user):
+ """
+ Test that trying to update a user who does not exist returns a 404.
+ """
+ client = self.login_client(api_client, user)
+ response = client.patch(
+ reverse("accounts_api", kwargs={'username': "does_not_exist"}),
+ data=json.dumps({}), content_type="application/merge-patch+json"
+ )
+ self.assertEqual(404, response.status_code)
+
@ddt.data(
(
"client", "user", "gender", "f", "not a gender",
@@ -123,12 +167,15 @@ class TestAccountAPI(APITestCase):
),
("client", "user", "country", "GB", "XY", "Select a valid choice. XY is not one of the available choices."),
("client", "user", "year_of_birth", 2009, "not_an_int", "Enter a whole number."),
+ ("client", "user", "name", "bob", "z"*256, "Ensure this value has at most 255 characters (it has 256)."),
+ ("client", "user", "name", u"ȻħȺɍłɇs", "z ", "The name field must be at least 2 characters long."),
("client", "user", "language", "Creole"),
("client", "user", "goals", "Smell the roses"),
("client", "user", "mailing_address", "Sesame Street"),
# All of the fields can be edited by is_staff, but iterating through all of them again seems like overkill.
# Just test a representative field.
("staff_client", "staff_user", "goals", "Smell the roses"),
+ # Note that email is tested below, as it is not immediately updated.
)
@ddt.unpack
def test_patch_account(
@@ -179,7 +226,7 @@ class TestAccountAPI(APITestCase):
"Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"]
)
- for field_name in ["username", "email", "date_joined", "name"]:
+ for field_name in ["username", "date_joined"]:
response = self.send_patch(client, {field_name: "will_error", "gender": "f"}, expected_status=400)
verify_error_response(field_name, response.data)
@@ -188,10 +235,10 @@ class TestAccountAPI(APITestCase):
self.assertEqual("m", response.data["gender"])
# Test error message with multiple read-only items
- response = self.send_patch(client, {"username": "will_error", "email": "xx"}, expected_status=400)
+ response = self.send_patch(client, {"username": "will_error", "date_joined": "xx"}, expected_status=400)
self.assertEqual(2, len(response.data["field_errors"]))
verify_error_response("username", response.data)
- verify_error_response("email", response.data)
+ verify_error_response("date_joined", response.data)
def test_patch_bad_content_type(self):
"""
@@ -219,6 +266,84 @@ class TestAccountAPI(APITestCase):
response = self.send_get(self.client)
self.assertIsNone(response.data[field_name])
+ def test_patch_name_metadata(self):
+ """
+ Test the metadata stored when changing the name field.
+ """
+ def get_name_change_info(expected_entries):
+ legacy_profile = UserProfile.objects.get(id=self.user.id)
+ name_change_info = legacy_profile.get_meta()["old_names"]
+ self.assertEqual(expected_entries, len(name_change_info))
+ return name_change_info
+
+ def verify_change_info(change_info, old_name, requester, new_name):
+ self.assertEqual(3, len(change_info))
+ self.assertEqual(old_name, change_info[0])
+ self.assertEqual("Name change requested through account API by {}".format(requester), change_info[1])
+ self.assertIsNotNone(change_info[2])
+ # Verify the new name was also stored.
+ get_response = self.send_get(self.client)
+ self.assertEqual(new_name, get_response.data["name"])
+
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
+ legacy_profile = UserProfile.objects.get(id=self.user.id)
+ self.assertEqual({}, legacy_profile.get_meta())
+ old_name = legacy_profile.name
+
+ # First change the name as the user and verify meta information.
+ self.send_patch(self.client, {"name": "Mickey Mouse"})
+ name_change_info = get_name_change_info(1)
+ verify_change_info(name_change_info[0], old_name, self.user.username, "Mickey Mouse")
+
+ # Now change the name as a different (staff) user and verify meta information.
+ self.staff_client.login(username=self.staff_user.username, password=TEST_PASSWORD)
+ self.send_patch(self.staff_client, {"name": "Donald Duck"})
+ name_change_info = get_name_change_info(2)
+ verify_change_info(name_change_info[0], old_name, self.user.username, "Donald Duck",)
+ verify_change_info(name_change_info[1], "Mickey Mouse", self.staff_user.username, "Donald Duck")
+
+ @ddt.data(
+ ("client", "user"),
+ ("staff_client", "staff_user"),
+ )
+ @ddt.unpack
+ def test_patch_email(self, api_client, user):
+ """
+ Test that the user (and anyone with an is_staff account) can request an email change through the accounts API.
+ Full testing of the helper method used (do_email_change_request) exists in the package with the code.
+ Here just do minimal smoke testing.
+ """
+ client = self.login_client(api_client, user)
+ old_email = self.user.email
+ new_email = "newemail@example.com"
+ self.send_patch(client, {"email": new_email, "goals": "change my email"})
+
+ # Since request is multi-step, the email won't change on GET immediately (though goals will update).
+ get_response = self.send_get(client)
+ self.assertEqual(old_email, get_response.data["email"])
+ self.assertEqual("change my email", get_response.data["goals"])
+
+ # Now call the method that will be invoked with the user clicks the activation key in the received email.
+ # First we must get the activation key that was sent.
+ pending_change = PendingEmailChange.objects.filter(user=self.user)
+ self.assertEqual(1, len(pending_change))
+ activation_key = pending_change[0].activation_key
+ confirm_change_url = reverse(
+ "student.views.confirm_email_change", kwargs={'key': activation_key}
+ )
+ response = self.client.post(confirm_change_url)
+ self.assertEqual(200, response.status_code)
+ get_response = self.send_get(client)
+ self.assertEqual(new_email, get_response.data["email"])
+
+ # Finally, try changing to an invalid email just to make sure error messages are appropriately returned.
+ error_response = self.send_patch(client, {"email": "not_an_email"}, expected_status=400)
+ self.assertEqual(
+ "Error thrown from do_email_change_request: 'Valid e-mail address required.'",
+ error_response.data["developer_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)
diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py
index ff29ec51bf..78c878d229 100644
--- a/openedx/core/djangoapps/user_api/accounts/views.py
+++ b/openedx/core/djangoapps/user_api/accounts/views.py
@@ -7,6 +7,8 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
+import datetime
+from pytz import UTC
from rest_framework.views import APIView
from rest_framework.response import Response
@@ -16,9 +18,12 @@ 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.lib.api.permissions import IsUserInUrlOrStaff
from openedx.core.lib.api.parsers import MergePatchParser
+from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError
class AccountView(APIView):
@@ -37,9 +42,10 @@ class AccountView(APIView):
* username: username associated with the account (not editable)
- * name: full name of the user (not editable through this API)
+ * name: full name of the user (must be at least two characters)
- * email: email for the user (not editable through this API)
+ * email: email for the user (the new email address must be confirmed via a confirmation email, so GET will
+ not reflect the change until the address has been confirmed)
* date_joined: date this account was created (not editable), in the string format provided by
datetime (for example, "2014-08-26T17:52:11Z")
@@ -82,7 +88,11 @@ class AccountView(APIView):
"""
GET /api/user/v0/accounts/{username}/
"""
- existing_user, existing_user_profile = self._get_user_and_profile(username)
+ try:
+ existing_user, existing_user_profile = self._get_user_and_profile(username)
+ except AccountUserNotFound:
+ return Response(status=status.HTTP_404_NOT_FOUND)
+
user_serializer = AccountUserSerializer(existing_user)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile)
@@ -96,10 +106,50 @@ class AccountView(APIView):
https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or
else an error response with status code 415 will be returned.
"""
- existing_user, existing_user_profile = self._get_user_and_profile(username)
+ try:
+ AccountView.update_account(request.user, username, request.DATA)
+ except AccountUserNotFound:
+ return Response(status=status.HTTP_404_NOT_FOUND)
+ except AccountUpdateError as err:
+ return Response(err.error_info, status=status.HTTP_400_BAD_REQUEST)
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ @staticmethod
+ def update_account(requesting_user, username, update):
+ """Update the account for the given username.
+
+ Note:
+ No authorization or permissions checks are done in this method. It is up to the caller
+ of this method to enforce the contract that this method is only called if
+ requesting_user.username == username or requesting_user.is_staff == True.
+
+ Arguments:
+ requesting_user (User): the user who initiated the request
+ username (string): the username associated with the account to change
+ update (dict): the updated account field values
+
+ Raises:
+ AccountUserNotFound: no user exists with the specified username
+ AccountUpdateError: the update could not be completed, usually due to validation errors
+ (for example, read-only fields were specified or field values are not legal)
+ """
+ existing_user, existing_user_profile = AccountView._get_user_and_profile(username)
+
+ # If user has requested to change email, we must call the multi-step process to handle this.
+ # It is not handled by the serializer (which considers email to be read-only).
+ new_email = None
+ if "email" in update:
+ new_email = update["email"]
+ del update["email"]
+
+ # If user has requested to change name, store old name because we must update associated metadata
+ # after the save process is complete.
+ old_name = None
+ if "name" in update:
+ old_name = existing_user_profile.name
# Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400.
- update = request.DATA
read_only_fields = set(update.keys()).intersection(
AccountUserSerializer.Meta.read_only_fields + AccountLegacyProfileSerializer.Meta.read_only_fields
)
@@ -110,33 +160,57 @@ class AccountView(APIView):
"developer_message": "This field is not editable via this API",
"user_message": _("Field '{field_name}' cannot be edited.".format(field_name=read_only_field))
}
- response_data = {"field_errors": field_errors}
- return Response(response_data, status=status.HTTP_400_BAD_REQUEST)
+ raise AccountUpdateError({"field_errors": field_errors})
+
+ # If the user asked to change email, send the request now.
+ if new_email:
+ try:
+ do_email_change_request(existing_user, new_email)
+ except ValueError as err:
+ response_data = {
+ "developer_message": "Error thrown from do_email_change_request: '{}'".format(err.message),
+ "user_message": err.message
+ }
+ raise AccountUpdateError(response_data)
user_serializer = AccountUserSerializer(existing_user, data=update)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile, data=update)
for serializer in user_serializer, legacy_profile_serializer:
- validation_errors = self._get_validation_errors(update, serializer)
+ validation_errors = AccountView._get_validation_errors(update, serializer)
if validation_errors:
- return Response(validation_errors, status=status.HTTP_400_BAD_REQUEST)
+ raise AccountUpdateError(validation_errors)
serializer.save()
- return Response(status=status.HTTP_204_NO_CONTENT)
+ # If the name was changed, store information about the change operation. This is outside of the
+ # serializer so that we can store who requested the change.
+ if old_name:
+ meta = existing_user_profile.get_meta()
+ if 'old_names' not in meta:
+ meta['old_names'] = []
+ meta['old_names'].append([
+ old_name,
+ "Name change requested through account API by {0}".format(requesting_user.username),
+ datetime.datetime.now(UTC).isoformat()
+ ])
+ existing_user_profile.set_meta(meta)
+ existing_user_profile.save()
- def _get_user_and_profile(self, username):
+ @staticmethod
+ def _get_user_and_profile(username):
"""
Helper method to return the legacy user and profile objects based on username.
"""
try:
existing_user = User.objects.get(username=username)
+ existing_user_profile = UserProfile.objects.get(user=existing_user)
except ObjectDoesNotExist:
- return Response({}, status=status.HTTP_404_NOT_FOUND)
- existing_user_profile = UserProfile.objects.get(user=existing_user)
+ raise AccountUserNotFound()
return existing_user, existing_user_profile
- def _get_validation_errors(self, update, serializer):
+ @staticmethod
+ def _get_validation_errors(update, serializer):
"""
Helper method that returns any validation errors that are present.
"""
diff --git a/openedx/core/djangoapps/user_api/api/account.py b/openedx/core/djangoapps/user_api/api/account.py
index f2cf4a2a62..bdef6cc839 100644
--- a/openedx/core/djangoapps/user_api/api/account.py
+++ b/openedx/core/djangoapps/user_api/api/account.py
@@ -40,11 +40,6 @@ class AccountUserAlreadyExists(AccountRequestError):
pass
-class AccountUsernameAlreadyExists(AccountUserAlreadyExists):
- """An account already exists with the requested username. """
- pass
-
-
class AccountUsernameInvalid(AccountRequestError):
"""The requested username is not in a valid format. """
pass
@@ -70,6 +65,15 @@ class AccountNotAuthorized(AccountRequestError):
pass
+class AccountUpdateError(AccountRequestError):
+ """
+ An update to the account failed. More detailed information is present in error_info (a dict
+ with at least a developer_message, though possibly also a nested field_errors dict).
+ """
+ def __init__(self, error_info):
+ self.error_info = error_info
+
+
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
@transaction.commit_on_success
def create_account(username, password, email):
@@ -211,6 +215,7 @@ def activate_account(activation_key):
# This implicitly saves the registration
registration.activate()
+
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def request_password_change(email, orig_host, is_secure):
"""Email a single-use link for performing a password reset.
diff --git a/openedx/core/djangoapps/user_api/api/profile.py b/openedx/core/djangoapps/user_api/api/profile.py
index 5fa2c68ff1..2697e2b499 100644
--- a/openedx/core/djangoapps/user_api/api/profile.py
+++ b/openedx/core/djangoapps/user_api/api/profile.py
@@ -50,10 +50,6 @@ class ProfileInternalError(Exception):
pass
-FULL_NAME_MAX_LENGTH = 255
-FULL_NAME_MIN_LENGTH = 2
-
-
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
def profile_info(username):
"""Retrieve a user's profile information.
@@ -90,36 +86,6 @@ def profile_info(username):
return profile_dict
-@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
-def update_profile(username, full_name=None):
- """Update a user's profile.
-
- Arguments:
- username (unicode): The username associated with the account.
-
- Keyword Arguments:
- full_name (unicode): If provided, set the user's full name to this value.
-
- Returns:
- None
-
- Raises:
- ProfileRequestError: If there is no profile matching the provided username.
-
- """
- try:
- profile = UserProfile.objects.get(user__username=username)
- except UserProfile.DoesNotExist:
- raise ProfileUserNotFound
-
- if full_name is not None:
- name_length = len(full_name)
- if name_length > FULL_NAME_MAX_LENGTH or name_length < FULL_NAME_MIN_LENGTH:
- raise ProfileInvalidField("full_name", full_name)
- else:
- profile.update_name(full_name)
-
-
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
def preference_info(username):
"""Retrieve information about a user's preferences.
diff --git a/openedx/core/djangoapps/user_api/tests/test_profile_api.py b/openedx/core/djangoapps/user_api/tests/test_profile_api.py
index 7abefedc96..7ad09a5ec7 100644
--- a/openedx/core/djangoapps/user_api/tests/test_profile_api.py
+++ b/openedx/core/djangoapps/user_api/tests/test_profile_api.py
@@ -41,56 +41,10 @@ class ProfileApiTest(ModuleStoreTestCase):
'city': None,
})
- def test_update_full_name(self):
- account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
- profile_api.update_profile(self.USERNAME, full_name=u'ȻħȺɍłɇs')
- profile = profile_api.profile_info(self.USERNAME)
- self.assertEqual(profile['full_name'], u'ȻħȺɍłɇs')
-
- @raises(profile_api.ProfileInvalidField)
- @ddt.data('', 'a', 'a' * profile_api.FULL_NAME_MAX_LENGTH + 'a')
- def test_update_full_name_invalid(self, invalid_name):
- account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
- profile_api.update_profile(self.USERNAME, full_name=invalid_name)
-
- @raises(profile_api.ProfileUserNotFound)
- def test_update_profile_no_user(self):
- profile_api.update_profile(self.USERNAME, full_name='test')
-
def test_retrieve_profile_no_user(self):
profile = profile_api.profile_info('does not exist')
self.assertIs(profile, None)
- def test_record_name_change_history(self):
- account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
-
- # Change the name once
- # Since the original name was an empty string, expect that the list
- # of old names is empty
- profile_api.update_profile(self.USERNAME, full_name='new name')
- meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
- self.assertEqual(meta, {})
-
- # Change the name again and expect the new name is stored in the history
- profile_api.update_profile(self.USERNAME, full_name='another new name')
- meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
-
- self.assertEqual(len(meta['old_names']), 1)
- name, rationale, timestamp = meta['old_names'][0]
- self.assertEqual(name, 'new name')
- self.assertEqual(rationale, u'')
- self._assert_is_datetime(timestamp)
-
- # Change the name a third time and expect both names are stored in the history
- profile_api.update_profile(self.USERNAME, full_name='yet another new name')
- meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
-
- self.assertEqual(len(meta['old_names']), 2)
- name, rationale, timestamp = meta['old_names'][1]
- self.assertEqual(name, 'another new name')
- self.assertEqual(rationale, u'')
- self._assert_is_datetime(timestamp)
-
def test_update_and_retrieve_preference_info(self):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py
index 0120750543..8f43b8a97b 100644
--- a/openedx/core/djangoapps/user_api/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/tests/test_views.py
@@ -27,6 +27,7 @@ from ..api import account as account_api, profile as profile_api
from ..models import UserOrgTag
from ..tests.factories import UserPreferenceFactory
from ..tests.test_constants import SORTED_COUNTRIES
+from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
TEST_API_KEY = "test_api_key"
@@ -840,7 +841,7 @@ class RegistrationViewTest(ApiTestCase):
u"label": u"Full name",
u"instructions": u"The name that will appear on your certificates",
u"restrictions": {
- "max_length": profile_api.FULL_NAME_MAX_LENGTH,
+ "max_length": NAME_MIN_LENGTH,
},
}
)
@@ -920,7 +921,7 @@ class RegistrationViewTest(ApiTestCase):
u"label": u"Full name",
u"instructions": u"The name that will appear on your certificates",
u"restrictions": {
- "max_length": profile_api.FULL_NAME_MAX_LENGTH
+ "max_length": NAME_MIN_LENGTH
}
}
)
diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py
index 929c7133d1..0a93898c5a 100644
--- a/openedx/core/djangoapps/user_api/views.py
+++ b/openedx/core/djangoapps/user_api/views.py
@@ -33,6 +33,8 @@ from .helpers import FormDescription, shim_student_view, require_post_params
from .models import UserPreference, UserProfile
from .serializers import UserSerializer, UserPreferenceSerializer
+from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
+
class LoginSessionView(APIView):
"""HTTP end-points for logging in users. """
@@ -350,7 +352,7 @@ class RegistrationView(APIView):
label=name_label,
instructions=name_instructions,
restrictions={
- "max_length": profile_api.FULL_NAME_MAX_LENGTH,
+ "max_length": NAME_MIN_LENGTH,
},
required=required
)