diff --git a/cms/envs/test.py b/cms/envs/test.py
index ebf44c35fd..2ddf3a1e09 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -39,16 +39,9 @@ from lms.envs.test import (
REGISTRATION_EXTRA_FIELDS,
)
-# Add some host names used in assorted tests
+# Allow all hosts during tests, we use a lot of different ones all over the codebase.
ALLOWED_HOSTS = [
- 'localhost',
- 'logistration.testserver',
- '.testserver.fake',
- 'test-site.testserver',
- 'testserver.fakeother',
- 'edx.org',
- 'microsite.example.com',
- 'testserver2',
+ '*'
]
# mongo connection settings
diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py
index bec5d01179..298fb79001 100644
--- a/common/djangoapps/student/helpers.py
+++ b/common/djangoapps/student/helpers.py
@@ -1,24 +1,55 @@
-"""Helpers for the student app. """
+"""
+Helpers for the student app.
+"""
+import json
import logging
import mimetypes
import urllib
import urlparse
from datetime import datetime
+import django
from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.urlresolvers import NoReverseMatch, reverse
+from django.core.validators import ValidationError, validate_email
+from django.contrib.auth import authenticate, load_backend, login, logout
+from django.contrib.auth.models import User
+from django.db import IntegrityError, transaction
from django.utils import http
+from django.utils.translation import ugettext as _
from oauth2_provider.models import AccessToken as dot_access_token
from oauth2_provider.models import RefreshToken as dot_refresh_token
from provider.oauth2.models import AccessToken as dop_access_token
from provider.oauth2.models import RefreshToken as dop_refresh_token
from pytz import UTC
-
+from six import iteritems, text_type
import third_party_auth
from course_modes.models import CourseMode
+from lms.djangoapps.certificates.api import ( # pylint: disable=import-error
+ get_certificate_url,
+ has_html_certificates_enabled
+)
+from lms.djangoapps.certificates.models import ( # pylint: disable=import-error
+ CertificateStatuses,
+ certificate_status_for_student
+)
+from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
+from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.djangoapps.theming import helpers as theming_helpers
from openedx.core.djangoapps.theming.helpers import get_themes
+from student.models import (
+ CourseEnrollment,
+ LinkedInAddToProfileConfiguration,
+ PasswordHistory,
+ Registration,
+ UserAttribute,
+ UserProfile,
+ unique_id_for_user
+)
+
# Enumeration of per-course verification statuses
# we display on the student dashboard.
@@ -186,7 +217,7 @@ def check_verify_status_by_course(user, course_enrollments):
}
if recent_verification_datetime:
- for key, value in status_by_course.iteritems(): # pylint: disable=unused-variable
+ for key, value in iteritems(status_by_course): # pylint: disable=unused-variable
status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y")
return status_by_course
@@ -348,3 +379,304 @@ def destroy_oauth_tokens(user):
dop_refresh_token.objects.filter(user=user.id).delete()
dot_access_token.objects.filter(user=user.id).delete()
dot_refresh_token.objects.filter(user=user.id).delete()
+
+
+def generate_activation_email_context(user, registration):
+ """
+ Constructs a dictionary for use in activation email contexts
+
+ Arguments:
+ user (User): Currently logged-in user
+ registration (Registration): Registration object for the currently logged-in user
+ """
+ return {
+ 'name': user.profile.name,
+ 'key': registration.activation_key,
+ 'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
+ 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
+ 'support_url': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
+ 'support_email': configuration_helpers.get_value('CONTACT_EMAIL', settings.CONTACT_EMAIL),
+ }
+
+
+def create_or_set_user_attribute_created_on_site(user, site):
+ """
+ Create or Set UserAttribute indicating the microsite site the user account was created on.
+ User maybe created on 'courses.edx.org', or a white-label site
+ """
+ if site:
+ UserAttribute.set_user_attribute(user, 'created_on_site', site.domain)
+
+
+# TODO: Remove Django 1.11 upgrade shim
+# SHIM: Compensate for behavior change of default authentication backend in 1.10
+if django.VERSION < (1, 10):
+ NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.ModelBackend'
+else:
+ # We want to allow inactive users to log in only when their account is first created
+ NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.AllowAllUsersModelBackend'
+
+# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint
+# pylint: disable=logging-format-interpolation
+
+
+def authenticate_new_user(request, username, password):
+ """
+ Immediately after a user creates an account, we log them in. They are only
+ logged in until they close the browser. They can't log in again until they click
+ the activation link from the email.
+ """
+ backend = load_backend(NEW_USER_AUTH_BACKEND)
+ user = backend.authenticate(request=request, username=username, password=password)
+ user.backend = NEW_USER_AUTH_BACKEND
+ return user
+
+
+class AccountValidationError(Exception):
+ """
+ Used in account creation views to raise exceptions with details about specific invalid fields
+ """
+ def __init__(self, message, field):
+ super(AccountValidationError, self).__init__(message)
+ self.field = field
+
+
+def cert_info(user, course_overview):
+ """
+ Get the certificate info needed to render the dashboard section for the given
+ student and course.
+
+ Arguments:
+ user (User): A user.
+ course_overview (CourseOverview): A course.
+
+ Returns:
+ dict: A dictionary with keys:
+ 'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or
+ 'certificate_earned_but_not_available'
+ 'download_url': url, only present if show_download_url is True
+ 'show_survey_button': bool
+ 'survey_url': url, only if show_survey_button is True
+ 'grade': if status is not 'processing'
+ 'can_unenroll': if status allows for unenrollment
+ """
+ return _cert_info(
+ user,
+ course_overview,
+ certificate_status_for_student(user, course_overview.id)
+ )
+
+
+def _cert_info(user, course_overview, cert_status):
+ """
+ Implements the logic for cert_info -- split out for testing.
+
+ Arguments:
+ user (User): A user.
+ course_overview (CourseOverview): A course.
+ """
+ # simplify the status for the template using this lookup table
+ template_state = {
+ CertificateStatuses.generating: 'generating',
+ CertificateStatuses.downloadable: 'downloadable',
+ CertificateStatuses.notpassing: 'notpassing',
+ CertificateStatuses.restricted: 'restricted',
+ CertificateStatuses.auditing: 'auditing',
+ CertificateStatuses.audit_passing: 'auditing',
+ CertificateStatuses.audit_notpassing: 'auditing',
+ CertificateStatuses.unverified: 'unverified',
+ }
+
+ certificate_earned_but_not_available_status = 'certificate_earned_but_not_available'
+ default_status = 'processing'
+
+ default_info = {
+ 'status': default_status,
+ 'show_survey_button': False,
+ 'can_unenroll': True,
+ }
+
+ if cert_status is None:
+ return default_info
+
+ status = template_state.get(cert_status['status'], default_status)
+ is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing')
+
+ if (
+ not certificates_viewable_for_course(course_overview) and
+ (status in CertificateStatuses.PASSED_STATUSES) and
+ course_overview.certificate_available_date
+ ):
+ status = certificate_earned_but_not_available_status
+
+ if (
+ course_overview.certificates_display_behavior == 'early_no_info' and
+ is_hidden_status
+ ):
+ return default_info
+
+ status_dict = {
+ 'status': status,
+ 'mode': cert_status.get('mode', None),
+ 'linked_in_url': None,
+ 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
+ }
+
+ if not status == default_status and course_overview.end_of_course_survey_url is not None:
+ status_dict.update({
+ 'show_survey_button': True,
+ 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
+ else:
+ status_dict['show_survey_button'] = False
+
+ if status == 'downloadable':
+ # showing the certificate web view button if certificate is downloadable state and feature flags are enabled.
+ if has_html_certificates_enabled(course_overview):
+ if course_overview.has_any_active_web_certificate:
+ status_dict.update({
+ 'show_cert_web_view': True,
+ 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid'])
+ })
+ else:
+ # don't show download certificate button if we don't have an active certificate for course
+ status_dict['status'] = 'unavailable'
+ elif 'download_url' not in cert_status:
+ log.warning(
+ u"User %s has a downloadable cert for %s, but no download url",
+ user.username,
+ course_overview.id
+ )
+ return default_info
+ else:
+ status_dict['download_url'] = cert_status['download_url']
+
+ # If enabled, show the LinkedIn "add to profile" button
+ # Clicking this button sends the user to LinkedIn where they
+ # can add the certificate information to their profile.
+ linkedin_config = LinkedInAddToProfileConfiguration.current()
+
+ # posting certificates to LinkedIn is not currently
+ # supported in White Labels
+ if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site():
+ status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
+ course_overview.id,
+ course_overview.display_name,
+ cert_status.get('mode'),
+ cert_status['download_url']
+ )
+
+ if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}:
+ cert_grade_percent = -1
+ persisted_grade_percent = -1
+ persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False)
+ if persisted_grade is not None:
+ persisted_grade_percent = persisted_grade.percent
+
+ if 'grade' in cert_status:
+ cert_grade_percent = float(cert_status['grade'])
+
+ if cert_grade_percent == -1 and persisted_grade_percent == -1:
+ # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
+ # who need to be regraded (we weren't tracking 'notpassing' at first).
+ # We can add a log.warning here once we think it shouldn't happen.
+ return default_info
+
+ status_dict['grade'] = text_type(max(cert_grade_percent, persisted_grade_percent))
+
+ return status_dict
+
+
+def process_survey_link(survey_link, user):
+ """
+ If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
+ Currently, this is sha1(user.username). Otherwise, return survey_link.
+ """
+ return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
+
+
+def do_create_account(form, custom_form=None):
+ """
+ Given cleaned post variables, create the User and UserProfile objects, as well as the
+ registration for this user.
+
+ Returns a tuple (User, UserProfile, Registration).
+
+ Note: this function is also used for creating test users.
+ """
+ # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
+ if not configuration_helpers.get_value(
+ 'ALLOW_PUBLIC_ACCOUNT_CREATION',
+ settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
+ ):
+ raise PermissionDenied()
+
+ errors = {}
+ errors.update(form.errors)
+ if custom_form:
+ errors.update(custom_form.errors)
+
+ if errors:
+ raise ValidationError(errors)
+
+ user = User(
+ username=form.cleaned_data["username"],
+ email=form.cleaned_data["email"],
+ is_active=False
+ )
+ user.set_password(form.cleaned_data["password"])
+ registration = Registration()
+
+ # TODO: Rearrange so that if part of the process fails, the whole process fails.
+ # Right now, we can have e.g. no registration e-mail sent out and a zombie account
+ try:
+ with transaction.atomic():
+ user.save()
+ if custom_form:
+ custom_model = custom_form.save(commit=False)
+ custom_model.user = user
+ custom_model.save()
+ except IntegrityError:
+ # Figure out the cause of the integrity error
+ # TODO duplicate email is already handled by form.errors above as a ValidationError.
+ # The checks for duplicate email/username should occur in the same place with an
+ # AccountValidationError and a consistent user message returned (i.e. both should
+ # return "It looks like {username} belongs to an existing account. Try again with a
+ # different username.")
+ if len(User.objects.filter(username=user.username)) > 0:
+ raise AccountValidationError(
+ _("An account with the Public Username '{username}' already exists.").format(username=user.username),
+ field="username"
+ )
+ elif len(User.objects.filter(email=user.email)) > 0:
+ raise AccountValidationError(
+ _("An account with the Email '{email}' already exists.").format(email=user.email),
+ field="email"
+ )
+ else:
+ raise
+
+ # add this account creation to password history
+ # NOTE, this will be a NOP unless the feature has been turned on in configuration
+ password_history_entry = PasswordHistory()
+ password_history_entry.create(user)
+
+ registration.register(user)
+
+ profile_fields = [
+ "name", "level_of_education", "gender", "mailing_address", "city", "country", "goals",
+ "year_of_birth"
+ ]
+ profile = UserProfile(
+ user=user,
+ **{key: form.cleaned_data.get(key) for key in profile_fields}
+ )
+ extended_profile = form.cleaned_extended_profile
+ if extended_profile:
+ profile.meta = json.dumps(extended_profile)
+ try:
+ profile.save()
+ except Exception: # pylint: disable=broad-except
+ log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
+ raise
+
+ return user, profile, registration
diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py
index 089d0845a2..64485634dc 100644
--- a/common/djangoapps/student/tests/test_certificates.py
+++ b/common/djangoapps/student/tests/test_certificates.py
@@ -167,7 +167,7 @@ class CertificateDisplayTest(CertificateDisplayTestBase):
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_no_certificate_status_no_problem(self):
- with patch('student.views.cert_info', return_value={}):
+ with patch('student.views.dashboard.cert_info', return_value={}):
self._create_certificate('honor')
self._check_can_not_download_certificate()
diff --git a/common/djangoapps/student/tests/test_credit.py b/common/djangoapps/student/tests/test_credit.py
index 73c3373bad..68a45ffa69 100644
--- a/common/djangoapps/student/tests/test_credit.py
+++ b/common/djangoapps/student/tests/test_credit.py
@@ -233,7 +233,7 @@ class CreditCourseDashboardTest(ModuleStoreTestCase):
self._make_eligible()
# The user should have the option to purchase credit
- with patch('student.views.get_credit_provider_display_names') as mock_method:
+ with patch('student.views.dashboard.get_credit_provider_display_names') as mock_method:
mock_method.return_value = providers_list
response = self._load_dashboard()
diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py
index 7f1752777b..36e0c2ce1c 100644
--- a/common/djangoapps/student/tests/test_email.py
+++ b/common/djangoapps/student/tests/test_email.py
@@ -22,37 +22,45 @@ from student.views import (
SETTING_CHANGE_INITIATED,
confirm_email_change,
do_email_change_request,
- generate_activation_email_context,
- reactivation_email_for_user,
validate_new_email
)
+from student.views import generate_activation_email_context, send_reactivation_email_for_user
from third_party_auth.views import inactive_user_view
from util.request import safe_get_host
from util.testing import EventTestMixin
class TestException(Exception):
- """Exception used for testing that nothing will catch explicitly"""
+ """
+ Exception used for testing that nothing will catch explicitly
+ """
pass
def mock_render_to_string(template_name, context):
- """Return a string that encodes template_name and context"""
+ """
+ Return a string that encodes template_name and context
+ """
return str((template_name, sorted(context.iteritems())))
def mock_render_to_response(template_name, context):
- """Return an HttpResponse with content that encodes template_name and context"""
+ """
+ Return an HttpResponse with content that encodes template_name and context
+ """
# This simulates any db access in the templates.
UserProfile.objects.exists()
return HttpResponse(mock_render_to_string(template_name, context))
class EmailTestMixin(object):
- """Adds useful assertions for testing `email_user`"""
+ """
+ Adds useful assertions for testing `email_user`
+ """
def assertEmailUser(self, email_user, subject_template, subject_context, body_template, body_context):
- """Assert that `email_user` was used to send and email with the supplied subject and body
+ """
+ Assert that `email_user` was used to send and email with the supplied subject and body
`email_user`: The mock `django.contrib.auth.models.User.email_user` function
to verify
@@ -68,14 +76,18 @@ class EmailTestMixin(object):
)
def append_allowed_hosts(self, hostname):
- """ Append hostname to settings.ALLOWED_HOSTS """
+ """
+ Append hostname to settings.ALLOWED_HOSTS
+ """
settings.ALLOWED_HOSTS.append(hostname)
self.addCleanup(settings.ALLOWED_HOSTS.pop)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ActivationEmailTests(TestCase):
- """Test sending of the activation email. """
+ """
+ Test sending of the activation email.
+ """
ACTIVATION_SUBJECT = u"Action Required: Activate your {} account".format(settings.PLATFORM_NAME)
@@ -100,7 +112,9 @@ class ActivationEmailTests(TestCase):
self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS)
def _create_account(self):
- """Create an account, triggering the activation email. """
+ """
+ Create an account, triggering the activation email.
+ """
url = reverse('create_account')
params = {
'username': 'test_user',
@@ -120,7 +134,9 @@ class ActivationEmailTests(TestCase):
)
def _assert_activation_email(self, subject, body_fragments):
- """Verify that the activation email was sent. """
+ """
+ Verify that the activation email was sent.
+ """
self.assertEqual(len(mail.outbox), 1)
msg = mail.outbox[0]
self.assertEqual(msg.subject, subject)
@@ -146,10 +162,12 @@ class ActivationEmailTests(TestCase):
)
-@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+@patch('student.views.login.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
@patch('django.contrib.auth.models.User.email_user')
class ReactivationEmailTests(EmailTestMixin, TestCase):
- """Test sending a reactivation email to a user"""
+ """
+ Test sending a reactivation email to a user
+ """
def setUp(self):
super(ReactivationEmailTests, self).setUp()
@@ -162,10 +180,12 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
Send the reactivation email to the specified user,
and return the response as json data.
"""
- return json.loads(reactivation_email_for_user(user).content)
+ return json.loads(send_reactivation_email_for_user(user).content)
def assertReactivateEmailSent(self, email_user):
- """Assert that the correct reactivation email has been sent"""
+ """
+ Assert that the correct reactivation email has been sent
+ """
context = generate_activation_email_context(self.user, self.registration)
self.assertEmailUser(
@@ -222,10 +242,12 @@ class ReactivationEmailTests(EmailTestMixin, TestCase):
class EmailChangeRequestTests(EventTestMixin, TestCase):
- """Test changing a user's email address"""
+ """
+ Test changing a user's email address
+ """
def setUp(self):
- super(EmailChangeRequestTests, self).setUp('student.views.tracker')
+ super(EmailChangeRequestTests, self).setUp('student.views.management.tracker')
self.user = UserFactory.create()
self.new_email = 'new.email@edx.org'
self.req_factory = RequestFactory()
@@ -237,28 +259,36 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
self.user.email_user = Mock()
def do_email_validation(self, email):
- """Executes validate_new_email, returning any resulting error message. """
+ """
+ Executes validate_new_email, returning any resulting error message.
+ """
try:
validate_new_email(self.request.user, email)
except ValueError as err:
return err.message
def do_email_change(self, user, email, activation_key=None):
- """Executes do_email_change_request, returning any resulting error message. """
+ """
+ Executes do_email_change_request, returning any resulting error message.
+ """
try:
do_email_change_request(user, email, activation_key)
except ValueError as err:
return err.message
def assertFailedRequest(self, response_data, expected_error):
- """Assert that `response_data` indicates a failed request that returns `expected_error`"""
+ """
+ Assert that `response_data` indicates a failed request that returns `expected_error`
+ """
self.assertFalse(response_data['success'])
self.assertEquals(expected_error, response_data['error'])
self.assertFalse(self.user.email_user.called)
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_duplicate_activation_key(self):
- """Assert that if two users change Email address simultaneously, no error is thrown"""
+ """
+ Assert that if two users change Email address simultaneously, no error is thrown
+ """
# New emails for the users
user1_new_email = "valid_user1_email@example.com"
@@ -280,7 +310,9 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
self.assertEqual(self.do_email_validation(email), '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. """
+ """
+ Test the error message if user attempts to change email to the existing value.
+ """
self.assertEqual(self.do_email_validation(self.user.email), 'Old email is the same as the new email.')
def test_duplicate_email(self):
@@ -292,9 +324,11 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
self.assertEqual(self.do_email_validation(self.new_email), 'An account with this e-mail already exists.')
@patch('django.core.mail.send_mail')
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.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. """
+ """
+ Test the return value if sending the email for the user to click fails.
+ """
send_mail.side_effect = [Exception, None]
self.assertEqual(
self.do_email_change(self.user, "valid@email.com"),
@@ -303,9 +337,11 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
self.assert_no_events_were_emitted()
@patch('django.core.mail.send_mail')
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.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. """
+ """
+ Test email was sent if no errors encountered.
+ """
old_email = self.user.email
new_email = "valid@example.com"
registration_key = "test registration key"
@@ -327,10 +363,12 @@ class EmailChangeRequestTests(EventTestMixin, TestCase):
@patch('django.contrib.auth.models.User.email_user')
-@patch('student.views.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True))
-@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+@patch('student.views.management.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True))
+@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase):
- """Test that confirmation of email change requests function even in the face of exceptions thrown while sending email"""
+ """
+ Test that confirmation of email change requests function even in the face of exceptions thrown while sending email
+ """
def setUp(self):
super(EmailChangeConfirmationTests, self).setUp()
self.user = UserFactory.create()
@@ -343,18 +381,23 @@ class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase):
self.key = self.pending_change_request.activation_key
def assertRolledBack(self):
- """Assert that no changes to user, profile, or pending email have been made to the db"""
+ """
+ Assert that no changes to user, profile, or pending email have been made to the db
+ """
self.assertEquals(self.user.email, User.objects.get(username=self.user.username).email)
self.assertEquals(self.profile.meta, UserProfile.objects.get(user=self.user).meta)
self.assertEquals(1, PendingEmailChange.objects.count())
def assertFailedBeforeEmailing(self, email_user):
- """Assert that the function failed before emailing a user"""
+ """
+ Assert that the function failed before emailing a user
+ """
self.assertRolledBack()
self.assertFalse(email_user.called)
def check_confirm_email_change(self, expected_template, expected_context):
- """Call `confirm_email_change` and assert that the content was generated as expected
+ """
+ Call `confirm_email_change` and assert that the content was generated as expected
`expected_template`: The name of the template that should have been used
to generate the content
@@ -368,7 +411,9 @@ class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase):
)
def assertChangeEmailSent(self, email_user):
- """Assert that the correct email was sent to confirm an email change"""
+ """
+ Assert that the correct email was sent to confirm an email change
+ """
context = {
'old_email': self.user.email,
'new_email': self.pending_change_request.new_email,
diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py
index 6d9040fb87..4fe33807fb 100644
--- a/common/djangoapps/student/tests/test_login.py
+++ b/common/djangoapps/student/tests/test_login.py
@@ -84,7 +84,11 @@ class LoginTest(CacheIsolationTestCase):
def test_login_fail_no_user_exists(self):
nonexistent_email = u'not_a_user@edx.org'
- response, mock_audit_log = self._login_response(nonexistent_email, 'test_password')
+ response, mock_audit_log = self._login_response(
+ nonexistent_email,
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
@@ -92,7 +96,11 @@ class LoginTest(CacheIsolationTestCase):
@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True})
def test_login_fail_incorrect_email_with_advanced_security(self):
nonexistent_email = u'not_a_user@edx.org'
- response, mock_audit_log = self._login_response(nonexistent_email, 'test_password')
+ response, mock_audit_log = self._login_response(
+ nonexistent_email,
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
@@ -100,21 +108,33 @@ class LoginTest(CacheIsolationTestCase):
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
def test_login_fail_no_user_exists_no_pii(self):
nonexistent_email = u'not_a_user@edx.org'
- response, mock_audit_log = self._login_response(nonexistent_email, 'test_password')
+ response, mock_audit_log = self._login_response(
+ nonexistent_email,
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email'])
self._assert_not_in_audit_log(mock_audit_log, 'warning', [nonexistent_email])
def test_login_fail_wrong_password(self):
- response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password')
+ response, mock_audit_log = self._login_response(
+ 'test@edx.org',
+ 'wrong_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid'])
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
def test_login_fail_wrong_password_no_pii(self):
- response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password')
+ response, mock_audit_log = self._login_response(
+ 'test@edx.org',
+ 'wrong_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'invalid'])
@@ -126,7 +146,11 @@ class LoginTest(CacheIsolationTestCase):
self.user.save()
# Should now be unable to login
- response, mock_audit_log = self._login_response('test@edx.org', 'test_password')
+ response, mock_audit_log = self._login_response(
+ 'test@edx.org',
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value="In order to sign in, you need to activate your account.")
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user'])
@@ -138,7 +162,11 @@ class LoginTest(CacheIsolationTestCase):
self.user.save()
# Should now be unable to login
- response, mock_audit_log = self._login_response('test@edx.org', 'test_password')
+ response, mock_audit_log = self._login_response(
+ 'test@edx.org',
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False,
value="In order to sign in, you need to activate your account.")
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user'])
@@ -146,13 +174,21 @@ class LoginTest(CacheIsolationTestCase):
def test_login_unicode_email(self):
unicode_email = u'test@edx.org' + unichr(40960)
- response, mock_audit_log = self._login_response(unicode_email, 'test_password')
+ response, mock_audit_log = self._login_response(
+ unicode_email,
+ 'test_password',
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False)
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email])
def test_login_unicode_password(self):
unicode_password = u'test_password' + unichr(1972)
- response, mock_audit_log = self._login_response('test@edx.org', unicode_password)
+ response, mock_audit_log = self._login_response(
+ 'test@edx.org',
+ unicode_password,
+ 'student.views.login.AUDIT_LOG'
+ )
self._assert_response(response, success=False)
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid'])
@@ -378,7 +414,9 @@ class LoginTest(CacheIsolationTestCase):
self._assert_response(response, success=True)
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG', extra_post_params=None):
- ''' Post the login info '''
+ """
+ Post the login info
+ """
post_params = {'email': email, 'password': password}
if extra_post_params is not None:
post_params.update(extra_post_params)
@@ -387,7 +425,7 @@ class LoginTest(CacheIsolationTestCase):
return result, mock_audit_log
def _assert_response(self, response, success=None, value=None):
- '''
+ """
Assert that the response had status 200 and returned a valid
JSON-parseable dict.
@@ -396,7 +434,7 @@ class LoginTest(CacheIsolationTestCase):
If value is provided, assert that the response contained that
value for 'value' in the JSON dict.
- '''
+ """
self.assertEqual(response.status_code, 200)
try:
@@ -499,16 +537,16 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
Tests the redirects when visiting course-specific URL with @login_required.
Should vary by course depending on its enrollment_domain
"""
- TARGET_URL = reverse('courseware', args=[text_type(self.course.id)]) # pylint: disable=invalid-name
- noshib_response = self.client.get(TARGET_URL, follow=True, HTTP_ACCEPT="text/html")
+ target_url = reverse('courseware', args=[text_type(self.course.id)])
+ noshib_response = self.client.get(target_url, follow=True, HTTP_ACCEPT="text/html")
self.assertEqual(noshib_response.redirect_chain[-1],
- (expected_redirect_url('/login?next={url}'.format(url=TARGET_URL)), 302))
+ (expected_redirect_url('/login?next={url}'.format(url=target_url)), 302))
self.assertContains(noshib_response, (u"Sign in or Register | {platform_name}"
.format(platform_name=settings.PLATFORM_NAME)))
self.assertEqual(noshib_response.status_code, 200)
- TARGET_URL_SHIB = reverse('courseware', args=[text_type(self.shib_course.id)]) # pylint: disable=invalid-name
- shib_response = self.client.get(**{'path': TARGET_URL_SHIB,
+ target_url_shib = reverse('courseware', args=[text_type(self.shib_course.id)])
+ shib_response = self.client.get(**{'path': target_url_shib,
'follow': True,
'REMOTE_USER': self.extauth.external_id,
'Shib-Identity-Provider': 'https://idp.stanford.edu/',
@@ -517,9 +555,9 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
# The 'courseware' page actually causes a redirect itself, so it's not the end of the chain and we
# won't test its contents
self.assertEqual(shib_response.redirect_chain[-3],
- (expected_redirect_url('/shib-login/?next={url}'.format(url=TARGET_URL_SHIB)), 302))
+ (expected_redirect_url('/shib-login/?next={url}'.format(url=target_url_shib)), 302))
self.assertEqual(shib_response.redirect_chain[-2],
- (expected_redirect_url(TARGET_URL_SHIB), 302))
+ (expected_redirect_url(target_url_shib), 302))
self.assertEqual(shib_response.status_code, 200)
diff --git a/common/djangoapps/student/tests/test_recent_enrollments.py b/common/djangoapps/student/tests/test_recent_enrollments.py
index 542f484be1..8050992cbd 100644
--- a/common/djangoapps/student/tests/test_recent_enrollments.py
+++ b/common/djangoapps/student/tests/test_recent_enrollments.py
@@ -18,7 +18,8 @@ from openedx.core.djangoapps.site_configuration.tests.test_util import with_site
from shoppingcart.models import DonationConfiguration
from student.models import CourseEnrollment, DashboardConfiguration
from student.tests.factories import UserFactory
-from student.views import _get_recently_enrolled_courses, get_course_enrollments
+from student.views import get_course_enrollments
+from student.views.dashboard import _get_recently_enrolled_courses
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py
index 61b2488db3..7175949d1c 100644
--- a/common/djangoapps/student/tests/test_reset_password.py
+++ b/common/djangoapps/student/tests/test_reset_password.py
@@ -44,7 +44,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
ENABLED_CACHES = ['default']
def setUp(self):
- super(ResetPasswordTests, self).setUp('student.views.tracker')
+ super(ResetPasswordTests, self).setUp('student.views.management.tracker')
self.user = UserFactory.create()
self.user.is_active = False
self.user.save()
@@ -56,7 +56,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX
self.user_bad_passwd.save()
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX"""
@@ -71,7 +71,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
})
self.assert_no_events_were_emitted()
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email."""
@@ -88,7 +88,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
})
self.assert_no_events_were_emitted()
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_password_reset_ratelimited(self):
""" Try (and fail) resetting password 30 times in a row on an non-existant email address """
cache.clear()
@@ -110,7 +110,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('django.core.mail.send_mail')
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_reset_password_email(self, send_email):
"""Tests contents of reset password email, and that user is not active"""
@@ -310,7 +310,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
self.assertEqual(response.context_data['err_msg'], password_dict['error_message'])
- @patch('student.views.password_reset_confirm')
+ @patch('student.views.management.password_reset_confirm')
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
def test_reset_password_good_token_configuration_override(self, reset_confirm):
"""Tests password reset confirmation page for site configuration override."""
diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py
index 8a9deab0ba..cf63aa82d1 100644
--- a/common/djangoapps/student/tests/test_views.py
+++ b/common/djangoapps/student/tests/test_views.py
@@ -63,7 +63,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase):
self.cert_status = 'processing'
self.client.login(username=self.user.username, password=PASSWORD)
- def mock_cert(self, _user, _course_overview, _course_mode):
+ def mock_cert(self, _user, _course_overview):
""" Return a preset certificate status. """
return {
'status': self.cert_status,
@@ -86,7 +86,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase):
""" Assert that the unenroll action is shown or not based on the cert status."""
self.cert_status = cert_status
- with patch('student.views.cert_info', side_effect=self.mock_cert):
+ with patch('student.views.dashboard.cert_info', side_effect=self.mock_cert):
response = self.client.get(reverse('dashboard'))
self.assertEqual(pq(response.content)(self.UNENROLL_ELEMENT_ID).length, unenroll_action_count)
@@ -104,7 +104,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase):
""" Assert that the unenroll method is called or not based on the cert status"""
self.cert_status = cert_status
- with patch('student.views.cert_info', side_effect=self.mock_cert):
+ with patch('student.views.management.cert_info', side_effect=self.mock_cert):
with patch('lms.djangoapps.commerce.signals.handle_refund_order') as mock_refund_handler:
REFUND_ORDER.connect(mock_refund_handler)
response = self.client.post(
@@ -354,8 +354,8 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertNotIn('
', response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
- @patch('student.views.get_visible_sessions_for_entitlement')
- @patch('student.views.get_pseudo_session_for_entitlement')
+ @patch('student.views.dashboard.get_visible_sessions_for_entitlement')
+ @patch('student.views.dashboard.get_pseudo_session_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
def test_unfulfilled_entitlement(self, mock_course_overview, mock_pseudo_session,
mock_course_runs, mock_get_programs):
@@ -411,7 +411,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertIn('You must select a session to access the course.', response.content)
self.assertNotIn('To access the course, select a session.', response.content)
- @patch('student.views.get_visible_sessions_for_entitlement')
+ @patch('student.views.dashboard.get_visible_sessions_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
def test_unfulfilled_expired_entitlement(self, mock_course_overview, mock_course_runs):
"""
@@ -504,7 +504,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
# self.assertNotIn(noAvailableSessions, response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
- @patch('student.views.get_visible_sessions_for_entitlement')
+ @patch('student.views.dashboard.get_visible_sessions_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_fulfilled_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs):
@@ -541,7 +541,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertIn('Related Programs:', response.content)
@patch('openedx.core.djangoapps.programs.utils.get_programs')
- @patch('student.views.get_visible_sessions_for_entitlement')
+ @patch('student.views.dashboard.get_visible_sessions_for_entitlement')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_fulfilled_expired_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs):
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index a1753204ec..31df861188 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -35,6 +35,7 @@ from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, Pr
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
+from student.helpers import _cert_info, process_survey_link
from student.models import (
CourseEnrollment,
LinkedInAddToProfileConfiguration,
@@ -44,7 +45,7 @@ from student.models import (
user_by_anonymous_id
)
from student.tests.factories import CourseEnrollmentFactory, UserFactory
-from student.views import _cert_info, complete_course_mode_info, process_survey_link
+from student.views import complete_course_mode_info
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreEnum, ModuleStoreTestCase, SharedModuleStoreTestCase
@@ -78,10 +79,9 @@ class CourseEndingTest(TestCase):
certificates_display_behavior='end',
id=CourseLocator(org="x", course="y", run="z"),
)
- course_mode = 'honor'
self.assertEqual(
- _cert_info(user, course, None, course_mode),
+ _cert_info(user, course, None),
{
'status': 'processing',
'show_survey_button': False,
@@ -91,7 +91,7 @@ class CourseEndingTest(TestCase):
cert_status = {'status': 'unavailable'}
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'processing',
'show_survey_button': False,
@@ -105,7 +105,7 @@ class CourseEndingTest(TestCase):
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = Mock(percent=1.0)
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'generating',
'show_survey_button': True,
@@ -119,7 +119,7 @@ class CourseEndingTest(TestCase):
cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor'}
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'generating',
'show_survey_button': True,
@@ -140,7 +140,7 @@ class CourseEndingTest(TestCase):
}
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'downloadable',
'download_url': download_url,
@@ -159,7 +159,7 @@ class CourseEndingTest(TestCase):
'mode': 'honor'
}
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'notpassing',
'show_survey_button': True,
@@ -178,7 +178,7 @@ class CourseEndingTest(TestCase):
'download_url': download_url, 'mode': 'honor'
}
self.assertEqual(
- _cert_info(user, course2, cert_status, course_mode),
+ _cert_info(user, course2, cert_status),
{
'status': 'notpassing',
'show_survey_button': False,
@@ -193,7 +193,7 @@ class CourseEndingTest(TestCase):
course2.certificates_display_behavior = 'early_no_info'
cert_status = {'status': 'unavailable'}
self.assertEqual(
- _cert_info(user, course2, cert_status, course_mode),
+ _cert_info(user, course2, cert_status),
{
'status': 'processing',
'show_survey_button': False,
@@ -207,7 +207,7 @@ class CourseEndingTest(TestCase):
'mode': 'honor'
}
self.assertEqual(
- _cert_info(user, course2, cert_status, course_mode),
+ _cert_info(user, course2, cert_status),
{
'status': 'processing',
'show_survey_button': False,
@@ -239,7 +239,6 @@ class CourseEndingTest(TestCase):
certificates_display_behavior='end',
id=CourseLocator(org="x", course="y", run="z"),
)
- course_mode = 'honor'
if cert_grade is not None:
cert_status = {'status': 'generating', 'grade': unicode(cert_grade), 'mode': 'honor'}
@@ -249,7 +248,7 @@ class CourseEndingTest(TestCase):
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = Mock(percent=persisted_grade)
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'generating',
'show_survey_button': True,
@@ -274,13 +273,12 @@ class CourseEndingTest(TestCase):
certificates_display_behavior='end',
id=CourseLocator(org="x", course="y", run="z"),
)
- course_mode = 'honor'
cert_status = {'status': 'generating', 'mode': 'honor'}
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = None
self.assertEqual(
- _cert_info(user, course, cert_status, course_mode),
+ _cert_info(user, course, cert_status),
{
'status': 'processing',
'show_survey_button': False,
diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py
index d7daecf96f..21be51b267 100644
--- a/common/djangoapps/student/urls.py
+++ b/common/djangoapps/student/urls.py
@@ -6,39 +6,39 @@ from django.conf import settings
from django.conf.urls import url
from django.contrib.auth.views import password_reset_complete
-import student.views
+from . import views
urlpatterns = [
- url(r'^logout$', student.views.LogoutView.as_view(), name='logout'),
+ url(r'^logout$', views.LogoutView.as_view(), name='logout'),
# TODO: standardize login
# login endpoint used by cms.
- url(r'^login_post$', student.views.login_user, name='login_post'),
+ url(r'^login_post$', views.login_user, name='login_post'),
# login endpoints used by lms.
- url(r'^login_ajax$', student.views.login_user, name="login"),
- url(r'^login_ajax/(?P[^/]*)$', student.views.login_user),
+ url(r'^login_ajax$', views.login_user, name="login"),
+ url(r'^login_ajax/(?P[^/]*)$', views.login_user),
- url(r'^email_confirm/(?P[^/]*)$', student.views.confirm_email_change, name='confirm_email_change'),
+ url(r'^email_confirm/(?P[^/]*)$', views.confirm_email_change, name='confirm_email_change'),
- url(r'^create_account$', student.views.create_account, name='create_account'),
- url(r'^activate/(?P[^/]*)$', student.views.activate_account, name="activate"),
+ url(r'^create_account$', views.create_account, name='create_account'),
+ url(r'^activate/(?P[^/]*)$', views.activate_account, name="activate"),
- url(r'^accounts/disable_account_ajax$', student.views.disable_account_ajax, name="disable_account_ajax"),
- url(r'^accounts/manage_user_standing', student.views.manage_user_standing, name='manage_user_standing'),
+ url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"),
+ url(r'^accounts/manage_user_standing', views.manage_user_standing, name='manage_user_standing'),
- url(r'^change_setting$', student.views.change_setting, name='change_setting'),
- url(r'^change_email_settings$', student.views.change_email_settings, name='change_email_settings'),
+ url(r'^change_setting$', views.change_setting, name='change_setting'),
+ url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'),
- # password reset in student.views (see below for password reset django views)
- url(r'^password_reset/$', student.views.password_reset, name='password_reset'),
+ # password reset in views (see below for password reset django views)
+ url(r'^password_reset/$', views.password_reset, name='password_reset'),
url(
r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$',
- student.views.password_reset_confirm_wrapper,
+ views.password_reset_confirm_wrapper,
name='password_reset_confirm',
),
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),
- student.views.course_run_refund_status,
+ views.course_run_refund_status,
name="course_run_refund_status"),
]
@@ -46,10 +46,10 @@ urlpatterns = [
# enable automatic login
if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
urlpatterns += [
- url(r'^auto_auth$', student.views.auto_auth),
+ url(r'^auto_auth$', views.auto_auth),
]
-# password reset django views (see above for password reset student.views)
+# password reset django views (see above for password reset views)
urlpatterns += [
# TODO: Replace with Mako-ized views
url(
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
deleted file mode 100644
index 98829a79ed..0000000000
--- a/common/djangoapps/student/views.py
+++ /dev/null
@@ -1,3165 +0,0 @@
-"""
-Student Views
-"""
-
-import datetime
-import json
-import logging
-import uuid
-import warnings
-from collections import defaultdict, namedtuple
-from urlparse import parse_qs, urlsplit, urlunsplit
-
-import django
-import analytics
-import edx_oauth2_provider
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth import authenticate, load_backend, login, logout
-from django.contrib.auth.decorators import login_required
-from django.contrib.auth.models import AnonymousUser, User
-from django.contrib.auth.views import password_reset_confirm
-from django.core import mail
-from django.template.context_processors import csrf
-from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
-from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
-from django.core.validators import ValidationError, validate_email
-from django.db import IntegrityError, transaction
-from django.db.models.signals import post_save
-from django.dispatch import Signal, receiver
-from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
-from django.shortcuts import redirect
-from django.template.response import TemplateResponse
-from django.utils.encoding import force_bytes, force_text
-from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode
-from django.utils.translation import ugettext as _
-from django.utils.translation import get_language, ungettext
-from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
-from django.views.decorators.http import require_GET, require_POST
-from django.views.generic import TemplateView
-from ipware.ip import get_ip
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey
-from opaque_keys.edx.locator import CourseLocator
-from provider.oauth2.models import Client
-from pytz import UTC
-from ratelimitbackend.exceptions import RateLimitException
-from requests import HTTPError
-from six import text_type
-from social_core.backends import oauth as social_oauth
-from social_core.exceptions import AuthAlreadyAssociated, AuthException
-from social_django import utils as social_utils
-
-import dogstats_wrapper as dog_stats_api
-import openedx.core.djangoapps.external_auth.views
-import third_party_auth
-from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY
-import track.views
-from bulk_email.models import BulkEmailFlag, Optout # pylint: disable=import-error
-from lms.djangoapps.certificates.api import get_certificate_url, has_html_certificates_enabled # pylint: disable=import-error
-from lms.djangoapps.certificates.models import ( # pylint: disable=import-error
- CertificateStatuses,
- GeneratedCertificate,
- certificate_status_for_student
-)
-from course_modes.models import CourseMode
-from courseware.access import has_access
-from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error
-from django_comment_common.models import assign_role
-from edxmako.shortcuts import render_to_response, render_to_string
-from entitlements.models import CourseEntitlement
-from eventtracking import tracker
-from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
-from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
-# Note that this lives in LMS, so this dependency should be refactored.
-from notification_prefs.views import enable_notifications
-from openedx.core.djangoapps import monitoring_utils
-from openedx.core.djangoapps.catalog.utils import (
- get_programs, get_programs_with_type, get_visible_sessions_for_entitlement, get_pseudo_session_for_entitlement
-)
-from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course
-from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
-from openedx.core.djangoapps.embargo import api as embargo_api
-from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login
-from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
-from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
-from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
-from openedx.core.djangoapps.programs.models import ProgramsApiConfig
-from openedx.core.djangoapps.programs.utils import (
- ProgramDataExtender,
- ProgramProgressMeter
-)
-from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from openedx.core.djangoapps.theming import helpers as theming_helpers
-from openedx.core.djangoapps.user_api import accounts as accounts_settings
-from openedx.core.djangoapps.user_api.preferences import api as preferences_api
-from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, WaffleFlag
-from openedx.core.djangolib.markup import HTML
-from openedx.features.course_experience import course_home_url_name
-from openedx.features.enterprise_support.api import get_dashboard_consent_notification
-from shoppingcart.api import order_history
-from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
-from student.cookies import delete_logged_in_cookies, set_logged_in_cookies, set_user_info_cookie
-from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
-from student.helpers import (
- DISABLE_UNENROLL_CERT_STATES,
- auth_pipeline_urls,
- check_verify_status_by_course,
- destroy_oauth_tokens,
- get_next_url_for_login_page
-)
-from student.models import (
- ALLOWEDTOENROLL_TO_ENROLLED,
- CourseAccessRole,
- CourseEnrollment,
- CourseEnrollmentAllowed,
- CourseEnrollmentAttribute,
- DashboardConfiguration,
- LinkedInAddToProfileConfiguration,
- LoginFailures,
- ManualEnrollmentAudit,
- PasswordHistory,
- PendingEmailChange,
- Registration,
- RegistrationCookieConfiguration,
- UserAttribute,
- UserProfile,
- UserSignupSource,
- UserStanding,
- anonymous_id_for_user,
- create_comments_service_user,
- unique_id_for_user
-)
-from student.signals import REFUND_ORDER
-from student.tasks import send_activation_email
-from student.text_me_the_app import TextMeTheAppFragmentView
-from third_party_auth import pipeline, provider
-from util.bad_request_rate_limiter import BadRequestRateLimiter
-from util.db import outer_atomic
-from util.json_request import JsonResponse
-from util.milestones_helpers import get_pre_requisite_courses_not_completed
-from util.password_policy_validators import validate_password_length, validate_password_strength
-from xmodule.modulestore.django import modulestore
-
-log = logging.getLogger("edx.student")
-AUDIT_LOG = logging.getLogger("audit")
-ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name
-SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
-# Used as the name of the user attribute for tracking affiliate registrations
-REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
-REGISTRATION_UTM_PARAMETERS = {
- 'utm_source': 'registration_utm_source',
- 'utm_medium': 'registration_utm_medium',
- 'utm_campaign': 'registration_utm_campaign',
- 'utm_term': 'registration_utm_term',
- 'utm_content': 'registration_utm_content',
-}
-REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
-# used to announce a registration
-REGISTER_USER = Signal(providing_args=["user", "registration"])
-
-# TODO: Remove Django 1.11 upgrade shim
-# SHIM: Compensate for behavior change of default authentication backend in 1.10
-if django.VERSION < (1, 10):
- NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.ModelBackend'
-else:
- # We want to allow inactive users to log in only when their account is first created
- NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.AllowAllUsersModelBackend'
-
-# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint
-# pylint: disable=logging-format-interpolation
-
-
-def authenticate_new_user(request, username, password):
- """
- Immediately after a user creates an account, we log them in. They are only
- logged in until they close the browser. They can't log in again until they click
- the activation link from the email.
- """
- backend = load_backend(NEW_USER_AUTH_BACKEND)
- user = backend.authenticate(request=request, username=username, password=password)
- user.backend = NEW_USER_AUTH_BACKEND
- return user
-
-
-def csrf_token(context):
- """A csrf token that can be included in a form."""
- token = context.get('csrf_token', '')
- if token == 'NOTPROVIDED':
- return ''
- return (u'' % (token))
-
-
-# NOTE: This view is not linked to directly--it is called from
-# branding/views.py:index(), which is cached for anonymous users.
-# This means that it should always return the same thing for anon
-# users. (in particular, no switching based on query params allowed)
-def index(request, extra_context=None, user=AnonymousUser()):
- """
- Render the edX main page.
-
- extra_context is used to allow immediate display of certain modal windows, eg signup,
- as used by external_auth.
- """
- if extra_context is None:
- extra_context = {}
-
- programs_list = []
- courses = get_courses(user)
-
- if configuration_helpers.get_value(
- "ENABLE_COURSE_SORTING_BY_START_DATE",
- settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"],
- ):
- courses = sort_by_start_date(courses)
- else:
- courses = sort_by_announcement(courses)
-
- context = {'courses': courses}
-
- context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html')
-
- # This appears to be an unused context parameter, at least for the master templates...
- context['show_partners'] = configuration_helpers.get_value('show_partners', True)
-
- # TO DISPLAY A YOUTUBE WELCOME VIDEO
- # 1) Change False to True
- context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False)
-
- # Maximum number of courses to display on the homepage.
- context['homepage_course_max'] = configuration_helpers.get_value(
- 'HOMEPAGE_COURSE_MAX', settings.HOMEPAGE_COURSE_MAX
- )
-
- # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration
- # Note: This value should be moved into a configuration setting and plumbed-through to the
- # context via the site configuration workflow, versus living here
- youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id")
- context['homepage_promo_video_youtube_id'] = youtube_video_id
-
- # allow for theme override of the courses list
- context['courses_list'] = theming_helpers.get_template_path('courses_list.html')
-
- # Insert additional context for use in the template
- context.update(extra_context)
-
- # Add marketable programs to the context.
- context['programs_list'] = get_programs_with_type(request.site, include_hidden=False)
-
- return render_to_response('index.html', context)
-
-
-def process_survey_link(survey_link, user):
- """
- If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
- Currently, this is sha1(user.username). Otherwise, return survey_link.
- """
- return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
-
-
-def cert_info(user, course_overview, course_mode):
- """
- Get the certificate info needed to render the dashboard section for the given
- student and course.
-
- Arguments:
- user (User): A user.
- course_overview (CourseOverview): A course.
- course_mode (str): The enrollment mode (honor, verified, audit, etc.)
-
- Returns:
- dict: A dictionary with keys:
- 'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or
- 'certificate_earned_but_not_available'
- 'download_url': url, only present if show_download_url is True
- 'show_survey_button': bool
- 'survey_url': url, only if show_survey_button is True
- 'grade': if status is not 'processing'
- 'can_unenroll': if status allows for unenrollment
- """
- return _cert_info(
- user,
- course_overview,
- certificate_status_for_student(user, course_overview.id),
- course_mode
- )
-
-
-def reverification_info(statuses):
- """
- Returns reverification-related information for *all* of user's enrollments whose
- reverification status is in statuses.
-
- Args:
- statuses (list): a list of reverification statuses we want information for
- example: ["must_reverify", "denied"]
-
- Returns:
- dictionary of lists: dictionary with one key per status, e.g.
- dict["must_reverify"] = []
- dict["must_reverify"] = [some information]
- """
- reverifications = defaultdict(list)
-
- # Sort the data by the reverification_end_date
- for status in statuses:
- if reverifications[status]:
- reverifications[status].sort(key=lambda x: x.date)
- return reverifications
-
-
-def get_course_enrollments(user, org_whitelist, org_blacklist):
- """
- Given a user, return a filtered set of his or her course enrollments.
-
- Arguments:
- user (User): the user in question.
- org_whitelist (list[str]): If not None, ONLY courses of these orgs will be returned.
- org_blacklist (list[str]): Courses of these orgs will be excluded.
-
- Returns:
- generator[CourseEnrollment]: a sequence of enrollments to be displayed
- on the user's dashboard.
- """
- for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user):
-
- # If the course is missing or broken, log an error and skip it.
- course_overview = enrollment.course_overview
- if not course_overview:
- log.error(
- "User %s enrolled in broken or non-existent course %s",
- user.username,
- enrollment.course_id
- )
- continue
-
- # Filter out anything that is not in the whitelist.
- if org_whitelist and course_overview.location.org not in org_whitelist:
- continue
-
- # Conversely, filter out any enrollments in the blacklist.
- elif org_blacklist and course_overview.location.org in org_blacklist:
- continue
-
- # Else, include the enrollment.
- else:
- yield enrollment
-
-
-def get_org_black_and_whitelist_for_site(user):
- """
- Returns the org blacklist and whitelist for the current site.
-
- Returns:
- (org_whitelist, org_blacklist): A tuple of lists of orgs that serve as
- either a blacklist or a whitelist of orgs for the current site. The
- whitelist takes precedence, and the blacklist is used if the
- whitelist is None.
- """
- # Default blacklist is empty.
- org_blacklist = None
- # Whitelist the orgs configured for the current site. Each site outside
- # of edx.org has a list of orgs associated with its configuration.
- org_whitelist = configuration_helpers.get_current_site_orgs()
-
- if not org_whitelist:
- # If there is no whitelist, the blacklist will include all orgs that
- # have been configured for any other sites. This applies to edx.org,
- # where it is easier to blacklist all other orgs.
- org_blacklist = configuration_helpers.get_all_orgs()
-
- return (org_whitelist, org_blacklist)
-
-
-def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disable=unused-argument
- """
- Implements the logic for cert_info -- split out for testing.
-
- Arguments:
- user (User): A user.
- course_overview (CourseOverview): A course.
- course_mode (str): The enrollment mode (honor, verified, audit, etc.)
- """
- # simplify the status for the template using this lookup table
- template_state = {
- CertificateStatuses.generating: 'generating',
- CertificateStatuses.downloadable: 'downloadable',
- CertificateStatuses.notpassing: 'notpassing',
- CertificateStatuses.restricted: 'restricted',
- CertificateStatuses.auditing: 'auditing',
- CertificateStatuses.audit_passing: 'auditing',
- CertificateStatuses.audit_notpassing: 'auditing',
- CertificateStatuses.unverified: 'unverified',
- }
-
- certificate_earned_but_not_available_status = 'certificate_earned_but_not_available'
- default_status = 'processing'
-
- default_info = {
- 'status': default_status,
- 'show_survey_button': False,
- 'can_unenroll': True,
- }
-
- if cert_status is None:
- return default_info
-
- status = template_state.get(cert_status['status'], default_status)
- is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing')
-
- if (
- not certificates_viewable_for_course(course_overview) and
- (status in CertificateStatuses.PASSED_STATUSES) and
- course_overview.certificate_available_date
- ):
- status = certificate_earned_but_not_available_status
-
- if (
- course_overview.certificates_display_behavior == 'early_no_info' and
- is_hidden_status
- ):
- return default_info
-
- status_dict = {
- 'status': status,
- 'mode': cert_status.get('mode', None),
- 'linked_in_url': None,
- 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
- }
-
- if not status == default_status and course_overview.end_of_course_survey_url is not None:
- status_dict.update({
- 'show_survey_button': True,
- 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
- else:
- status_dict['show_survey_button'] = False
-
- if status == 'downloadable':
- # showing the certificate web view button if certificate is downloadable state and feature flags are enabled.
- if has_html_certificates_enabled(course_overview):
- if course_overview.has_any_active_web_certificate:
- status_dict.update({
- 'show_cert_web_view': True,
- 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid'])
- })
- else:
- # don't show download certificate button if we don't have an active certificate for course
- status_dict['status'] = 'unavailable'
- elif 'download_url' not in cert_status:
- log.warning(
- u"User %s has a downloadable cert for %s, but no download url",
- user.username,
- course_overview.id
- )
- return default_info
- else:
- status_dict['download_url'] = cert_status['download_url']
-
- # If enabled, show the LinkedIn "add to profile" button
- # Clicking this button sends the user to LinkedIn where they
- # can add the certificate information to their profile.
- linkedin_config = LinkedInAddToProfileConfiguration.current()
-
- # posting certificates to LinkedIn is not currently
- # supported in White Labels
- if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site():
- status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
- course_overview.id,
- course_overview.display_name,
- cert_status.get('mode'),
- cert_status['download_url']
- )
-
- if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}:
- cert_grade_percent = -1
- persisted_grade_percent = -1
- persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False)
- if persisted_grade is not None:
- persisted_grade_percent = persisted_grade.percent
-
- if 'grade' in cert_status:
- cert_grade_percent = float(cert_status['grade'])
-
- if cert_grade_percent == -1 and persisted_grade_percent == -1:
- # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
- # who need to be regraded (we weren't tracking 'notpassing' at first).
- # We can add a log.warning here once we think it shouldn't happen.
- return default_info
-
- status_dict['grade'] = unicode(max(cert_grade_percent, persisted_grade_percent))
-
- return status_dict
-
-
-@ensure_csrf_cookie
-def signin_user(request):
- """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
- external_auth_response = external_auth_login(request)
- if external_auth_response is not None:
- return external_auth_response
- # Determine the URL to redirect to following login:
- redirect_to = get_next_url_for_login_page(request)
- if request.user.is_authenticated():
- return redirect(redirect_to)
-
- third_party_auth_error = None
- for msg in messages.get_messages(request):
- if msg.extra_tags.split()[0] == "social-auth":
- # msg may or may not be translated. Try translating [again] in case we are able to:
- third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string
- break
-
- context = {
- 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
- # Bool injected into JS to submit form if we're inside a running third-
- # party auth pipeline; distinct from the actual instance of the running
- # pipeline, if any.
- 'pipeline_running': 'true' if pipeline.running(request) else 'false',
- 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
- 'platform_name': configuration_helpers.get_value(
- 'platform_name',
- settings.PLATFORM_NAME
- ),
- 'third_party_auth_error': third_party_auth_error
- }
-
- return render_to_response('login.html', context)
-
-
-@ensure_csrf_cookie
-def register_user(request, extra_context=None):
- """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
- # Determine the URL to redirect to following login:
- redirect_to = get_next_url_for_login_page(request)
- if request.user.is_authenticated():
- return redirect(redirect_to)
-
- external_auth_response = external_auth_register(request)
- if external_auth_response is not None:
- return external_auth_response
-
- context = {
- 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
- 'email': '',
- 'name': '',
- 'running_pipeline': None,
- 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
- 'platform_name': configuration_helpers.get_value(
- 'platform_name',
- settings.PLATFORM_NAME
- ),
- 'selected_provider': '',
- 'username': '',
- }
-
- if extra_context is not None:
- context.update(extra_context)
-
- if context.get("extauth_domain", '').startswith(
- openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
- ):
- return render_to_response('register-shib.html', context)
-
- # If third-party auth is enabled, prepopulate the form with data from the
- # selected provider.
- if third_party_auth.is_enabled() and pipeline.running(request):
- running_pipeline = pipeline.get(request)
- current_provider = provider.Registry.get_from_pipeline(running_pipeline)
- if current_provider is not None:
- overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
- overrides['running_pipeline'] = running_pipeline
- overrides['selected_provider'] = current_provider.name
- context.update(overrides)
-
- return render_to_response('register.html', context)
-
-
-def complete_course_mode_info(course_id, enrollment, modes=None):
- """
- We would like to compute some more information from the given course modes
- and the user's current enrollment
-
- Returns the given information:
- - whether to show the course upsell information
- - numbers of days until they can't upsell anymore
- """
- if modes is None:
- modes = CourseMode.modes_for_course_dict(course_id)
-
- mode_info = {'show_upsell': False, 'days_for_upsell': None}
- # we want to know if the user is already enrolled as verified or credit and
- # if verified is an option.
- if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES:
- mode_info['show_upsell'] = True
- mode_info['verified_sku'] = modes['verified'].sku
- mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku
- # if there is an expiration date, find out how long from now it is
- if modes['verified'].expiration_datetime:
- today = datetime.datetime.now(UTC).date()
- mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
-
- return mode_info
-
-
-def is_course_blocked(request, redeemed_registration_codes, course_key):
- """Checking either registration is blocked or not ."""
- blocked = False
- for redeemed_registration in redeemed_registration_codes:
- # registration codes may be generated via Bulk Purchase Scenario
- # we have to check only for the invoice generated registration codes
- # that their invoice is valid or not
- if redeemed_registration.invoice_item:
- if not redeemed_registration.invoice_item.invoice.is_valid:
- blocked = True
- # disabling email notifications for unpaid registration courses
- Optout.objects.get_or_create(user=request.user, course_id=course_key)
- log.info(
- u"User %s (%s) opted out of receiving emails from course %s",
- request.user.username,
- request.user.email,
- course_key,
- )
- track.views.server_track(
- request,
- "change-email1-settings",
- {"receive_emails": "no", "course": text_type(course_key)},
- page='dashboard',
- )
- break
-
- return blocked
-
-
-def generate_activation_email_context(user, registration):
- """
- Constructs a dictionary for use in activation email contexts
-
- Arguments:
- user (User): Currently logged-in user
- registration (Registration): Registration object for the currently logged-in user
- """
- return {
- 'name': user.profile.name,
- 'key': registration.activation_key,
- 'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
- 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
- 'support_url': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
- 'support_email': configuration_helpers.get_value('CONTACT_EMAIL', settings.CONTACT_EMAIL),
- }
-
-
-def compose_and_send_activation_email(user, profile, user_registration=None):
- """
- Construct all the required params and send the activation email
- through celery task
-
- Arguments:
- user: current logged-in user
- profile: profile object of the current logged-in user
- user_registration: registration of the current logged-in user
- """
- dest_addr = user.email
- if user_registration is None:
- user_registration = Registration.objects.get(user=user)
- context = generate_activation_email_context(user, user_registration)
- subject = render_to_string('emails/activation_email_subject.txt', context)
- # Email subject *must not* contain newlines
- subject = ''.join(subject.splitlines())
- message_for_activation = render_to_string('emails/activation_email.txt', context)
- from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
- from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
- if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
- dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
- message_for_activation = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
- '-' * 80 + '\n\n' + message_for_activation)
- send_activation_email.delay(subject, message_for_activation, from_address, dest_addr)
-
-
-@login_required
-@ensure_csrf_cookie
-def dashboard(request):
- """
- Provides the LMS dashboard view
-
- TODO: This is lms specific and does not belong in common code.
-
- Arguments:
- request: The request object.
-
- Returns:
- The dashboard response.
-
- """
- user = request.user
- if not UserProfile.objects.filter(user=user).exists():
- return redirect(reverse('account_settings'))
-
- platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
- enable_verified_certificates = configuration_helpers.get_value(
- 'ENABLE_VERIFIED_CERTIFICATES',
- settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES')
- )
- display_course_modes_on_dashboard = configuration_helpers.get_value(
- 'DISPLAY_COURSE_MODES_ON_DASHBOARD',
- settings.FEATURES.get('DISPLAY_COURSE_MODES_ON_DASHBOARD', True)
- )
- activation_email_support_link = configuration_helpers.get_value(
- 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK
- ) or settings.SUPPORT_SITE_LINK
-
- # Get the org whitelist or the org blacklist for the current site
- site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site(user)
- course_enrollments = list(get_course_enrollments(user, site_org_whitelist, site_org_blacklist))
-
- # Get the entitlements for the user and a mapping to all available sessions for that entitlement
- # If an entitlement has no available sessions, pass through a mock course overview object
- course_entitlements = list(CourseEntitlement.get_active_entitlements_for_user(user))
- course_entitlement_available_sessions = {}
- unfulfilled_entitlement_pseudo_sessions = {}
- for course_entitlement in course_entitlements:
- course_entitlement.update_expired_at()
- available_sessions = get_visible_sessions_for_entitlement(course_entitlement)
- course_entitlement_available_sessions[str(course_entitlement.uuid)] = available_sessions
- if not course_entitlement.enrollment_course_run:
- # Unfulfilled entitlements need a mock session for metadata
- pseudo_session = get_pseudo_session_for_entitlement(course_entitlement)
- unfulfilled_entitlement_pseudo_sessions[str(course_entitlement.uuid)] = pseudo_session
-
- # Record how many courses there are so that we can get a better
- # understanding of usage patterns on prod.
- monitoring_utils.accumulate('num_courses', len(course_enrollments))
-
- # Sort the enrollment pairs by the enrollment date
- course_enrollments.sort(key=lambda x: x.created, reverse=True)
-
- # Retrieve the course modes for each course
- enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
- __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
- course_modes_by_course = {
- course_id: {
- mode.slug: mode
- for mode in modes
- }
- for course_id, modes in unexpired_course_modes.iteritems()
- }
-
- # Check to see if the student has recently enrolled in a course.
- # If so, display a notification message confirming the enrollment.
- enrollment_message = _create_recent_enrollment_message(
- course_enrollments, course_modes_by_course
- )
-
- course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
-
- sidebar_account_activation_message = ''
- banner_account_activation_message = ''
- display_account_activation_message_on_sidebar = configuration_helpers.get_value(
- 'DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR',
- settings.FEATURES.get('DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR', False)
- )
-
- # Display activation message in sidebar if DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR
- # flag is active. Otherwise display existing message at the top.
- if display_account_activation_message_on_sidebar and not user.is_active:
- sidebar_account_activation_message = render_to_string(
- 'registration/account_activation_sidebar_notice.html',
- {
- 'email': user.email,
- 'platform_name': platform_name,
- 'activation_email_support_link': activation_email_support_link
- }
- )
- elif not user.is_active:
- banner_account_activation_message = render_to_string(
- 'registration/activate_account_notice.html',
- {'email': user.email}
- )
-
- enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments)
-
- # Disable lookup of Enterprise consent_required_course due to ENT-727
- # Will re-enable after fixing WL-1315
- consent_required_courses = set()
- enterprise_customer_name = None
-
- # Account activation message
- account_activation_messages = [
- message for message in messages.get_messages(request) if 'account-activation' in message.tags
- ]
-
- # Global staff can see what courses encountered an error on their dashboard
- staff_access = False
- errored_courses = {}
- if has_access(user, 'staff', 'global'):
- # Show any courses that encountered an error on load
- staff_access = True
- errored_courses = modulestore().get_errored_courses()
-
- show_courseware_links_for = frozenset(
- enrollment.course_id for enrollment in course_enrollments
- if has_access(request.user, 'load', enrollment.course_overview)
- )
-
- # Find programs associated with course runs being displayed. This information
- # is passed in the template context to allow rendering of program-related
- # information on the dashboard.
- meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
- ecommerce_service = EcommerceService()
- inverted_programs = meter.invert_programs()
-
- urls, program_data = {}, {}
- bundles_on_dashboard_flag = WaffleFlag(WaffleFlagNamespace(name=u'student.experiments'), u'bundles_on_dashboard')
-
- if bundles_on_dashboard_flag.is_enabled():
- programs_data = meter.programs
- if programs_data and inverted_programs and inverted_programs.values():
- program_uuid = inverted_programs.values()[0][0]['uuid']
- meter.programs = [get_programs(request.site, uuid=program_uuid)]
- program_data = meter.programs[0]
- program_data = ProgramDataExtender(program_data, request.user).extend()
-
- skus = program_data.get('skus')
-
- urls = {
- 'commerce_api_url': reverse('commerce_api:v0:baskets:create'),
- 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus)
- }
- urls['completeProgramURL'] = urls['buy_button_url'] + '&bundle=' + program_data.get('uuid')
-
- # Construct a dictionary of course mode information
- # used to render the course list. We re-use the course modes dict
- # we loaded earlier to avoid hitting the database.
- course_mode_info = {
- enrollment.course_id: complete_course_mode_info(
- enrollment.course_id, enrollment,
- modes=course_modes_by_course[enrollment.course_id]
- )
- for enrollment in course_enrollments
- }
-
- # Determine the per-course verification status
- # This is a dictionary in which the keys are course locators
- # and the values are one of:
- #
- # VERIFY_STATUS_NEED_TO_VERIFY
- # VERIFY_STATUS_SUBMITTED
- # VERIFY_STATUS_APPROVED
- # VERIFY_STATUS_MISSED_DEADLINE
- #
- # Each of which correspond to a particular message to display
- # next to the course on the dashboard.
- #
- # If a course is not included in this dictionary,
- # there is no verification messaging to display.
- verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
- cert_statuses = {
- enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
- for enrollment in course_enrollments
- }
-
- # only show email settings for Mongo course and when bulk email is turned on
- show_email_settings_for = frozenset(
- enrollment.course_id for enrollment in course_enrollments if (
- BulkEmailFlag.feature_enabled(enrollment.course_id)
- )
- )
-
- # Verification Attempts
- # Used to generate the "you must reverify for course x" banner
- verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user)
- verification_errors = get_verification_error_reasons_for_display(verification_error_codes)
-
- # Gets data for midcourse reverifications, if any are necessary or have failed
- statuses = ["approved", "denied", "pending", "must_reverify"]
- reverifications = reverification_info(statuses)
-
- block_courses = frozenset(
- enrollment.course_id for enrollment in course_enrollments
- if is_course_blocked(
- request,
- CourseRegistrationCode.objects.filter(
- course_id=enrollment.course_id,
- registrationcoderedemption__redeemed_by=request.user
- ),
- enrollment.course_id
- )
- )
-
- enrolled_courses_either_paid = frozenset(
- enrollment.course_id for enrollment in course_enrollments
- if enrollment.is_paid_course()
- )
-
- # If there are *any* denied reverifications that have not been toggled off,
- # we'll display the banner
- denied_banner = any(item.display for item in reverifications["denied"])
-
- # Populate the Order History for the side-bar.
- order_history_list = order_history(user, course_org_filter=site_org_whitelist, org_filter_out_set=site_org_blacklist)
-
- # get list of courses having pre-requisites yet to be completed
- courses_having_prerequisites = frozenset(
- enrollment.course_id for enrollment in course_enrollments
- if enrollment.course_overview.pre_requisite_courses
- )
- courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
-
- if 'notlive' in request.GET:
- redirect_message = _("The course you are looking for does not start until {date}.").format(
- date=request.GET['notlive']
- )
- elif 'course_closed' in request.GET:
- redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format(
- date=request.GET['course_closed']
- )
- else:
- redirect_message = ''
-
- valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
- display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses
-
- # Filter out any course enrollment course cards that are associated with fulfilled entitlements
- for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]:
- course_enrollments = [enr for enr in course_enrollments if entitlement.enrollment_course_run.course_id != enr.course_id] # pylint: disable=line-too-long
-
- context = {
- 'urls': urls,
- 'program_data': program_data,
- 'enterprise_message': enterprise_message,
- 'consent_required_courses': consent_required_courses,
- 'enterprise_customer_name': enterprise_customer_name,
- 'enrollment_message': enrollment_message,
- 'redirect_message': redirect_message,
- 'account_activation_messages': account_activation_messages,
- 'course_enrollments': course_enrollments,
- 'course_entitlements': course_entitlements,
- 'course_entitlement_available_sessions': course_entitlement_available_sessions,
- 'unfulfilled_entitlement_pseudo_sessions': unfulfilled_entitlement_pseudo_sessions,
- 'course_optouts': course_optouts,
- 'banner_account_activation_message': banner_account_activation_message,
- 'sidebar_account_activation_message': sidebar_account_activation_message,
- 'staff_access': staff_access,
- 'errored_courses': errored_courses,
- 'show_courseware_links_for': show_courseware_links_for,
- 'all_course_modes': course_mode_info,
- 'cert_statuses': cert_statuses,
- 'credit_statuses': _credit_statuses(user, course_enrollments),
- 'show_email_settings_for': show_email_settings_for,
- 'reverifications': reverifications,
- 'verification_status': verification_status,
- 'verification_status_by_course': verify_status_by_course,
- 'verification_errors': verification_errors,
- 'block_courses': block_courses,
- 'denied_banner': denied_banner,
- 'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
- 'user': user,
- 'logout_url': reverse('logout'),
- 'platform_name': platform_name,
- 'enrolled_courses_either_paid': enrolled_courses_either_paid,
- 'provider_states': [],
- 'order_history_list': order_history_list,
- 'courses_requirements_not_met': courses_requirements_not_met,
- 'nav_hidden': True,
- 'inverted_programs': inverted_programs,
- 'show_program_listing': ProgramsApiConfig.is_enabled(),
- 'show_dashboard_tabs': True,
- 'disable_courseware_js': True,
- 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
- 'display_sidebar_on_dashboard': display_sidebar_on_dashboard,
- }
-
- if ecommerce_service.is_enabled(request.user):
- context.update({
- 'use_ecommerce_payment_flow': True,
- 'ecommerce_payment_page': ecommerce_service.payment_page_url(),
- })
-
- response = render_to_response('dashboard.html', context)
- set_user_info_cookie(response, request)
- return response
-
-
-@login_required
-def course_run_refund_status(request, course_id):
- """
- Get Refundable status for a course.
-
- Arguments:
- request: The request object.
- course_id (str): The unique identifier for the course.
-
- Returns:
- Json response.
-
- """
-
- try:
- course_key = CourseKey.from_string(course_id)
- course_enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
-
- except InvalidKeyError:
- logging.exception("The course key used to get refund status caused InvalidKeyError during look up.")
-
- return JsonResponse({'course_refundable_status': ''}, status=406)
-
- refundable_status = course_enrollment.refundable()
- logging.info("Course refund status for course {0} is {1}".format(course_id, refundable_status))
-
- return JsonResponse({'course_refundable_status': refundable_status}, status=200)
-
-
-def get_verification_error_reasons_for_display(verification_error_codes):
- verification_errors = []
- verification_error_map = {
- 'photos_mismatched': _('Photos are mismatched'),
- 'id_image_missing_name': _('Name missing from ID photo'),
- 'id_image_missing': _('ID photo not provided'),
- 'id_invalid': _('ID is invalid'),
- 'user_image_not_clear': _('Learner photo is blurry'),
- 'name_mismatch': _('Name on ID does not match name on account'),
- 'user_image_missing': _('Learner photo not provided'),
- 'id_image_not_clear': _('ID photo is blurry'),
- }
-
- for error in verification_error_codes:
- error_text = verification_error_map.get(error)
- if error_text:
- verification_errors.append(error_text)
-
- return verification_errors
-
-
-def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name
- """
- Builds a recent course enrollment message.
-
- Constructs a new message template based on any recent course enrollments
- for the student.
-
- Args:
- course_enrollments (list[CourseEnrollment]): a list of course enrollments.
- course_modes (dict): Mapping of course ID's to course mode dictionaries.
-
- Returns:
- A string representing the HTML message output from the message template.
- None if there are no recently enrolled courses.
-
- """
- recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
-
- if recently_enrolled_courses:
- enrollments_count = len(recently_enrolled_courses)
- course_name_separator = ', '
- # If length of enrolled course 2, join names with 'and'
- if enrollments_count == 2:
- course_name_separator = _(' and ')
-
- course_names = course_name_separator.join(
- [enrollment.course_overview.display_name for enrollment in recently_enrolled_courses]
- )
-
- allow_donations = any(
- _allow_donation(course_modes, enrollment.course_overview.id, enrollment)
- for enrollment in recently_enrolled_courses
- )
-
- platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
-
- return render_to_string(
- 'enrollment/course_enrollment_message.html',
- {
- 'course_names': course_names,
- 'enrollments_count': enrollments_count,
- 'allow_donations': allow_donations,
- 'platform_name': platform_name,
- 'course_id': recently_enrolled_courses[0].course_overview.id if enrollments_count == 1 else None
- }
- )
-
-
-def _get_recently_enrolled_courses(course_enrollments):
- """
- Given a list of enrollments, filter out all but recent enrollments.
-
- Args:
- course_enrollments (list[CourseEnrollment]): A list of course enrollments.
-
- Returns:
- list[CourseEnrollment]: A list of recent course enrollments.
- """
- seconds = DashboardConfiguration.current().recent_enrollment_time_delta
- time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
- return [
- enrollment for enrollment in course_enrollments
- # If the enrollment has no created date, we are explicitly excluding the course
- # from the list of recent enrollments.
- if enrollment.is_active and enrollment.created > time_delta
- ]
-
-
-def _allow_donation(course_modes, course_id, enrollment):
- """Determines if the dashboard will request donations for the given course.
-
- Check if donations are configured for the platform, and if the current course is accepting donations.
-
- Args:
- course_modes (dict): Mapping of course ID's to course mode dictionaries.
- course_id (str): The unique identifier for the course.
- enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
-
- Returns:
- True if the course is allowing donations.
-
- """
- if course_id not in course_modes:
- flat_unexpired_modes = {
- unicode(course_id): [mode for mode in modes]
- for course_id, modes in course_modes.iteritems()
- }
- flat_all_modes = {
- unicode(course_id): [mode.slug for mode in modes]
- for course_id, modes in CourseMode.all_modes_for_courses([course_id]).iteritems()
- }
- log.error(
- u'Can not find `%s` in course modes.`%s`. All modes: `%s`',
- course_id,
- flat_unexpired_modes,
- flat_all_modes
- )
- donations_enabled = configuration_helpers.get_value(
- 'ENABLE_DONATIONS',
- DonationConfiguration.current().enabled
- )
- return (
- donations_enabled and
- enrollment.mode in course_modes[course_id] and
- course_modes[course_id][enrollment.mode].min_price == 0
- )
-
-
-def _update_email_opt_in(request, org):
- """Helper function used to hit the profile API if email opt-in is enabled."""
-
- email_opt_in = request.POST.get('email_opt_in')
- if email_opt_in is not None:
- email_opt_in_boolean = email_opt_in == 'true'
- preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
-
-
-def _credit_statuses(user, course_enrollments):
- """
- Retrieve the status for credit courses.
-
- A credit course is a course for which a user can purchased
- college credit. The current flow is:
-
- 1. User becomes eligible for credit (submits verifications, passes the course, etc.)
- 2. User purchases credit from a particular credit provider.
- 3. User requests credit from the provider, usually creating an account on the provider's site.
- 4. The credit provider notifies us whether the user's request for credit has been accepted or rejected.
-
- The dashboard is responsible for communicating the user's state in this flow.
-
- Arguments:
- user (User): The currently logged-in user.
- course_enrollments (list[CourseEnrollment]): List of enrollments for the
- user.
-
- Returns: dict
-
- The returned dictionary has keys that are `CourseKey`s and values that
- are dictionaries with:
-
- * eligible (bool): True if the user is eligible for credit in this course.
- * deadline (datetime): The deadline for purchasing and requesting credit for this course.
- * purchased (bool): Whether the user has purchased credit for this course.
- * provider_name (string): The display name of the credit provider.
- * provider_status_url (string): A URL the user can visit to check on their credit request status.
- * request_status (string): Either "pending", "approved", or "rejected"
- * error (bool): If true, an unexpected error occurred when retrieving the credit status,
- so the user should contact the support team.
-
- Example:
- >>> _credit_statuses(user, course_enrollments)
- {
- CourseKey.from_string("edX/DemoX/Demo_Course"): {
- "course_key": "edX/DemoX/Demo_Course",
- "eligible": True,
- "deadline": 2015-11-23 00:00:00 UTC,
- "purchased": True,
- "provider_name": "Hogwarts",
- "provider_status_url": "http://example.com/status",
- "request_status": "pending",
- "error": False
- }
- }
-
- """
- from openedx.core.djangoapps.credit import api as credit_api
-
- # Feature flag off
- if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"):
- return {}
-
- request_status_by_course = {
- request["course_key"]: request["status"]
- for request in credit_api.get_credit_requests_for_user(user.username)
- }
-
- credit_enrollments = {
- enrollment.course_id: enrollment
- for enrollment in course_enrollments
- if enrollment.mode == "credit"
- }
-
- # When a user purchases credit in a course, the user's enrollment
- # mode is set to "credit" and an enrollment attribute is set
- # with the ID of the credit provider. We retrieve *all* such attributes
- # here to minimize the number of database queries.
- purchased_credit_providers = {
- attribute.enrollment.course_id: attribute.value
- for attribute in CourseEnrollmentAttribute.objects.filter(
- namespace="credit",
- name="provider_id",
- enrollment__in=credit_enrollments.values()
- ).select_related("enrollment")
- }
-
- provider_info_by_id = {
- provider["id"]: provider
- for provider in credit_api.get_credit_providers()
- }
-
- statuses = {}
- for eligibility in credit_api.get_eligibilities_for_user(user.username):
- course_key = CourseKey.from_string(unicode(eligibility["course_key"]))
- providers_names = get_credit_provider_display_names(course_key)
- status = {
- "course_key": unicode(course_key),
- "eligible": True,
- "deadline": eligibility["deadline"],
- "purchased": course_key in credit_enrollments,
- "provider_name": make_providers_strings(providers_names),
- "provider_status_url": None,
- "provider_id": None,
- "request_status": request_status_by_course.get(course_key),
- "error": False,
- }
-
- # If the user has purchased credit, then include information about the credit
- # provider from which the user purchased credit.
- # We retrieve the provider's ID from the an "enrollment attribute" set on the user's
- # enrollment when the user's order for credit is fulfilled by the E-Commerce service.
- if status["purchased"]:
- provider_id = purchased_credit_providers.get(course_key)
- if provider_id is None:
- status["error"] = True
- log.error(
- u"Could not find credit provider associated with credit enrollment "
- u"for user %s in course %s. The user will not be able to see his or her "
- u"credit request status on the student dashboard. This attribute should "
- u"have been set when the user purchased credit in the course.",
- user.id, course_key
- )
- else:
- provider_info = provider_info_by_id.get(provider_id, {})
- status["provider_name"] = provider_info.get("display_name")
- status["provider_status_url"] = provider_info.get("status_url")
- status["provider_id"] = provider_id
-
- statuses[course_key] = status
-
- return statuses
-
-
-@transaction.non_atomic_requests
-@require_POST
-@outer_atomic(read_committed=True)
-def change_enrollment(request, check_access=True):
- """
- Modify the enrollment status for the logged-in user.
-
- TODO: This is lms specific and does not belong in common code.
-
- The request parameter must be a POST request (other methods return 405)
- that specifies course_id and enrollment_action parameters. If course_id or
- enrollment_action is not specified, if course_id is not valid, if
- enrollment_action is something other than "enroll" or "unenroll", if
- enrollment_action is "enroll" and enrollment is closed for the course, or
- if enrollment_action is "unenroll" and the user is not enrolled in the
- course, a 400 error will be returned. If the user is not logged in, 403
- will be returned; it is important that only this case return 403 so the
- front end can redirect the user to a registration or login page when this
- happens. This function should only be called from an AJAX request, so
- the error messages in the responses should never actually be user-visible.
-
- Args:
- request (`Request`): The Django request object
-
- Keyword Args:
- check_access (boolean): If True, we check that an accessible course actually
- exists for the given course_key before we enroll the student.
- The default is set to False to avoid breaking legacy code or
- code with non-standard flows (ex. beta tester invitations), but
- for any standard enrollment flow you probably want this to be True.
-
- Returns:
- Response
-
- """
- # Get the user
- user = request.user
-
- # Ensure the user is authenticated
- if not user.is_authenticated():
- return HttpResponseForbidden()
-
- # Ensure we received a course_id
- action = request.POST.get("enrollment_action")
- if 'course_id' not in request.POST:
- return HttpResponseBadRequest(_("Course id not specified"))
-
- try:
- course_id = CourseKey.from_string(request.POST.get("course_id"))
- except InvalidKeyError:
- log.warning(
- u"User %s tried to %s with invalid course id: %s",
- user.username,
- action,
- request.POST.get("course_id"),
- )
- return HttpResponseBadRequest(_("Invalid course id"))
-
- # Allow us to monitor performance of this transaction on a per-course basis since we often roll-out features
- # on a per-course basis.
- monitoring_utils.set_custom_metric('course_id', unicode(course_id))
-
- if action == "enroll":
- # Make sure the course exists
- # We don't do this check on unenroll, or a bad course id can't be unenrolled from
- if not modulestore().has_course(course_id):
- log.warning(
- u"User %s tried to enroll in non-existent course %s",
- user.username,
- course_id
- )
- return HttpResponseBadRequest(_("Course id is invalid"))
-
- # Record the user's email opt-in preference
- if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
- _update_email_opt_in(request, course_id.org)
-
- available_modes = CourseMode.modes_for_course_dict(course_id)
-
- # Check whether the user is blocked from enrolling in this course
- # This can occur if the user's IP is on a global blacklist
- # or if the user is enrolling in a country in which the course
- # is not available.
- redirect_url = embargo_api.redirect_if_blocked(
- course_id, user=user, ip_address=get_ip(request),
- url=request.path
- )
- if redirect_url:
- return HttpResponse(redirect_url)
-
- # Check that auto enrollment is allowed for this course
- # (= the course is NOT behind a paywall)
- if CourseMode.can_auto_enroll(course_id):
- # Enroll the user using the default mode (audit)
- # We're assuming that users of the course enrollment table
- # will NOT try to look up the course enrollment model
- # by its slug. If they do, it's possible (based on the state of the database)
- # for no such model to exist, even though we've set the enrollment type
- # to "audit".
- try:
- enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
- if enroll_mode:
- CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
- except Exception: # pylint: disable=broad-except
- return HttpResponseBadRequest(_("Could not enroll"))
-
- # If we have more than one course mode or professional ed is enabled,
- # then send the user to the choose your track page.
- # (In the case of no-id-professional/professional ed, this will redirect to a page that
- # funnels users directly into the verification / payment flow)
- if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
- return HttpResponse(
- reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
- )
-
- # Otherwise, there is only one mode available (the default)
- return HttpResponse()
- elif action == "unenroll":
- enrollment = CourseEnrollment.get_enrollment(user, course_id)
- if not enrollment:
- return HttpResponseBadRequest(_("You are not enrolled in this course"))
-
- certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode)
- if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES:
- return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course"))
-
- CourseEnrollment.unenroll(user, course_id)
- REFUND_ORDER.send(sender=None, course_enrollment=enrollment)
- return HttpResponse()
- else:
- return HttpResponseBadRequest(_("Enrollment action is invalid"))
-
-
-def _generate_not_activated_message(user):
- """
- Generates the message displayed on the sign-in screen when a learner attempts to access the
- system with an inactive account.
-
- Arguments:
- user (User): User object for the learner attempting to sign in.
- """
-
- support_url = configuration_helpers.get_value(
- 'SUPPORT_SITE_LINK',
- settings.SUPPORT_SITE_LINK
- )
-
- platform_name = configuration_helpers.get_value(
- 'PLATFORM_NAME',
- settings.PLATFORM_NAME
- )
-
- not_activated_msg_template = _('In order to sign in, you need to activate your account.
'
- 'We just sent an activation link to {email}. If '
- 'you do not receive an email, check your spam folders or '
- 'contact {platform} Support.')
-
- not_activated_message = not_activated_msg_template.format(
- email=user.email,
- support_url=support_url,
- platform=platform_name
- )
-
- return not_activated_message
-
-
-# Need different levels of logging
-@ensure_csrf_cookie
-def login_user(request, error=""): # pylint: disable=too-many-statements,unused-argument
- """AJAX request to log in the user."""
-
- backend_name = None
- email = None
- password = None
- redirect_url = None
- response = None
- running_pipeline = None
- third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
- third_party_auth_successful = False
- trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
- user = None
- platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
-
- if third_party_auth_requested and not trumped_by_first_party_auth:
- # The user has already authenticated via third-party auth and has not
- # asked to do first party auth by supplying a username or password. We
- # now want to put them through the same logging and cookie calculation
- # logic as with first-party auth.
- running_pipeline = pipeline.get(request)
- username = running_pipeline['kwargs'].get('username')
- backend_name = running_pipeline['backend']
- third_party_uid = running_pipeline['kwargs']['uid']
- requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
-
- try:
- user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
- third_party_auth_successful = True
- except User.DoesNotExist:
- AUDIT_LOG.info(
- u"Login failed - user with username {username} has no social auth "
- "with backend_name {backend_name}".format(
- username=username, backend_name=backend_name)
- )
- message = _(
- "You've successfully logged into your {provider_name} account, "
- "but this account isn't linked with an {platform_name} account yet."
- ).format(
- platform_name=platform_name,
- provider_name=requested_provider.name,
- )
- message += "
"
- message += _(
- "Use your {platform_name} username and password to log into {platform_name} below, "
- "and then link your {platform_name} account with {provider_name} from your dashboard."
- ).format(
- platform_name=platform_name,
- provider_name=requested_provider.name,
- )
- message += "
"
- message += _(
- "If you don't have an {platform_name} account yet, "
- "click Register at the top of the page."
- ).format(
- platform_name=platform_name
- )
-
- return HttpResponse(message, content_type="text/plain", status=403)
-
- else:
-
- if 'email' not in request.POST or 'password' not in request.POST:
- return JsonResponse({
- "success": False,
- # TODO: User error message
- "value": _('There was an error receiving your login information. Please email us.'),
- }) # TODO: this should be status code 400
-
- email = request.POST['email']
- password = request.POST['password']
- try:
- user = User.objects.get(email=email)
- except User.DoesNotExist:
- if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
- AUDIT_LOG.warning(u"Login failed - Unknown user email")
- else:
- AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
-
- # check if the user has a linked shibboleth account, if so, redirect the user to shib-login
- # This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu
- # address into the Gmail login.
- if settings.FEATURES.get('AUTH_USE_SHIB') and user:
- try:
- eamap = ExternalAuthMap.objects.get(user=user)
- if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
- return JsonResponse({
- "success": False,
- "redirect": reverse('shib-login'),
- }) # TODO: this should be status code 301 # pylint: disable=fixme
- except ExternalAuthMap.DoesNotExist:
- # This is actually the common case, logging in user without external linked login
- AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
-
- # see if account has been locked out due to excessive login failures
- user_found_by_email_lookup = user
- if user_found_by_email_lookup and LoginFailures.is_feature_enabled():
- if LoginFailures.is_user_locked_out(user_found_by_email_lookup):
- lockout_message = _('This account has been temporarily locked due '
- 'to excessive login failures. Try again later.')
- return JsonResponse({
- "success": False,
- "value": lockout_message,
- }) # TODO: this should be status code 429 # pylint: disable=fixme
-
- # see if the user must reset his/her password due to any policy settings
- if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup):
- return JsonResponse({
- "success": False,
- "value": _('Your password has expired due to password policy on this account. You must '
- 'reset your password before you can log in again. Please click the '
- '"Forgot Password" link on this page to reset your password before logging in again.'),
- }) # TODO: this should be status code 403 # pylint: disable=fixme
-
- # if the user doesn't exist, we want to set the username to an invalid
- # username so that authentication is guaranteed to fail and we can take
- # advantage of the ratelimited backend
- username = user.username if user else ""
-
- if not third_party_auth_successful:
- try:
- user = authenticate(username=username, password=password, request=request)
- # this occurs when there are too many attempts from the same IP address
- except RateLimitException:
- return JsonResponse({
- "success": False,
- "value": _('Too many failed login attempts. Try again later.'),
- }) # TODO: this should be status code 429 # pylint: disable=fixme
-
- if user is None:
- # tick the failed login counters if the user exists in the database
- if user_found_by_email_lookup and LoginFailures.is_feature_enabled():
- LoginFailures.increment_lockout_counter(user_found_by_email_lookup)
-
- # TODO: Remove Django 1.11 upgrade shim
- # SHIM: In Django 1.10+ the user will not authenticate if it is not active (after the initial account creation,
- # see NEW_USER_AUTH_BACKEND for how we handle that). This code is duplicated below for the < 1.10 case where
- # the user is successfully authenticated, but not active. To remove this shim, remove the version check here
- # and the duplicated code at the end of this function.
- #
- # Note that this will be triggered for ALL inactive users, regardless of whether the password is correct. In
- # either case they need to activate before continuing so password status is moot.
- if django.VERSION >= (1, 10) and user_found_by_email_lookup and not user_found_by_email_lookup.is_active:
- if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
- AUDIT_LOG.warning(
- u"Login failed - Account not active for user.id: {0}, resending activation".format(
- user_found_by_email_lookup.id)
- )
- else:
- AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(
- user_found_by_email_lookup.username)
- )
-
- reactivation_email_for_user(user_found_by_email_lookup)
-
- return JsonResponse({
- "success": False,
- "value": _generate_not_activated_message(user_found_by_email_lookup),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
- # if we didn't find this username earlier, the account for this email
- # doesn't exist, and doesn't have a corresponding password
- if username != "":
- if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
- loggable_id = user_found_by_email_lookup.id if user_found_by_email_lookup else ""
- AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id))
- else:
- AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
- return JsonResponse({
- "success": False,
- "value": _('Email or password is incorrect.'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
- # successful login, clear failed login attempts counters, if applicable
- if LoginFailures.is_feature_enabled():
- LoginFailures.clear_lockout_counter(user)
-
- # Track the user's sign in
- if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
- tracking_context = tracker.get_tracker().resolve_context()
- analytics.identify(
- user.id,
- {
- 'email': email,
- 'username': username
- },
- {
- # Disable MailChimp because we don't want to update the user's email
- # and username in MailChimp on every page load. We only need to capture
- # this data on registration/activation.
- 'MailChimp': False
- }
- )
-
- analytics.track(
- user.id,
- "edx.bi.user.account.authenticated",
- {
- 'category': "conversion",
- 'label': request.POST.get('course_id'),
- 'provider': None
- },
- context={
- 'ip': tracking_context.get('ip'),
- 'Google Analytics': {
- 'clientId': tracking_context.get('client_id')
- }
- }
- )
- if user is not None and user.is_active:
- try:
- # We do not log here, because we have a handler registered
- # to perform logging on successful logins.
- login(request, user)
- if request.POST.get('remember') == 'true':
- request.session.set_expiry(604800)
- log.debug("Setting user session to never expire")
- else:
- request.session.set_expiry(0)
- except Exception as exc: # pylint: disable=broad-except
- AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
- log.critical("Login failed - Could not create session. Is memcached running?")
- log.exception(exc)
- raise
-
- redirect_url = None # The AJAX method calling should know the default destination upon success
- if third_party_auth_successful:
- redirect_url = pipeline.get_complete_url(backend_name)
-
- response = JsonResponse({
- "success": True,
- "redirect_url": redirect_url,
- })
-
- # Ensure that the external marketing site can
- # detect that the user is logged in.
- return set_logged_in_cookies(request, response, user)
-
- # TODO: Remove Django 1.11 upgrade shim
- # SHIM: See above SHIM for details and removal instructions.
- if django.VERSION < (1, 10):
- if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
- AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id))
- else:
- AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(username))
-
- reactivation_email_for_user(user)
-
- return JsonResponse({
- "success": False,
- "value": _generate_not_activated_message(user),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
-
-@csrf_exempt
-@require_POST
-@social_utils.psa("social:complete")
-def login_oauth_token(request, backend):
- """
- Authenticate the client using an OAuth access token by using the token to
- retrieve information from a third party and matching that information to an
- existing user.
- """
- warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)
-
- backend = request.backend
- if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2):
- if "access_token" in request.POST:
- # Tell third party auth pipeline that this is an API call
- request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
- user = None
- access_token = request.POST["access_token"]
- try:
- user = backend.do_auth(access_token)
- except (HTTPError, AuthException):
- pass
- # do_auth can return a non-User object if it fails
- if user and isinstance(user, User):
- login(request, user)
- return JsonResponse(status=204)
- else:
- # Ensure user does not re-enter the pipeline
- request.social_strategy.clean_partial_pipeline(access_token)
- return JsonResponse({"error": "invalid_token"}, status=401)
- else:
- return JsonResponse({"error": "invalid_request"}, status=400)
- raise Http404
-
-
-@require_GET
-@login_required
-@ensure_csrf_cookie
-def manage_user_standing(request):
- """
- Renders the view used to manage user standing. Also displays a table
- of user accounts that have been disabled and who disabled them.
- """
- if not request.user.is_staff:
- raise Http404
- all_disabled_accounts = UserStanding.objects.filter(
- account_status=UserStanding.ACCOUNT_DISABLED
- )
-
- all_disabled_users = [standing.user for standing in all_disabled_accounts]
-
- headers = ['username', 'account_changed_by']
- rows = []
- for user in all_disabled_users:
- row = [user.username, user.standing.changed_by]
- rows.append(row)
-
- context = {'headers': headers, 'rows': rows}
-
- return render_to_response("manage_user_standing.html", context)
-
-
-@require_POST
-@login_required
-@ensure_csrf_cookie
-def disable_account_ajax(request):
- """
- Ajax call to change user standing. Endpoint of the form
- in manage_user_standing.html
- """
- if not request.user.is_staff:
- raise Http404
- username = request.POST.get('username')
- context = {}
- if username is None or username.strip() == '':
- context['message'] = _('Please enter a username')
- return JsonResponse(context, status=400)
-
- account_action = request.POST.get('account_action')
- if account_action is None:
- context['message'] = _('Please choose an option')
- return JsonResponse(context, status=400)
-
- username = username.strip()
- try:
- user = User.objects.get(username=username)
- except User.DoesNotExist:
- context['message'] = _("User with username {} does not exist").format(username)
- return JsonResponse(context, status=400)
- else:
- user_account, _success = UserStanding.objects.get_or_create(
- user=user, defaults={'changed_by': request.user},
- )
- if account_action == 'disable':
- user_account.account_status = UserStanding.ACCOUNT_DISABLED
- context['message'] = _("Successfully disabled {}'s account").format(username)
- log.info(u"%s disabled %s's account", request.user, username)
- elif account_action == 'reenable':
- user_account.account_status = UserStanding.ACCOUNT_ENABLED
- context['message'] = _("Successfully reenabled {}'s account").format(username)
- log.info(u"%s reenabled %s's account", request.user, username)
- else:
- context['message'] = _("Unexpected account status")
- return JsonResponse(context, status=400)
- user_account.changed_by = request.user
- user_account.standing_last_changed_at = datetime.datetime.now(UTC)
- user_account.save()
-
- return JsonResponse(context)
-
-
-@login_required
-@ensure_csrf_cookie
-def change_setting(request):
- """JSON call to change a profile setting: Right now, location"""
- # TODO (vshnayder): location is no longer used
- u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache
- if 'location' in request.POST:
- u_prof.location = request.POST['location']
- u_prof.save()
-
- return JsonResponse({
- "success": True,
- "location": u_prof.location,
- })
-
-
-class AccountValidationError(Exception):
- def __init__(self, message, field):
- super(AccountValidationError, self).__init__(message)
- self.field = field
-
-
-@receiver(post_save, sender=User)
-def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
- """
- handler that saves the user Signup Source
- when the user is created
- """
- if 'created' in kwargs and kwargs['created']:
- site = configuration_helpers.get_value('SITE_NAME')
- if site:
- user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
- user_signup_source.save()
- log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))
-
-
-def _do_create_account(form, custom_form=None):
- """
- Given cleaned post variables, create the User and UserProfile objects, as well as the
- registration for this user.
-
- Returns a tuple (User, UserProfile, Registration).
-
- Note: this function is also used for creating test users.
- """
- # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
- if not configuration_helpers.get_value(
- 'ALLOW_PUBLIC_ACCOUNT_CREATION',
- settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
- ):
- raise PermissionDenied()
-
- errors = {}
- errors.update(form.errors)
- if custom_form:
- errors.update(custom_form.errors)
-
- if errors:
- raise ValidationError(errors)
-
- user = User(
- username=form.cleaned_data["username"],
- email=form.cleaned_data["email"],
- is_active=False
- )
- user.set_password(form.cleaned_data["password"])
- registration = Registration()
-
- # TODO: Rearrange so that if part of the process fails, the whole process fails.
- # Right now, we can have e.g. no registration e-mail sent out and a zombie account
- try:
- with transaction.atomic():
- user.save()
- if custom_form:
- custom_model = custom_form.save(commit=False)
- custom_model.user = user
- custom_model.save()
- except IntegrityError:
- # Figure out the cause of the integrity error
- # TODO duplicate email is already handled by form.errors above as a ValidationError.
- # The checks for duplicate email/username should occur in the same place with an
- # AccountValidationError and a consistent user message returned (i.e. both should
- # return "It looks like {username} belongs to an existing account. Try again with a
- # different username.")
- if len(User.objects.filter(username=user.username)) > 0:
- raise AccountValidationError(
- _("An account with the Public Username '{username}' already exists.").format(username=user.username),
- field="username"
- )
- elif len(User.objects.filter(email=user.email)) > 0:
- raise AccountValidationError(
- _("An account with the Email '{email}' already exists.").format(email=user.email),
- field="email"
- )
- else:
- raise
-
- # add this account creation to password history
- # NOTE, this will be a NOP unless the feature has been turned on in configuration
- password_history_entry = PasswordHistory()
- password_history_entry.create(user)
-
- registration.register(user)
-
- profile_fields = [
- "name", "level_of_education", "gender", "mailing_address", "city", "country", "goals",
- "year_of_birth"
- ]
- profile = UserProfile(
- user=user,
- **{key: form.cleaned_data.get(key) for key in profile_fields}
- )
- extended_profile = form.cleaned_extended_profile
- if extended_profile:
- profile.meta = json.dumps(extended_profile)
- try:
- profile.save()
- except Exception: # pylint: disable=broad-except
- log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
- raise
-
- return (user, profile, registration)
-
-
-def _create_or_set_user_attribute_created_on_site(user, site):
- # Create or Set UserAttribute indicating the microsite site the user account was created on.
- # User maybe created on 'courses.edx.org', or a white-label site
- if site:
- UserAttribute.set_user_attribute(user, 'created_on_site', site.domain)
-
-
-def create_account_with_params(request, params):
- """
- Given a request and a dict of parameters (which may or may not have come
- from the request), create an account for the requesting user, including
- creating a comments service user object and sending an activation email.
- This also takes external/third-party auth into account, updates that as
- necessary, and authenticates the user for the request's session.
-
- Does not return anything.
-
- Raises AccountValidationError if an account with the username or email
- specified by params already exists, or ValidationError if any of the given
- parameters is invalid for any other reason.
-
- Issues with this code:
- * It is not transactional. If there is a failure part-way, an incomplete
- account will be created and left in the database.
- * Third-party auth passwords are not verified. There is a comment that
- they are unused, but it would be helpful to have a sanity check that
- they are sane.
- * It is over 300 lines long (!) and includes disprate functionality, from
- registration e-mails to all sorts of other things. It should be broken
- up into semantically meaningful functions.
- * The user-facing text is rather unfriendly (e.g. "Username must be a
- minimum of two characters long" rather than "Please use a username of
- at least two characters").
- * Duplicate email raises a ValidationError (rather than the expected
- AccountValidationError). Duplicate username returns an inconsistent
- user message (i.e. "An account with the Public Username '{username}'
- already exists." rather than "It looks like {username} belongs to an
- existing account. Try again with a different username.") The two checks
- occur at different places in the code; as a result, registering with
- both a duplicate username and email raises only a ValidationError for
- email only.
- """
- # Copy params so we can modify it; we can't just do dict(params) because if
- # params is request.POST, that results in a dict containing lists of values
- params = dict(params.items())
-
- # allow to define custom set of required/optional/hidden fields via configuration
- extra_fields = configuration_helpers.get_value(
- 'REGISTRATION_EXTRA_FIELDS',
- getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
- )
- # registration via third party (Google, Facebook) using mobile application
- # doesn't use social auth pipeline (no redirect uri(s) etc involved).
- # In this case all related info (required for account linking)
- # is sent in params.
- # `third_party_auth_credentials_in_api` essentially means 'request
- # is made from mobile application'
- third_party_auth_credentials_in_api = 'provider' in params
-
- is_third_party_auth_enabled = third_party_auth.is_enabled()
-
- if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
- params["password"] = pipeline.make_random_password()
-
- # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate
- # error message
- if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)):
- raise ValidationError(
- {'session_expired': [
- _(u"Registration using {provider} has timed out.").format(
- provider=params.get('social_auth_provider'))
- ]}
- )
-
- # if doing signup for an external authorization, then get email, password, name from the eamap
- # don't use the ones from the form, since the user could have hacked those
- # unless originally we didn't get a valid email or name from the external auth
- # TODO: We do not check whether these values meet all necessary criteria, such as email length
- do_external_auth = 'ExternalAuthMap' in request.session
- if do_external_auth:
- eamap = request.session['ExternalAuthMap']
- try:
- validate_email(eamap.external_email)
- params["email"] = eamap.external_email
- except ValidationError:
- pass
- if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH:
- params["name"] = eamap.external_name
- params["password"] = eamap.internal_password
- log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"])
-
- extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', [])
- enforce_password_policy = (
- settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
- not do_external_auth
- )
- # Can't have terms of service for certain SHIB users, like at Stanford
- registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
- tos_required = (
- registration_fields.get('terms_of_service') != 'hidden' or
- registration_fields.get('honor_code') != 'hidden'
- ) and (
- not settings.FEATURES.get("AUTH_USE_SHIB") or
- not settings.FEATURES.get("SHIB_DISABLE_TOS") or
- not do_external_auth or
- not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX)
- )
-
- form = AccountCreationForm(
- data=params,
- extra_fields=extra_fields,
- extended_profile_fields=extended_profile_fields,
- enforce_username_neq_password=True,
- enforce_password_policy=enforce_password_policy,
- tos_required=tos_required,
- )
- custom_form = get_registration_extension_form(data=params)
-
- # Perform operations within a transaction that are critical to account creation
- with transaction.atomic():
- # first, create the account
- (user, profile, registration) = _do_create_account(form, custom_form)
-
- # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
- # (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
-
- # Note: this is orthogonal to the 3rd party authentication pipeline that occurs
- # when the account is created via the browser and redirect URLs.
-
- if is_third_party_auth_enabled and third_party_auth_credentials_in_api:
- backend_name = params['provider']
- request.social_strategy = social_utils.load_strategy(request)
- redirect_uri = reverse('social:complete', args=(backend_name, ))
- request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri)
- social_access_token = params.get('access_token')
- if not social_access_token:
- raise ValidationError({
- 'access_token': [
- _("An access_token is required when passing value ({}) for provider.").format(
- params['provider']
- )
- ]
- })
- request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API
- pipeline_user = None
- error_message = ""
- try:
- pipeline_user = request.backend.do_auth(social_access_token, user=user)
- except AuthAlreadyAssociated:
- error_message = _("The provided access_token is already associated with another user.")
- except (HTTPError, AuthException):
- error_message = _("The provided access_token is not valid.")
- if not pipeline_user or not isinstance(pipeline_user, User):
- # Ensure user does not re-enter the pipeline
- request.social_strategy.clean_partial_pipeline(social_access_token)
- raise ValidationError({'access_token': [error_message]})
-
- # Perform operations that are non-critical parts of account creation
- _create_or_set_user_attribute_created_on_site(user, request.site)
-
- preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())
-
- if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
- try:
- enable_notifications(user)
- except Exception: # pylint: disable=broad-except
- log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))
-
- dog_stats_api.increment("common.student.account_created")
-
- # If the user is registering via 3rd party auth, track which provider they use
- third_party_provider = None
- running_pipeline = None
- if is_third_party_auth_enabled and pipeline.running(request):
- running_pipeline = pipeline.get(request)
- third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
-
- # Track the user's registration
- if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
- tracking_context = tracker.get_tracker().resolve_context()
- identity_args = [
- user.id, # pylint: disable=no-member
- {
- 'email': user.email,
- 'username': user.username,
- 'name': profile.name,
- # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey.
- 'age': profile.age or -1,
- 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year,
- 'education': profile.level_of_education_display,
- 'address': profile.mailing_address,
- 'gender': profile.gender_display,
- 'country': unicode(profile.country),
- }
- ]
-
- if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'):
- identity_args.append({
- "MailChimp": {
- "listId": settings.MAILCHIMP_NEW_USER_LIST_ID
- }
- })
-
- analytics.identify(*identity_args)
-
- analytics.track(
- user.id,
- "edx.bi.user.account.registered",
- {
- 'category': 'conversion',
- 'label': params.get('course_id'),
- 'provider': third_party_provider.name if third_party_provider else None
- },
- context={
- 'ip': tracking_context.get('ip'),
- 'Google Analytics': {
- 'clientId': tracking_context.get('client_id')
- }
- }
- )
-
- # Announce registration
- REGISTER_USER.send(sender=None, user=user, registration=registration)
-
- create_comments_service_user(user)
-
- # Check if we system is configured to skip activation email for the current user.
- skip_email = skip_activation_email(
- user, do_external_auth, running_pipeline, third_party_provider,
- )
-
- if skip_email:
- registration.activate()
- _enroll_user_in_pending_courses(user) # Enroll student in any pending courses
- else:
- compose_and_send_activation_email(user, profile, registration)
-
- new_user = authenticate_new_user(request, user.username, params['password'])
- login(request, new_user)
- request.session.set_expiry(0)
-
- try:
- record_registration_attributions(request, new_user)
- # Don't prevent a user from registering due to attribution errors.
- except Exception: # pylint: disable=broad-except
- log.exception('Error while attributing cookies to user registration.')
-
- # TODO: there is no error checking here to see that the user actually logged in successfully,
- # and is not yet an active user.
- if new_user is not None:
- AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
-
- if do_external_auth:
- eamap.user = new_user
- eamap.dtsignup = datetime.datetime.now(UTC)
- eamap.save()
- AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username)
- AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap)
-
- if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
- log.info('bypassing activation email')
- new_user.is_active = True
- new_user.save()
- AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email))
-
- return new_user
-
-
-def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider):
- """
- Return `True` if activation email should be skipped.
-
- Skip email if we are:
- 1. Doing load testing.
- 2. Random user generation for other forms of testing.
- 3. External auth bypassing activation.
- 4. Have the platform configured to not require e-mail activation.
- 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
-
- Note that this feature is only tested as a flag set one way or
- the other for *new* systems. we need to be careful about
- changing settings on a running system to make sure no users are
- left in an inconsistent state (or doing a migration if they are).
-
- Arguments:
- user (User): Django User object for the current user.
- do_external_auth (bool): True if external authentication is in progress.
- running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication.
- third_party_provider (ProviderConfig): An instance of third party provider configuration.
-
- Returns:
- (bool): `True` if account activation email should be skipped, `False` if account activation email should be
- sent.
- """
- sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email')
-
- # Email is valid if the SAML assertion email matches the user account email or
- # no email was provided in the SAML assertion. Some IdP's use a callback
- # to retrieve additional user account information (including email) after the
- # initial account creation.
- valid_email = (
- sso_pipeline_email == user.email or (
- sso_pipeline_email is None and
- third_party_provider and
- getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY
- )
- )
-
- # log the cases where skip activation email flag is set, but email validity check fails
- if third_party_provider and third_party_provider.skip_email_verification and not valid_email:
- log.info(
- '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] '
- 'Account activation email sent as user\'s system email differs from SSO email.',
- user.email,
- sso_pipeline_email,
- getattr(third_party_provider, "provider_id", None),
- getattr(third_party_provider, "identity_provider_type", None)
- )
-
- return (
- settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or
- settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or
- (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or
- (third_party_provider and third_party_provider.skip_email_verification and valid_email)
- )
-
-
-def _enroll_user_in_pending_courses(student):
- """
- Enroll student in any pending courses he/she may have.
- """
- ceas = CourseEnrollmentAllowed.objects.filter(email=student.email)
- for cea in ceas:
- if cea.auto_enroll:
- enrollment = CourseEnrollment.enroll(student, cea.course_id)
- manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student.email)
- if manual_enrollment_audit is not None:
- # get the enrolled by user and reason from the ManualEnrollmentAudit table.
- # then create a new ManualEnrollmentAudit table entry for the same email
- # different transition state.
- ManualEnrollmentAudit.create_manual_enrollment_audit(
- manual_enrollment_audit.enrolled_by, student.email, ALLOWEDTOENROLL_TO_ENROLLED,
- manual_enrollment_audit.reason, enrollment
- )
-
-
-def record_affiliate_registration_attribution(request, user):
- """
- Attribute this user's registration to the referring affiliate, if
- applicable.
- """
- affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
- if user and affiliate_id:
- UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
-
-
-def record_utm_registration_attribution(request, user):
- """
- Attribute this user's registration to the latest UTM referrer, if
- applicable.
- """
- utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name
- utm_cookie = request.COOKIES.get(utm_cookie_name)
- if user and utm_cookie:
- utm = json.loads(utm_cookie)
- for utm_parameter_name in REGISTRATION_UTM_PARAMETERS:
- utm_parameter = utm.get(utm_parameter_name)
- if utm_parameter:
- UserAttribute.set_user_attribute(
- user,
- REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name),
- utm_parameter
- )
- created_at_unixtime = utm.get('created_at')
- if created_at_unixtime:
- # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds.
- # PYTHON: time.time() => 1475590280.823698
- # JS: new Date().getTime() => 1475590280823
- created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC)
- UserAttribute.set_user_attribute(
- user,
- REGISTRATION_UTM_CREATED_AT,
- created_at_datetime
- )
-
-
-def record_registration_attributions(request, user):
- """
- Attribute this user's registration based on referrer cookies.
- """
- record_affiliate_registration_attribution(request, user)
- record_utm_registration_attribution(request, user)
-
-
-@csrf_exempt
-def create_account(request, post_override=None):
- """
- JSON call to create new edX account.
- Used by form in signup_modal.html, which is included into header.html
- """
- # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
- if not configuration_helpers.get_value(
- 'ALLOW_PUBLIC_ACCOUNT_CREATION',
- settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
- ):
- return HttpResponseForbidden(_("Account creation not allowed."))
-
- warnings.warn("Please use RegistrationView instead.", DeprecationWarning)
-
- try:
- user = create_account_with_params(request, post_override or request.POST)
- except AccountValidationError as exc:
- return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400)
- except ValidationError as exc:
- field, error_list = next(exc.message_dict.iteritems())
- return JsonResponse(
- {
- "success": False,
- "field": field,
- "value": error_list[0],
- },
- status=400
- )
-
- redirect_url = None # The AJAX method calling should know the default destination upon success
-
- # Resume the third-party-auth pipeline if necessary.
- if third_party_auth.is_enabled() and pipeline.running(request):
- running_pipeline = pipeline.get(request)
- redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
-
- response = JsonResponse({
- 'success': True,
- 'redirect_url': redirect_url,
- })
- set_logged_in_cookies(request, response, user)
- return response
-
-
-def str2bool(s):
- s = str(s)
- return s.lower() in ('yes', 'true', 't', '1')
-
-
-def _clean_roles(roles):
- """ Clean roles.
-
- Strips whitespace from roles, and removes empty items.
-
- Args:
- roles (str[]): List of role names.
-
- Returns:
- str[]
- """
- roles = [role.strip() for role in roles]
- roles = [role for role in roles if role]
- return roles
-
-
-def auto_auth(request):
- """
- Create or configure a user account, then log in as that user.
-
- Enabled only when
- settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
-
- Accepts the following querystring parameters:
- * `username`, `email`, and `password` for the user account
- * `full_name` for the user profile (the user's full name; defaults to the username)
- * `staff`: Set to "true" to make the user global staff.
- * `course_id`: Enroll the student in the course with `course_id`
- * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
- * `no_login`: Define this to create the user but not login
- * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or
- course home page if course_id is defined, otherwise it will redirect to dashboard
- * `redirect_to`: will redirect to to this url
- * `is_active` : make/update account with status provided as 'is_active'
- If username, email, or password are not provided, use
- randomly generated credentials.
- """
-
- # Generate a unique name to use if none provided
- generated_username = uuid.uuid4().hex[0:30]
-
- # Use the params from the request, otherwise use these defaults
- username = request.GET.get('username', generated_username)
- password = request.GET.get('password', username)
- email = request.GET.get('email', username + "@example.com")
- full_name = request.GET.get('full_name', username)
- is_staff = str2bool(request.GET.get('staff', False))
- is_superuser = str2bool(request.GET.get('superuser', False))
- course_id = request.GET.get('course_id')
- redirect_to = request.GET.get('redirect_to')
- is_active = str2bool(request.GET.get('is_active', True))
-
- # Valid modes: audit, credit, honor, no-id-professional, professional, verified
- enrollment_mode = request.GET.get('enrollment_mode', 'honor')
-
- # Parse roles, stripping whitespace, and filtering out empty strings
- roles = _clean_roles(request.GET.get('roles', '').split(','))
- course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(','))
-
- redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to
- login_when_done = 'no_login' not in request.GET
-
- form = AccountCreationForm(
- data={
- 'username': username,
- 'email': email,
- 'password': password,
- 'name': full_name,
- },
- tos_required=False
- )
-
- # Attempt to create the account.
- # If successful, this will return a tuple containing
- # the new user object.
- try:
- user, profile, reg = _do_create_account(form)
- except (AccountValidationError, ValidationError):
- # Attempt to retrieve the existing user.
- user = User.objects.get(username=username)
- user.email = email
- user.set_password(password)
- user.is_active = is_active
- user.save()
- profile = UserProfile.objects.get(user=user)
- reg = Registration.objects.get(user=user)
- except PermissionDenied:
- return HttpResponseForbidden(_('Account creation not allowed.'))
-
- user.is_staff = is_staff
- user.is_superuser = is_superuser
- user.save()
-
- if is_active:
- reg.activate()
- reg.save()
-
- # ensure parental consent threshold is met
- year = datetime.date.today().year
- age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT
- profile.year_of_birth = (year - age_limit) - 1
- profile.save()
-
- _create_or_set_user_attribute_created_on_site(user, request.site)
-
- # Enroll the user in a course
- course_key = None
- if course_id:
- course_key = CourseLocator.from_string(course_id)
- CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
-
- # Apply the roles
- for role in roles:
- assign_role(course_key, user, role)
-
- for role in course_access_roles:
- CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role)
-
- # Log in as the user
- if login_when_done:
- user = authenticate_new_user(request, username, password)
- login(request, user)
-
- create_comments_service_user(user)
-
- if redirect_when_done:
- if redirect_to:
- # Redirect to page specified by the client
- redirect_url = redirect_to
- elif course_id:
- # Redirect to the course homepage (in LMS) or outline page (in Studio)
- try:
- redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
- except NoReverseMatch:
- redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
- else:
- # Redirect to the learner dashboard (in LMS) or homepage (in Studio)
- try:
- redirect_url = reverse('dashboard')
- except NoReverseMatch:
- redirect_url = reverse('home')
-
- return redirect(redirect_url)
- else:
- response = JsonResponse({
- 'created_status': 'Logged in' if login_when_done else 'Created',
- 'username': username,
- 'email': email,
- 'password': password,
- 'user_id': user.id, # pylint: disable=no-member
- 'anonymous_id': anonymous_id_for_user(user, None),
- })
- response.set_cookie('csrftoken', csrf(request)['csrf_token'])
- return response
-
-
-@ensure_csrf_cookie
-def activate_account(request, key):
- """When link in activation e-mail is clicked"""
-
- # If request is in Studio call the appropriate view
- if theming_helpers.get_project_root_name().lower() == u'cms':
- return activate_account_studio(request, key)
-
- try:
- registration = Registration.objects.get(activation_key=key)
- except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
- messages.error(
- request,
- HTML(_(
- '{html_start}Your account could not be activated{html_end}'
- 'Something went wrong, please contact support to resolve this issue.'
- )).format(
- support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
- html_start=HTML('
'),
- html_end=HTML('
'),
- ),
- extra_tags='account-activation aa-icon'
- )
- else:
- if not registration.user.is_active:
- registration.activate()
- # Success message for logged in users.
- message = _('{html_start}Success{html_end} You have activated your account.')
-
- if not request.user.is_authenticated():
- # Success message for logged out users
- message = _(
- '{html_start}Success! You have activated your account.{html_end}'
- 'You will now receive email updates and alerts from us related to'
- ' the courses you are enrolled in. Sign In to continue.'
- )
-
- # Add message for later use.
- messages.success(
- request,
- HTML(message).format(
- html_start=HTML('
'),
- ),
- extra_tags='account-activation aa-icon',
- )
-
- # Enroll student in any pending courses he/she may have if auto_enroll flag is set
- _enroll_user_in_pending_courses(registration.user)
-
- return redirect('dashboard')
-
-
-@ensure_csrf_cookie
-def activate_account_studio(request, key):
- """
- When link in activation e-mail is clicked and the link belongs to studio.
- """
- try:
- registration = Registration.objects.get(activation_key=key)
- except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
- return render_to_response(
- "registration/activation_invalid.html",
- {'csrf': csrf(request)['csrf_token']}
- )
- else:
- user_logged_in = request.user.is_authenticated()
- already_active = True
- if not registration.user.is_active:
- registration.activate()
- already_active = False
-
- # Enroll student in any pending courses he/she may have if auto_enroll flag is set
- _enroll_user_in_pending_courses(registration.user)
-
- return render_to_response(
- "registration/activation_complete.html",
- {
- 'user_logged_in': user_logged_in,
- 'already_active': already_active
- }
- )
-
-
-@csrf_exempt
-@require_POST
-def password_reset(request):
- """ Attempts to send a password reset e-mail. """
- # Add some rate limiting here by re-using the RateLimitMixin as a helper class
- limiter = BadRequestRateLimiter()
- if limiter.is_rate_limit_exceeded(request):
- AUDIT_LOG.warning("Rate limit exceeded in password_reset")
- return HttpResponseForbidden()
-
- form = PasswordResetFormNoActive(request.POST)
- if form.is_valid():
- form.save(use_https=request.is_secure(),
- from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
- request=request)
- # When password change is complete, a "edx.user.settings.changed" event will be emitted.
- # But because changing the password is multi-step, we also emit an event here so that we can
- # track where the request was initiated.
- tracker.emit(
- SETTING_CHANGE_INITIATED,
- {
- "setting": "password",
- "old": None,
- "new": None,
- "user_id": request.user.id,
- }
- )
- destroy_oauth_tokens(request.user)
- else:
- # bad user? tick the rate limiter counter
- AUDIT_LOG.info("Bad password_reset user passed in.")
- limiter.tick_bad_request_counter(request)
-
- return JsonResponse({
- 'success': True,
- 'value': render_to_string('registration/password_reset_done.html', {}),
- })
-
-
-def uidb36_to_uidb64(uidb36):
- """
- Needed to support old password reset URLs that use base36-encoded user IDs
- https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231
- Args:
- uidb36: base36-encoded user ID
-
- Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID
- """
- try:
- uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
- except ValueError:
- uidb64 = '1' # dummy invalid ID (incorrect padding for base64)
- return uidb64
-
-
-def validate_password(password):
- """
- Validate password overall strength if ENFORCE_PASSWORD_POLICY is enable
- otherwise only validate the length of the password.
-
- Args:
- password: the user's proposed new password.
-
- Returns:
- err_msg: an error message if there's a violation of one of the password
- checks. Otherwise, `None`.
- """
-
- try:
- if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False):
- validate_password_strength(password)
- else:
- validate_password_length(password)
-
- except ValidationError as err:
- return _('Password: ') + '; '.join(err.messages)
-
-
-def validate_password_security_policy(user, password):
- """
- Tie in password policy enforcement as an optional level of
- security protection
-
- Args:
- user: the user object whose password we're checking.
- password: the user's proposed new password.
-
- Returns:
- err_msg: an error message if there's a violation of one of the password
- checks. Otherwise, `None`.
- """
-
- err_msg = None
- # also, check the password reuse policy
- if not PasswordHistory.is_allowable_password_reuse(user, password):
- if user.is_staff:
- num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
- else:
- num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE']
- # Because of how ngettext is, splitting the following into shorter lines would be ugly.
- # pylint: disable=line-too-long
- err_msg = ungettext(
- "You are re-using a password that you have used recently. You must have {num} distinct password before reusing a previous password.",
- "You are re-using a password that you have used recently. You must have {num} distinct passwords before reusing a previous password.",
- num_distinct
- ).format(num=num_distinct)
-
- # also, check to see if passwords are getting reset too frequent
- if PasswordHistory.is_password_reset_too_soon(user):
- num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS']
- # Because of how ngettext is, splitting the following into shorter lines would be ugly.
- # pylint: disable=line-too-long
- err_msg = ungettext(
- "You are resetting passwords too frequently. Due to security policies, {num} day must elapse between password resets.",
- "You are resetting passwords too frequently. Due to security policies, {num} days must elapse between password resets.",
- num_days
- ).format(num=num_days)
-
- return err_msg
-
-
-def password_reset_confirm_wrapper(request, uidb36=None, token=None):
- """
- A wrapper around django.contrib.auth.views.password_reset_confirm.
- Needed because we want to set the user as active at this step.
- We also optionally do some additional password policy checks.
- """
- # convert old-style base36-encoded user id to base64
- uidb64 = uidb36_to_uidb64(uidb36)
- platform_name = {
- "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
- }
- try:
- uid_int = base36_to_int(uidb36)
- user = User.objects.get(id=uid_int)
- except (ValueError, User.DoesNotExist):
- # if there's any error getting a user, just let django's
- # password_reset_confirm function handle it.
- return password_reset_confirm(
- request, uidb64=uidb64, token=token, extra_context=platform_name
- )
-
- if request.method == 'POST':
- password = request.POST['new_password1']
- valid_link = False
- error_message = validate_password_security_policy(user, password)
- if not error_message:
- # if security is not violated, we need to validate password
- error_message = validate_password(password)
- if error_message:
- # password reset link will be valid if there is no security violation
- valid_link = True
-
- if error_message:
- # We have a password reset attempt which violates some security
- # policy, or any other validation. Use the existing Django template to communicate that
- # back to the user.
- context = {
- 'validlink': valid_link,
- 'form': None,
- 'title': _('Password reset unsuccessful'),
- 'err_msg': error_message,
- }
- context.update(platform_name)
- return TemplateResponse(
- request, 'registration/password_reset_confirm.html', context
- )
-
- # remember what the old password hash is before we call down
- old_password_hash = user.password
-
- response = password_reset_confirm(
- request, uidb64=uidb64, token=token, extra_context=platform_name
- )
-
- # If password reset was unsuccessful a template response is returned (status_code 200).
- # Check if form is invalid then show an error to the user.
- # Note if password reset was successful we get response redirect (status_code 302).
- if response.status_code == 200:
- form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False
- if not form_valid:
- log.warning(
- u'Unable to reset password for user [%s] because form is not valid. '
- u'A possible cause is that the user had an invalid reset token',
- user.username,
- )
- response.context_data['err_msg'] = _('Error in resetting your password. Please try again.')
- return response
-
- # get the updated user
- updated_user = User.objects.get(id=uid_int)
-
- # did the password hash change, if so record it in the PasswordHistory
- if updated_user.password != old_password_hash:
- entry = PasswordHistory()
- entry.create(updated_user)
-
- else:
- response = password_reset_confirm(
- request, uidb64=uidb64, token=token, extra_context=platform_name
- )
-
- response_was_successful = response.context_data.get('validlink')
- if response_was_successful and not user.is_active:
- user.is_active = True
- user.save()
-
- return response
-
-
-def reactivation_email_for_user(user):
- try:
- registration = Registration.objects.get(user=user)
- except Registration.DoesNotExist:
- return JsonResponse({
- "success": False,
- "error": _('No inactive user with this e-mail exists'),
- }) # TODO: this should be status code 400 # pylint: disable=fixme
-
- try:
- context = generate_activation_email_context(user, registration)
- except ObjectDoesNotExist:
- log.error(
- u'Unable to send reactivation email due to unavailable profile for the user "%s"',
- user.username,
- exc_info=True
- )
- return JsonResponse({
- "success": False,
- "error": _('Unable to send reactivation email')
- })
-
- subject = render_to_string('emails/activation_email_subject.txt', context)
- subject = ''.join(subject.splitlines())
- message = render_to_string('emails/activation_email.txt', context)
- from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
- from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
-
- try:
- user.email_user(subject, message, from_address)
- except Exception: # pylint: disable=broad-except
- log.error(
- u'Unable to send reactivation email from "%s" to "%s"',
- from_address,
- user.email,
- exc_info=True
- )
- return JsonResponse({
- "success": False,
- "error": _('Unable to send reactivation email')
- }) # TODO: this should be status code 500 # pylint: disable=fixme
-
- return JsonResponse({"success": True})
-
-
-def validate_new_email(user, new_email):
- """
- Given a new email for a user, does some basic verification of the new address If any issues are encountered
- with verification a ValueError will be thrown.
- """
- try:
- validate_email(new_email)
- except ValidationError:
- 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:
- raise ValueError(_('An account with this e-mail already exists.'))
-
-
-def do_email_change_request(user, new_email, activation_key=None):
- """
- 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.
- """
- pec_list = PendingEmailChange.objects.filter(user=user)
- if len(pec_list) == 0:
- pec = PendingEmailChange()
- pec.user = user
- else:
- pec = pec_list[0]
-
- # if activation_key is not passing as an argument, generate a random key
- if not activation_key:
- activation_key = uuid.uuid4().hex
-
- pec.new_email = new_email
- pec.activation_key = activation_key
- pec.save()
-
- context = {
- 'key': pec.activation_key,
- 'old_email': user.email,
- 'new_email': pec.new_email
- }
-
- subject = render_to_string('emails/email_change_subject.txt', context)
- subject = ''.join(subject.splitlines())
-
- message = render_to_string('emails/email_change.txt', context)
-
- from_address = configuration_helpers.get_value(
- 'email_from_address',
- settings.DEFAULT_FROM_EMAIL
- )
- try:
- 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)
- raise ValueError(_('Unable to send email activation link. Please try again later.'))
-
- # When the email address change is complete, a "edx.user.settings.changed" event will be emitted.
- # But because changing the email address is multi-step, we also emit an event here so that we can
- # track where the request was initiated.
- tracker.emit(
- SETTING_CHANGE_INITIATED,
- {
- "setting": "email",
- "old": context['old_email'],
- "new": context['new_email'],
- "user_id": user.id,
- }
- )
-
-
-@ensure_csrf_cookie
-def confirm_email_change(request, key): # pylint: disable=unused-argument
- """
- User requested a new e-mail. This is called when the activation
- link is clicked. We confirm with the old e-mail, and update
- """
- with transaction.atomic():
- try:
- pec = PendingEmailChange.objects.get(activation_key=key)
- except PendingEmailChange.DoesNotExist:
- response = render_to_response("invalid_email_key.html", {})
- transaction.set_rollback(True)
- return response
-
- user = pec.user
- address_context = {
- 'old_email': user.email,
- 'new_email': pec.new_email
- }
-
- if len(User.objects.filter(email=pec.new_email)) != 0:
- response = render_to_response("email_exists.html", {})
- transaction.set_rollback(True)
- return response
-
- subject = render_to_string('emails/email_change_subject.txt', address_context)
- subject = ''.join(subject.splitlines())
- message = render_to_string('emails/confirm_email_change.txt', address_context)
- u_prof = UserProfile.objects.get(user=user)
- meta = u_prof.get_meta()
- if 'old_emails' not in meta:
- meta['old_emails'] = []
- meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
- u_prof.set_meta(meta)
- u_prof.save()
- # Send it to the old email...
- try:
- user.email_user(
- subject,
- message,
- configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
- )
- except Exception: # pylint: disable=broad-except
- log.warning('Unable to send confirmation email to old address', exc_info=True)
- response = render_to_response("email_change_failed.html", {'email': user.email})
- transaction.set_rollback(True)
- return response
-
- user.email = pec.new_email
- user.save()
- pec.delete()
- # And send it to the new email...
- try:
- user.email_user(
- subject,
- message,
- configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
- )
- except Exception: # pylint: disable=broad-except
- log.warning('Unable to send confirmation email to new address', exc_info=True)
- response = render_to_response("email_change_failed.html", {'email': pec.new_email})
- transaction.set_rollback(True)
- return response
-
- response = render_to_response("email_change_successful.html", address_context)
- return response
-
-
-@require_POST
-@login_required
-@ensure_csrf_cookie
-def change_email_settings(request):
- """Modify logged-in user's setting for receiving emails from a course."""
- user = request.user
-
- course_id = request.POST.get("course_id")
- course_key = CourseKey.from_string(course_id)
- receive_emails = request.POST.get("receive_emails")
- if receive_emails:
- optout_object = Optout.objects.filter(user=user, course_id=course_key)
- if optout_object:
- optout_object.delete()
- log.info(
- u"User %s (%s) opted in to receive emails from course %s",
- user.username,
- user.email,
- course_id,
- )
- track.views.server_track(
- request,
- "change-email-settings",
- {"receive_emails": "yes", "course": course_id},
- page='dashboard',
- )
- else:
- Optout.objects.get_or_create(user=user, course_id=course_key)
- log.info(
- u"User %s (%s) opted out of receiving emails from course %s",
- user.username,
- user.email,
- course_id,
- )
- track.views.server_track(
- request,
- "change-email-settings",
- {"receive_emails": "no", "course": course_id},
- page='dashboard',
- )
-
- return JsonResponse({"success": True})
-
-
-class LogoutView(TemplateView):
- """
- Logs out user and redirects.
-
- The template should load iframes to log the user out of OpenID Connect services.
- See http://openid.net/specs/openid-connect-logout-1_0.html.
- """
- oauth_client_ids = []
- template_name = 'logout.html'
-
- # Keep track of the page to which the user should ultimately be redirected.
- default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/'
-
- @property
- def target(self):
- """
- If a redirect_url is specified in the querystring for this request, and the value is a url
- with the same host, the view will redirect to this page after rendering the template.
- If it is not specified, we will use the default target url.
- """
- target_url = self.request.GET.get('redirect_url')
-
- if target_url and is_safe_url(target_url, self.request.META.get('HTTP_HOST')):
- return target_url
- else:
- return self.default_target
-
- def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring
- # We do not log here, because we have a handler registered to perform logging on successful logouts.
- request.is_from_logout = True
-
- # Get the list of authorized clients before we clear the session.
- self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, [])
-
- logout(request)
-
- # If we don't need to deal with OIDC logouts, just redirect the user.
- if self.oauth_client_ids:
- response = super(LogoutView, self).dispatch(request, *args, **kwargs)
- else:
- response = redirect(self.target)
-
- # Clear the cookie used by the edx.org marketing site
- delete_logged_in_cookies(response)
-
- return response
-
- def _build_logout_url(self, url):
- """
- Builds a logout URL with the `no_redirect` query string parameter.
-
- Args:
- url (str): IDA logout URL
-
- Returns:
- str
- """
- scheme, netloc, path, query_string, fragment = urlsplit(url)
- query_params = parse_qs(query_string)
- query_params['no_redirect'] = 1
- new_query_string = urlencode(query_params, doseq=True)
- return urlunsplit((scheme, netloc, path, new_query_string, fragment))
-
- def get_context_data(self, **kwargs):
- context = super(LogoutView, self).get_context_data(**kwargs)
-
- # Create a list of URIs that must be called to log the user out of all of the IDAs.
- uris = Client.objects.filter(client_id__in=self.oauth_client_ids,
- logout_uri__isnull=False).values_list('logout_uri', flat=True)
-
- referrer = self.request.META.get('HTTP_REFERER', '').strip('/')
- logout_uris = []
-
- for uri in uris:
- if not referrer or (referrer and not uri.startswith(referrer)):
- logout_uris.append(self._build_logout_url(uri))
-
- context.update({
- 'target': self.target,
- 'logout_uris': logout_uris,
- })
-
- return context
-
-
-@ensure_csrf_cookie
-def text_me_the_app(request):
- """
- Text me the app view.
- """
- text_me_fragment = TextMeTheAppFragmentView().render_to_fragment(request)
- context = {
- 'nav_hidden': True,
- 'show_dashboard_tabs': True,
- 'show_program_listing': ProgramsApiConfig.is_enabled(),
- 'fragment': text_me_fragment
- }
-
- return render_to_response('text-me-the-app.html', context)
diff --git a/common/djangoapps/student/views/__init__.py b/common/djangoapps/student/views/__init__.py
new file mode 100644
index 0000000000..eefb35ad17
--- /dev/null
+++ b/common/djangoapps/student/views/__init__.py
@@ -0,0 +1,8 @@
+"""
+Combines all of the broken out student views
+"""
+
+# pylint: disable=wildcard-import
+from dashboard import *
+from login import *
+from management import *
diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py
new file mode 100644
index 0000000000..f4862d60b3
--- /dev/null
+++ b/common/djangoapps/student/views/dashboard.py
@@ -0,0 +1,764 @@
+"""
+Dashboard view and supporting methods
+"""
+
+import datetime
+import logging
+from collections import defaultdict
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
+from opaque_keys.edx.keys import CourseKey
+from pytz import UTC
+from six import text_type, iteritems
+
+import track.views
+from bulk_email.models import BulkEmailFlag, Optout # pylint: disable=import-error
+from course_modes.models import CourseMode
+from courseware.access import has_access
+from edxmako.shortcuts import render_to_response, render_to_string
+from entitlements.models import CourseEntitlement
+from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
+from openedx.core.djangoapps import monitoring_utils
+from openedx.core.djangoapps.catalog.utils import (
+ get_programs,
+ get_pseudo_session_for_entitlement,
+ get_visible_sessions_for_entitlement
+)
+from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from openedx.core.djangoapps.programs.utils import ProgramDataExtender, ProgramProgressMeter
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace
+from openedx.features.enterprise_support.api import get_dashboard_consent_notification
+from shoppingcart.api import order_history
+from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
+from student.cookies import set_user_info_cookie
+from student.helpers import cert_info, check_verify_status_by_course
+from student.models import (
+ CourseEnrollment,
+ CourseEnrollmentAttribute,
+ DashboardConfiguration,
+ UserProfile
+)
+from util.milestones_helpers import get_pre_requisite_courses_not_completed
+from xmodule.modulestore.django import modulestore
+
+log = logging.getLogger("edx.student")
+
+
+def get_org_black_and_whitelist_for_site():
+ """
+ Returns the org blacklist and whitelist for the current site.
+
+ Returns:
+ (org_whitelist, org_blacklist): A tuple of lists of orgs that serve as
+ either a blacklist or a whitelist of orgs for the current site. The
+ whitelist takes precedence, and the blacklist is used if the
+ whitelist is None.
+ """
+ # Default blacklist is empty.
+ org_blacklist = None
+ # Whitelist the orgs configured for the current site. Each site outside
+ # of edx.org has a list of orgs associated with its configuration.
+ org_whitelist = configuration_helpers.get_current_site_orgs()
+
+ if not org_whitelist:
+ # If there is no whitelist, the blacklist will include all orgs that
+ # have been configured for any other sites. This applies to edx.org,
+ # where it is easier to blacklist all other orgs.
+ org_blacklist = configuration_helpers.get_all_orgs()
+
+ return org_whitelist, org_blacklist
+
+
+def _get_recently_enrolled_courses(course_enrollments):
+ """
+ Given a list of enrollments, filter out all but recent enrollments.
+
+ Args:
+ course_enrollments (list[CourseEnrollment]): A list of course enrollments.
+
+ Returns:
+ list[CourseEnrollment]: A list of recent course enrollments.
+ """
+ seconds = DashboardConfiguration.current().recent_enrollment_time_delta
+ time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
+ return [
+ enrollment for enrollment in course_enrollments
+ # If the enrollment has no created date, we are explicitly excluding the course
+ # from the list of recent enrollments.
+ if enrollment.is_active and enrollment.created > time_delta
+ ]
+
+
+def _allow_donation(course_modes, course_id, enrollment):
+ """
+ Determines if the dashboard will request donations for the given course.
+
+ Check if donations are configured for the platform, and if the current course is accepting donations.
+
+ Args:
+ course_modes (dict): Mapping of course ID's to course mode dictionaries.
+ course_id (str): The unique identifier for the course.
+ enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
+
+ Returns:
+ True if the course is allowing donations.
+
+ """
+ if course_id not in course_modes:
+ flat_unexpired_modes = {
+ text_type(course_id): [mode for mode in modes]
+ for course_id, modes in iteritems(course_modes)
+ }
+ flat_all_modes = {
+ text_type(course_id): [mode.slug for mode in modes]
+ for course_id, modes in iteritems(CourseMode.all_modes_for_courses([course_id]))
+ }
+ log.error(
+ u'Can not find `%s` in course modes.`%s`. All modes: `%s`',
+ course_id,
+ flat_unexpired_modes,
+ flat_all_modes
+ )
+ donations_enabled = configuration_helpers.get_value(
+ 'ENABLE_DONATIONS',
+ DonationConfiguration.current().enabled
+ )
+ return (
+ donations_enabled and
+ enrollment.mode in course_modes[course_id] and
+ course_modes[course_id][enrollment.mode].min_price == 0
+ )
+
+
+def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name
+ """
+ Builds a recent course enrollment message.
+
+ Constructs a new message template based on any recent course enrollments
+ for the student.
+
+ Args:
+ course_enrollments (list[CourseEnrollment]): a list of course enrollments.
+ course_modes (dict): Mapping of course ID's to course mode dictionaries.
+
+ Returns:
+ A string representing the HTML message output from the message template.
+ None if there are no recently enrolled courses.
+
+ """
+ recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
+
+ if recently_enrolled_courses:
+ enrollments_count = len(recently_enrolled_courses)
+ course_name_separator = ', '
+ # If length of enrolled course 2, join names with 'and'
+ if enrollments_count == 2:
+ course_name_separator = _(' and ')
+
+ course_names = course_name_separator.join(
+ [enrollment.course_overview.display_name for enrollment in recently_enrolled_courses]
+ )
+
+ allow_donations = any(
+ _allow_donation(course_modes, enrollment.course_overview.id, enrollment)
+ for enrollment in recently_enrolled_courses
+ )
+
+ platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
+
+ return render_to_string(
+ 'enrollment/course_enrollment_message.html',
+ {
+ 'course_names': course_names,
+ 'enrollments_count': enrollments_count,
+ 'allow_donations': allow_donations,
+ 'platform_name': platform_name,
+ 'course_id': recently_enrolled_courses[0].course_overview.id if enrollments_count == 1 else None
+ }
+ )
+
+
+def get_course_enrollments(user, org_whitelist, org_blacklist):
+ """
+ Given a user, return a filtered set of his or her course enrollments.
+
+ Arguments:
+ user (User): the user in question.
+ org_whitelist (list[str]): If not None, ONLY courses of these orgs will be returned.
+ org_blacklist (list[str]): Courses of these orgs will be excluded.
+
+ Returns:
+ generator[CourseEnrollment]: a sequence of enrollments to be displayed
+ on the user's dashboard.
+ """
+ for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user):
+
+ # If the course is missing or broken, log an error and skip it.
+ course_overview = enrollment.course_overview
+ if not course_overview:
+ log.error(
+ "User %s enrolled in broken or non-existent course %s",
+ user.username,
+ enrollment.course_id
+ )
+ continue
+
+ # Filter out anything that is not in the whitelist.
+ if org_whitelist and course_overview.location.org not in org_whitelist:
+ continue
+
+ # Conversely, filter out any enrollments in the blacklist.
+ elif org_blacklist and course_overview.location.org in org_blacklist:
+ continue
+
+ # Else, include the enrollment.
+ else:
+ yield enrollment
+
+
+def complete_course_mode_info(course_id, enrollment, modes=None):
+ """
+ We would like to compute some more information from the given course modes
+ and the user's current enrollment
+
+ Returns the given information:
+ - whether to show the course upsell information
+ - numbers of days until they can't upsell anymore
+ """
+ if modes is None:
+ modes = CourseMode.modes_for_course_dict(course_id)
+
+ mode_info = {'show_upsell': False, 'days_for_upsell': None}
+ # we want to know if the user is already enrolled as verified or credit and
+ # if verified is an option.
+ if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES:
+ mode_info['show_upsell'] = True
+ mode_info['verified_sku'] = modes['verified'].sku
+ mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku
+ # if there is an expiration date, find out how long from now it is
+ if modes['verified'].expiration_datetime:
+ today = datetime.datetime.now(UTC).date()
+ mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
+
+ return mode_info
+
+
+def is_course_blocked(request, redeemed_registration_codes, course_key):
+ """
+ Checking if registration is blocked or not.
+ """
+ blocked = False
+ for redeemed_registration in redeemed_registration_codes:
+ # registration codes may be generated via Bulk Purchase Scenario
+ # we have to check only for the invoice generated registration codes
+ # that their invoice is valid or not
+ if redeemed_registration.invoice_item:
+ if not redeemed_registration.invoice_item.invoice.is_valid:
+ blocked = True
+ # disabling email notifications for unpaid registration courses
+ Optout.objects.get_or_create(user=request.user, course_id=course_key)
+ log.info(
+ u"User %s (%s) opted out of receiving emails from course %s",
+ request.user.username,
+ request.user.email,
+ course_key,
+ )
+ track.views.server_track(
+ request,
+ "change-email1-settings",
+ {"receive_emails": "no", "course": text_type(course_key)},
+ page='dashboard',
+ )
+ break
+
+ return blocked
+
+
+def get_verification_error_reasons_for_display(verification_error_codes):
+ verification_errors = []
+ verification_error_map = {
+ 'photos_mismatched': _('Photos are mismatched'),
+ 'id_image_missing_name': _('Name missing from ID photo'),
+ 'id_image_missing': _('ID photo not provided'),
+ 'id_invalid': _('ID is invalid'),
+ 'user_image_not_clear': _('Learner photo is blurry'),
+ 'name_mismatch': _('Name on ID does not match name on account'),
+ 'user_image_missing': _('Learner photo not provided'),
+ 'id_image_not_clear': _('ID photo is blurry'),
+ }
+
+ for error in verification_error_codes:
+ error_text = verification_error_map.get(error)
+ if error_text:
+ verification_errors.append(error_text)
+
+ return verification_errors
+
+
+def reverification_info(statuses):
+ """
+ Returns reverification-related information for *all* of user's enrollments whose
+ reverification status is in statuses.
+
+ Args:
+ statuses (list): a list of reverification statuses we want information for
+ example: ["must_reverify", "denied"]
+
+ Returns:
+ dictionary of lists: dictionary with one key per status, e.g.
+ dict["must_reverify"] = []
+ dict["must_reverify"] = [some information]
+ """
+ reverifications = defaultdict(list)
+
+ # Sort the data by the reverification_end_date
+ for status in statuses:
+ if reverifications[status]:
+ reverifications[status].sort(key=lambda x: x.date)
+ return reverifications
+
+
+def _credit_statuses(user, course_enrollments):
+ """
+ Retrieve the status for credit courses.
+
+ A credit course is a course for which a user can purchased
+ college credit. The current flow is:
+
+ 1. User becomes eligible for credit (submits verifications, passes the course, etc.)
+ 2. User purchases credit from a particular credit provider.
+ 3. User requests credit from the provider, usually creating an account on the provider's site.
+ 4. The credit provider notifies us whether the user's request for credit has been accepted or rejected.
+
+ The dashboard is responsible for communicating the user's state in this flow.
+
+ Arguments:
+ user (User): The currently logged-in user.
+ course_enrollments (list[CourseEnrollment]): List of enrollments for the
+ user.
+
+ Returns: dict
+
+ The returned dictionary has keys that are `CourseKey`s and values that
+ are dictionaries with:
+
+ * eligible (bool): True if the user is eligible for credit in this course.
+ * deadline (datetime): The deadline for purchasing and requesting credit for this course.
+ * purchased (bool): Whether the user has purchased credit for this course.
+ * provider_name (string): The display name of the credit provider.
+ * provider_status_url (string): A URL the user can visit to check on their credit request status.
+ * request_status (string): Either "pending", "approved", or "rejected"
+ * error (bool): If true, an unexpected error occurred when retrieving the credit status,
+ so the user should contact the support team.
+
+ Example:
+ >>> _credit_statuses(user, course_enrollments)
+ {
+ CourseKey.from_string("edX/DemoX/Demo_Course"): {
+ "course_key": "edX/DemoX/Demo_Course",
+ "eligible": True,
+ "deadline": 2015-11-23 00:00:00 UTC,
+ "purchased": True,
+ "provider_name": "Hogwarts",
+ "provider_status_url": "http://example.com/status",
+ "request_status": "pending",
+ "error": False
+ }
+ }
+
+ """
+ from openedx.core.djangoapps.credit import api as credit_api
+
+ # Feature flag off
+ if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"):
+ return {}
+
+ request_status_by_course = {
+ request["course_key"]: request["status"]
+ for request in credit_api.get_credit_requests_for_user(user.username)
+ }
+
+ credit_enrollments = {
+ enrollment.course_id: enrollment
+ for enrollment in course_enrollments
+ if enrollment.mode == "credit"
+ }
+
+ # When a user purchases credit in a course, the user's enrollment
+ # mode is set to "credit" and an enrollment attribute is set
+ # with the ID of the credit provider. We retrieve *all* such attributes
+ # here to minimize the number of database queries.
+ purchased_credit_providers = {
+ attribute.enrollment.course_id: attribute.value
+ for attribute in CourseEnrollmentAttribute.objects.filter(
+ namespace="credit",
+ name="provider_id",
+ enrollment__in=credit_enrollments.values()
+ ).select_related("enrollment")
+ }
+
+ provider_info_by_id = {
+ provider["id"]: provider
+ for provider in credit_api.get_credit_providers()
+ }
+
+ statuses = {}
+ for eligibility in credit_api.get_eligibilities_for_user(user.username):
+ course_key = CourseKey.from_string(text_type(eligibility["course_key"]))
+ providers_names = get_credit_provider_display_names(course_key)
+ status = {
+ "course_key": text_type(course_key),
+ "eligible": True,
+ "deadline": eligibility["deadline"],
+ "purchased": course_key in credit_enrollments,
+ "provider_name": make_providers_strings(providers_names),
+ "provider_status_url": None,
+ "provider_id": None,
+ "request_status": request_status_by_course.get(course_key),
+ "error": False,
+ }
+
+ # If the user has purchased credit, then include information about the credit
+ # provider from which the user purchased credit.
+ # We retrieve the provider's ID from the an "enrollment attribute" set on the user's
+ # enrollment when the user's order for credit is fulfilled by the E-Commerce service.
+ if status["purchased"]:
+ provider_id = purchased_credit_providers.get(course_key)
+ if provider_id is None:
+ status["error"] = True
+ log.error(
+ u"Could not find credit provider associated with credit enrollment "
+ u"for user %s in course %s. The user will not be able to see his or her "
+ u"credit request status on the student dashboard. This attribute should "
+ u"have been set when the user purchased credit in the course.",
+ user.id, course_key
+ )
+ else:
+ provider_info = provider_info_by_id.get(provider_id, {})
+ status["provider_name"] = provider_info.get("display_name")
+ status["provider_status_url"] = provider_info.get("status_url")
+ status["provider_id"] = provider_id
+
+ statuses[course_key] = status
+
+ return statuses
+
+
+@login_required
+@ensure_csrf_cookie
+def student_dashboard(request):
+ """
+ Provides the LMS dashboard view
+
+ TODO: This is lms specific and does not belong in common code.
+
+ Arguments:
+ request: The request object.
+
+ Returns:
+ The dashboard response.
+
+ """
+ user = request.user
+ if not UserProfile.objects.filter(user=user).exists():
+ return redirect(reverse('account_settings'))
+
+ platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
+ enable_verified_certificates = configuration_helpers.get_value(
+ 'ENABLE_VERIFIED_CERTIFICATES',
+ settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES')
+ )
+ display_course_modes_on_dashboard = configuration_helpers.get_value(
+ 'DISPLAY_COURSE_MODES_ON_DASHBOARD',
+ settings.FEATURES.get('DISPLAY_COURSE_MODES_ON_DASHBOARD', True)
+ )
+ activation_email_support_link = configuration_helpers.get_value(
+ 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK
+ ) or settings.SUPPORT_SITE_LINK
+
+ # Get the org whitelist or the org blacklist for the current site
+ site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site()
+ course_enrollments = list(get_course_enrollments(user, site_org_whitelist, site_org_blacklist))
+
+ # Get the entitlements for the user and a mapping to all available sessions for that entitlement
+ # If an entitlement has no available sessions, pass through a mock course overview object
+ course_entitlements = list(CourseEntitlement.get_active_entitlements_for_user(user))
+ course_entitlement_available_sessions = {}
+ unfulfilled_entitlement_pseudo_sessions = {}
+ for course_entitlement in course_entitlements:
+ course_entitlement.update_expired_at()
+ available_sessions = get_visible_sessions_for_entitlement(course_entitlement)
+ course_entitlement_available_sessions[str(course_entitlement.uuid)] = available_sessions
+ if not course_entitlement.enrollment_course_run:
+ # Unfulfilled entitlements need a mock session for metadata
+ pseudo_session = get_pseudo_session_for_entitlement(course_entitlement)
+ unfulfilled_entitlement_pseudo_sessions[str(course_entitlement.uuid)] = pseudo_session
+
+ # Record how many courses there are so that we can get a better
+ # understanding of usage patterns on prod.
+ monitoring_utils.accumulate('num_courses', len(course_enrollments))
+
+ # Sort the enrollment pairs by the enrollment date
+ course_enrollments.sort(key=lambda x: x.created, reverse=True)
+
+ # Retrieve the course modes for each course
+ enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
+ __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
+ course_modes_by_course = {
+ course_id: {
+ mode.slug: mode
+ for mode in modes
+ }
+ for course_id, modes in iteritems(unexpired_course_modes)
+ }
+
+ # Check to see if the student has recently enrolled in a course.
+ # If so, display a notification message confirming the enrollment.
+ enrollment_message = _create_recent_enrollment_message(
+ course_enrollments, course_modes_by_course
+ )
+
+ course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
+
+ sidebar_account_activation_message = ''
+ banner_account_activation_message = ''
+ display_account_activation_message_on_sidebar = configuration_helpers.get_value(
+ 'DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR',
+ settings.FEATURES.get('DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR', False)
+ )
+
+ # Display activation message in sidebar if DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR
+ # flag is active. Otherwise display existing message at the top.
+ if display_account_activation_message_on_sidebar and not user.is_active:
+ sidebar_account_activation_message = render_to_string(
+ 'registration/account_activation_sidebar_notice.html',
+ {
+ 'email': user.email,
+ 'platform_name': platform_name,
+ 'activation_email_support_link': activation_email_support_link
+ }
+ )
+ elif not user.is_active:
+ banner_account_activation_message = render_to_string(
+ 'registration/activate_account_notice.html',
+ {'email': user.email}
+ )
+
+ enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments)
+
+ # Disable lookup of Enterprise consent_required_course due to ENT-727
+ # Will re-enable after fixing WL-1315
+ consent_required_courses = set()
+ enterprise_customer_name = None
+
+ # Account activation message
+ account_activation_messages = [
+ message for message in messages.get_messages(request) if 'account-activation' in message.tags
+ ]
+
+ # Global staff can see what courses encountered an error on their dashboard
+ staff_access = False
+ errored_courses = {}
+ if has_access(user, 'staff', 'global'):
+ # Show any courses that encountered an error on load
+ staff_access = True
+ errored_courses = modulestore().get_errored_courses()
+
+ show_courseware_links_for = frozenset(
+ enrollment.course_id for enrollment in course_enrollments
+ if has_access(request.user, 'load', enrollment.course_overview)
+ )
+
+ # Find programs associated with course runs being displayed. This information
+ # is passed in the template context to allow rendering of program-related
+ # information on the dashboard.
+ meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
+ ecommerce_service = EcommerceService()
+ inverted_programs = meter.invert_programs()
+
+ urls, program_data = {}, {}
+ bundles_on_dashboard_flag = WaffleFlag(WaffleFlagNamespace(name=u'student.experiments'), u'bundles_on_dashboard')
+
+ if bundles_on_dashboard_flag.is_enabled():
+ programs_data = meter.programs
+
+ if programs_data and inverted_programs and inverted_programs.values():
+ program_uuid = inverted_programs.values()[0][0]['uuid']
+ meter.programs = [get_programs(request.site, uuid=program_uuid)]
+ program_data = meter.programs[0]
+ program_data = ProgramDataExtender(program_data, request.user).extend()
+
+ skus = program_data.get('skus')
+
+ urls = {
+ 'commerce_api_url': reverse('commerce_api:v0:baskets:create'),
+ 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus)
+ }
+ urls['completeProgramURL'] = urls['buy_button_url'] + '&bundle=' + program_data.get('uuid')
+
+ # Construct a dictionary of course mode information
+ # used to render the course list. We re-use the course modes dict
+ # we loaded earlier to avoid hitting the database.
+ course_mode_info = {
+ enrollment.course_id: complete_course_mode_info(
+ enrollment.course_id, enrollment,
+ modes=course_modes_by_course[enrollment.course_id]
+ )
+ for enrollment in course_enrollments
+ }
+
+ # Determine the per-course verification status
+ # This is a dictionary in which the keys are course locators
+ # and the values are one of:
+ #
+ # VERIFY_STATUS_NEED_TO_VERIFY
+ # VERIFY_STATUS_SUBMITTED
+ # VERIFY_STATUS_APPROVED
+ # VERIFY_STATUS_MISSED_DEADLINE
+ #
+ # Each of which correspond to a particular message to display
+ # next to the course on the dashboard.
+ #
+ # If a course is not included in this dictionary,
+ # there is no verification messaging to display.
+ verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
+ cert_statuses = {
+ enrollment.course_id: cert_info(request.user, enrollment.course_overview)
+ for enrollment in course_enrollments
+ }
+
+ # only show email settings for Mongo course and when bulk email is turned on
+ show_email_settings_for = frozenset(
+ enrollment.course_id for enrollment in course_enrollments if (
+ BulkEmailFlag.feature_enabled(enrollment.course_id)
+ )
+ )
+
+ # Verification Attempts
+ # Used to generate the "you must reverify for course x" banner
+ verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user)
+ verification_errors = get_verification_error_reasons_for_display(verification_error_codes)
+
+ # Gets data for midcourse reverifications, if any are necessary or have failed
+ statuses = ["approved", "denied", "pending", "must_reverify"]
+ reverifications = reverification_info(statuses)
+
+ block_courses = frozenset(
+ enrollment.course_id for enrollment in course_enrollments
+ if is_course_blocked(
+ request,
+ CourseRegistrationCode.objects.filter(
+ course_id=enrollment.course_id,
+ registrationcoderedemption__redeemed_by=request.user
+ ),
+ enrollment.course_id
+ )
+ )
+
+ enrolled_courses_either_paid = frozenset(
+ enrollment.course_id for enrollment in course_enrollments
+ if enrollment.is_paid_course()
+ )
+
+ # If there are *any* denied reverifications that have not been toggled off,
+ # we'll display the banner
+ denied_banner = any(item.display for item in reverifications["denied"])
+
+ # Populate the Order History for the side-bar.
+ order_history_list = order_history(
+ user,
+ course_org_filter=site_org_whitelist,
+ org_filter_out_set=site_org_blacklist
+ )
+
+ # get list of courses having pre-requisites yet to be completed
+ courses_having_prerequisites = frozenset(
+ enrollment.course_id for enrollment in course_enrollments
+ if enrollment.course_overview.pre_requisite_courses
+ )
+ courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
+
+ if 'notlive' in request.GET:
+ redirect_message = _("The course you are looking for does not start until {date}.").format(
+ date=request.GET['notlive']
+ )
+ elif 'course_closed' in request.GET:
+ redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format(
+ date=request.GET['course_closed']
+ )
+ else:
+ redirect_message = ''
+
+ valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
+ display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses
+
+ # Filter out any course enrollment course cards that are associated with fulfilled entitlements
+ for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]:
+ course_enrollments = [
+ enr for enr in course_enrollments if entitlement.enrollment_course_run.course_id != enr.course_id
+ ]
+
+ context = {
+ 'urls': urls,
+ 'program_data': program_data,
+ 'enterprise_message': enterprise_message,
+ 'consent_required_courses': consent_required_courses,
+ 'enterprise_customer_name': enterprise_customer_name,
+ 'enrollment_message': enrollment_message,
+ 'redirect_message': redirect_message,
+ 'account_activation_messages': account_activation_messages,
+ 'course_enrollments': course_enrollments,
+ 'course_entitlements': course_entitlements,
+ 'course_entitlement_available_sessions': course_entitlement_available_sessions,
+ 'unfulfilled_entitlement_pseudo_sessions': unfulfilled_entitlement_pseudo_sessions,
+ 'course_optouts': course_optouts,
+ 'banner_account_activation_message': banner_account_activation_message,
+ 'sidebar_account_activation_message': sidebar_account_activation_message,
+ 'staff_access': staff_access,
+ 'errored_courses': errored_courses,
+ 'show_courseware_links_for': show_courseware_links_for,
+ 'all_course_modes': course_mode_info,
+ 'cert_statuses': cert_statuses,
+ 'credit_statuses': _credit_statuses(user, course_enrollments),
+ 'show_email_settings_for': show_email_settings_for,
+ 'reverifications': reverifications,
+ 'verification_status': verification_status,
+ 'verification_status_by_course': verify_status_by_course,
+ 'verification_errors': verification_errors,
+ 'block_courses': block_courses,
+ 'denied_banner': denied_banner,
+ 'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
+ 'user': user,
+ 'logout_url': reverse('logout'),
+ 'platform_name': platform_name,
+ 'enrolled_courses_either_paid': enrolled_courses_either_paid,
+ 'provider_states': [],
+ 'order_history_list': order_history_list,
+ 'courses_requirements_not_met': courses_requirements_not_met,
+ 'nav_hidden': True,
+ 'inverted_programs': inverted_programs,
+ 'show_program_listing': ProgramsApiConfig.is_enabled(),
+ 'show_dashboard_tabs': True,
+ 'disable_courseware_js': True,
+ 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
+ 'display_sidebar_on_dashboard': display_sidebar_on_dashboard,
+ }
+
+ if ecommerce_service.is_enabled(request.user):
+ context.update({
+ 'use_ecommerce_payment_flow': True,
+ 'ecommerce_payment_page': ecommerce_service.payment_page_url(),
+ })
+
+ response = render_to_response('dashboard.html', context)
+ set_user_info_cookie(response, request)
+ return response
diff --git a/common/djangoapps/student/views/login.py b/common/djangoapps/student/views/login.py
new file mode 100644
index 0000000000..04b19dbc81
--- /dev/null
+++ b/common/djangoapps/student/views/login.py
@@ -0,0 +1,759 @@
+"""
+Views for login / logout and associated functionality
+
+Much of this file was broken out from views.py, previous history can be found there.
+"""
+
+import datetime
+import logging
+import uuid
+import warnings
+from urlparse import parse_qs, urlsplit, urlunsplit
+
+import analytics
+import edx_oauth2_provider
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth import authenticate, load_backend, login as django_login, logout
+from django.contrib.auth.models import AnonymousUser, User
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
+from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
+from django.core.validators import ValidationError, validate_email
+from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+from django.shortcuts import redirect
+from django.template.context_processors import csrf
+from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode
+from django.utils.translation import ugettext as _
+from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
+from django.views.decorators.http import require_GET, require_POST
+from django.views.generic import TemplateView
+from opaque_keys.edx.locator import CourseLocator
+from provider.oauth2.models import Client
+from ratelimitbackend.exceptions import RateLimitException
+from requests import HTTPError
+from six import text_type
+from social_core.backends import oauth as social_oauth
+from social_core.exceptions import AuthAlreadyAssociated, AuthException
+from social_django import utils as social_utils
+
+import openedx.core.djangoapps.external_auth.views
+import third_party_auth
+from django_comment_common.models import assign_role
+from edxmako.shortcuts import render_to_response, render_to_string
+from eventtracking import tracker
+from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login
+from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.features.course_experience import course_home_url_name
+from student.cookies import delete_logged_in_cookies, set_logged_in_cookies
+from student.forms import AccountCreationForm
+from student.helpers import (
+ AccountValidationError,
+ auth_pipeline_urls,
+ create_or_set_user_attribute_created_on_site,
+ generate_activation_email_context,
+ get_next_url_for_login_page
+)
+from student.models import (
+ CourseAccessRole,
+ CourseEnrollment,
+ LoginFailures,
+ PasswordHistory,
+ Registration,
+ UserProfile,
+ anonymous_id_for_user,
+ create_comments_service_user
+)
+from student.helpers import authenticate_new_user, do_create_account
+from third_party_auth import pipeline, provider
+from util.json_request import JsonResponse
+
+log = logging.getLogger("edx.student")
+AUDIT_LOG = logging.getLogger("audit")
+
+
+class AuthFailedError(Exception):
+ """
+ This is a helper for the login view, allowing the various sub-methods to early out with an appropriate failure
+ message.
+ """
+ def __init__(self, value=None, redirect=None, redirect_url=None):
+ self.value = value
+ self.redirect = redirect
+ self.redirect_url = redirect_url
+
+ def get_response(self):
+ resp = {'success': False}
+ for attr in ('value', 'redirect', 'redirect_url'):
+ if self.__getattribute__(attr) and len(self.__getattribute__(attr)):
+ resp[attr] = self.__getattribute__(attr)
+
+ return resp
+
+
+def _do_third_party_auth(request):
+ """
+ User is already authenticated via 3rd party, now try to find and return their associated Django user.
+ """
+ running_pipeline = pipeline.get(request)
+ username = running_pipeline['kwargs'].get('username')
+ backend_name = running_pipeline['backend']
+ third_party_uid = running_pipeline['kwargs']['uid']
+ requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
+ platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
+
+ try:
+ return pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
+ except User.DoesNotExist:
+ AUDIT_LOG.info(
+ u"Login failed - user with username {username} has no social auth "
+ "with backend_name {backend_name}".format(
+ username=username, backend_name=backend_name)
+ )
+ message = _(
+ "You've successfully logged into your {provider_name} account, "
+ "but this account isn't linked with an {platform_name} account yet."
+ ).format(
+ platform_name=platform_name,
+ provider_name=requested_provider.name,
+ )
+ message += "
"
+ message += _(
+ "Use your {platform_name} username and password to log into {platform_name} below, "
+ "and then link your {platform_name} account with {provider_name} from your dashboard."
+ ).format(
+ platform_name=platform_name,
+ provider_name=requested_provider.name,
+ )
+ message += "
"
+ message += _(
+ "If you don't have an {platform_name} account yet, "
+ "click Register at the top of the page."
+ ).format(
+ platform_name=platform_name
+ )
+
+ raise AuthFailedError(message)
+
+
+def _get_user_by_email(request):
+ """
+ Finds a user object in the database based on the given request, ignores all fields except for email.
+ """
+ if 'email' not in request.POST or 'password' not in request.POST:
+ raise AuthFailedError(_('There was an error receiving your login information. Please email us.'))
+
+ email = request.POST['email']
+
+ try:
+ return User.objects.get(email=email)
+ except User.DoesNotExist:
+ if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
+ AUDIT_LOG.warning(u"Login failed - Unknown user email")
+ else:
+ AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
+
+
+def _check_shib_redirect(user):
+ """
+ See if the user has a linked shibboleth account, if so, redirect the user to shib-login.
+ This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu
+ address into the Gmail login.
+ """
+ if settings.FEATURES.get('AUTH_USE_SHIB') and user:
+ try:
+ eamap = ExternalAuthMap.objects.get(user=user)
+ if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
+ raise AuthFailedError('', redirect=reverse('shib-login'))
+ except ExternalAuthMap.DoesNotExist:
+ # This is actually the common case, logging in user without external linked login
+ AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
+
+
+def _check_excessive_login_attempts(user):
+ """
+ See if account has been locked out due to excessive login failures
+ """
+ if user and LoginFailures.is_feature_enabled():
+ if LoginFailures.is_user_locked_out(user):
+ raise AuthFailedError(_('This account has been temporarily locked due '
+ 'to excessive login failures. Try again later.'))
+
+
+def _check_forced_password_reset(user):
+ """
+ See if the user must reset his/her password due to any policy settings
+ """
+ if user and PasswordHistory.should_user_reset_password_now(user):
+ raise AuthFailedError(_('Your password has expired due to password policy on this account. You must '
+ 'reset your password before you can log in again. Please click the '
+ '"Forgot Password" link on this page to reset your password before logging in again.'))
+
+
+def _generate_not_activated_message(user):
+ """
+ Generates the message displayed on the sign-in screen when a learner attempts to access the
+ system with an inactive account.
+ """
+
+ support_url = configuration_helpers.get_value(
+ 'SUPPORT_SITE_LINK',
+ settings.SUPPORT_SITE_LINK
+ )
+
+ platform_name = configuration_helpers.get_value(
+ 'PLATFORM_NAME',
+ settings.PLATFORM_NAME
+ )
+
+ not_activated_msg_template = _('In order to sign in, you need to activate your account.
'
+ 'We just sent an activation link to {email}. If '
+ 'you do not receive an email, check your spam folders or '
+ 'contact {platform} Support.')
+
+ not_activated_message = not_activated_msg_template.format(
+ email=user.email,
+ support_url=support_url,
+ platform=platform_name
+ )
+
+ return not_activated_message
+
+
+def _log_and_raise_inactive_user_auth_error(unauthenticated_user):
+ """
+ Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt
+ by an inactive user, re-sending the activation email, and raising an error with the correct message.
+ """
+ if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
+ AUDIT_LOG.warning(
+ u"Login failed - Account not active for user.id: {0}, resending activation".format(
+ unauthenticated_user.id)
+ )
+ else:
+ AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(
+ unauthenticated_user.username)
+ )
+
+ send_reactivation_email_for_user(unauthenticated_user)
+ raise AuthFailedError(_generate_not_activated_message(unauthenticated_user))
+
+
+def _authenticate_first_party(request, unauthenticated_user):
+ """
+ Use Django authentication on the given request, using rate limiting if configured
+ """
+
+ # If the user doesn't exist, we want to set the username to an invalid username so that authentication is guaranteed
+ # to fail and we can take advantage of the ratelimited backend
+ username = unauthenticated_user.username if unauthenticated_user else ""
+
+ try:
+ return authenticate(
+ username=username,
+ password=request.POST['password'],
+ request=request)
+
+ # This occurs when there are too many attempts from the same IP address
+ except RateLimitException:
+ raise AuthFailedError(_('Too many failed login attempts. Try again later.'))
+
+
+def _handle_failed_authentication(user):
+ """
+ Handles updating the failed login count, inactive user notifications, and logging failed authentications.
+ """
+ if user:
+ if LoginFailures.is_feature_enabled():
+ LoginFailures.increment_lockout_counter(user)
+
+ if not user.is_active:
+ _log_and_raise_inactive_user_auth_error(user)
+
+ # if we didn't find this username earlier, the account for this email
+ # doesn't exist, and doesn't have a corresponding password
+ if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
+ loggable_id = user.id if user else ""
+ AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id))
+ else:
+ AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(user.email))
+
+ raise AuthFailedError(_('Email or password is incorrect.'))
+
+
+def _handle_successful_authentication_and_login(user, request):
+ """
+ Handles clearing the failed login counter, login tracking, and setting session timeout.
+ """
+ if LoginFailures.is_feature_enabled():
+ LoginFailures.clear_lockout_counter(user)
+
+ _track_user_login(user, request)
+
+ try:
+ django_login(request, user)
+ if request.POST.get('remember') == 'true':
+ request.session.set_expiry(604800)
+ log.debug("Setting user session to never expire")
+ else:
+ request.session.set_expiry(0)
+ except Exception as exc: # pylint: disable=broad-except
+ AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
+ log.critical("Login failed - Could not create session. Is memcached running?")
+ log.exception(exc)
+ raise
+
+
+def _track_user_login(user, request):
+ """
+ Sends a tracking event for a successful login.
+ """
+ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
+ tracking_context = tracker.get_tracker().resolve_context()
+ analytics.identify(
+ user.id,
+ {
+ 'email': request.POST['email'],
+ 'username': user.username
+ },
+ {
+ # Disable MailChimp because we don't want to update the user's email
+ # and username in MailChimp on every page load. We only need to capture
+ # this data on registration/activation.
+ 'MailChimp': False
+ }
+ )
+
+ analytics.track(
+ user.id,
+ "edx.bi.user.account.authenticated",
+ {
+ 'category': "conversion",
+ 'label': request.POST.get('course_id'),
+ 'provider': None
+ },
+ context={
+ 'ip': tracking_context.get('ip'),
+ 'Google Analytics': {
+ 'clientId': tracking_context.get('client_id')
+ }
+ }
+ )
+
+
+def send_reactivation_email_for_user(user):
+ try:
+ registration = Registration.objects.get(user=user)
+ except Registration.DoesNotExist:
+ return JsonResponse({
+ "success": False,
+ "error": _('No inactive user with this e-mail exists'),
+ })
+
+ try:
+ context = generate_activation_email_context(user, registration)
+ except ObjectDoesNotExist:
+ log.error(
+ u'Unable to send reactivation email due to unavailable profile for the user "%s"',
+ user.username,
+ exc_info=True
+ )
+ return JsonResponse({
+ "success": False,
+ "error": _('Unable to send reactivation email')
+ })
+
+ subject = render_to_string('emails/activation_email_subject.txt', context)
+ subject = ''.join(subject.splitlines())
+ message = render_to_string('emails/activation_email.txt', context)
+ from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
+ from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
+
+ try:
+ user.email_user(subject, message, from_address)
+ except Exception: # pylint: disable=broad-except
+ log.error(
+ u'Unable to send reactivation email from "%s" to "%s"',
+ from_address,
+ user.email,
+ exc_info=True
+ )
+ return JsonResponse({
+ "success": False,
+ "error": _('Unable to send reactivation email')
+ })
+
+ return JsonResponse({"success": True})
+
+
+@ensure_csrf_cookie
+def login_user(request):
+ """
+ AJAX request to log in the user.
+ """
+ third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
+ trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
+ was_authenticated_third_party = False
+
+ try:
+ if third_party_auth_requested and not trumped_by_first_party_auth:
+ # The user has already authenticated via third-party auth and has not
+ # asked to do first party auth by supplying a username or password. We
+ # now want to put them through the same logging and cookie calculation
+ # logic as with first-party auth.
+
+ # This nested try is due to us only returning an HttpResponse in this
+ # one case vs. JsonResponse everywhere else.
+ try:
+ email_user = _do_third_party_auth(request)
+ was_authenticated_third_party = True
+ except AuthFailedError as e:
+ return HttpResponse(e.value, content_type="text/plain", status=403)
+ else:
+ email_user = _get_user_by_email(request)
+
+ _check_shib_redirect(email_user)
+ _check_excessive_login_attempts(email_user)
+ _check_forced_password_reset(email_user)
+
+ possibly_authenticated_user = email_user
+
+ if not was_authenticated_third_party:
+ possibly_authenticated_user = _authenticate_first_party(request, email_user)
+
+ if possibly_authenticated_user is None or not possibly_authenticated_user.is_active:
+ _handle_failed_authentication(email_user)
+
+ _handle_successful_authentication_and_login(possibly_authenticated_user, request)
+
+ redirect_url = None # The AJAX method calling should know the default destination upon success
+ if was_authenticated_third_party:
+ running_pipeline = pipeline.get(request)
+ redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend'])
+
+ response = JsonResponse({
+ 'success': True,
+ 'redirect_url': redirect_url,
+ })
+
+ # Ensure that the external marketing site can
+ # detect that the user is logged in.
+ return set_logged_in_cookies(request, response, possibly_authenticated_user)
+ except AuthFailedError as error:
+ return JsonResponse(error.get_response())
+
+
+@csrf_exempt
+@require_POST
+@social_utils.psa("social:complete")
+def login_oauth_token(request, backend):
+ """
+ Authenticate the client using an OAuth access token by using the token to
+ retrieve information from a third party and matching that information to an
+ existing user.
+ """
+ warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)
+
+ backend = request.backend
+ if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2):
+ if "access_token" in request.POST:
+ # Tell third party auth pipeline that this is an API call
+ request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
+ user = None
+ access_token = request.POST["access_token"]
+ try:
+ user = backend.do_auth(access_token)
+ except (HTTPError, AuthException):
+ pass
+ # do_auth can return a non-User object if it fails
+ if user and isinstance(user, User):
+ django_login(request, user)
+ return JsonResponse(status=204)
+ else:
+ # Ensure user does not re-enter the pipeline
+ request.social_strategy.clean_partial_pipeline(access_token)
+ return JsonResponse({"error": "invalid_token"}, status=401)
+ else:
+ return JsonResponse({"error": "invalid_request"}, status=400)
+ raise Http404
+
+
+@ensure_csrf_cookie
+def signin_user(request):
+ """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
+ external_auth_response = external_auth_login(request)
+ if external_auth_response is not None:
+ return external_auth_response
+ # Determine the URL to redirect to following login:
+ redirect_to = get_next_url_for_login_page(request)
+ if request.user.is_authenticated():
+ return redirect(redirect_to)
+
+ third_party_auth_error = None
+ for msg in messages.get_messages(request):
+ if msg.extra_tags.split()[0] == "social-auth":
+ # msg may or may not be translated. Try translating [again] in case we are able to:
+ third_party_auth_error = _(text_type(msg)) # pylint: disable=translation-of-non-string
+ break
+
+ context = {
+ 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
+ # Bool injected into JS to submit form if we're inside a running third-
+ # party auth pipeline; distinct from the actual instance of the running
+ # pipeline, if any.
+ 'pipeline_running': 'true' if pipeline.running(request) else 'false',
+ 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
+ 'platform_name': configuration_helpers.get_value(
+ 'platform_name',
+ settings.PLATFORM_NAME
+ ),
+ 'third_party_auth_error': third_party_auth_error
+ }
+
+ return render_to_response('login.html', context)
+
+
+def str2bool(s):
+ s = str(s)
+ return s.lower() in ('yes', 'true', 't', '1')
+
+
+def _clean_roles(roles):
+ """ Clean roles.
+
+ Strips whitespace from roles, and removes empty items.
+
+ Args:
+ roles (str[]): List of role names.
+
+ Returns:
+ str[]
+ """
+ roles = [role.strip() for role in roles]
+ roles = [role for role in roles if role]
+ return roles
+
+
+def auto_auth(request):
+ """
+ Create or configure a user account, then log in as that user.
+
+ Enabled only when
+ settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
+
+ Accepts the following querystring parameters:
+ * `username`, `email`, and `password` for the user account
+ * `full_name` for the user profile (the user's full name; defaults to the username)
+ * `staff`: Set to "true" to make the user global staff.
+ * `course_id`: Enroll the student in the course with `course_id`
+ * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
+ * `no_login`: Define this to create the user but not login
+ * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or
+ course home page if course_id is defined, otherwise it will redirect to dashboard
+ * `redirect_to`: will redirect to to this url
+ * `is_active` : make/update account with status provided as 'is_active'
+ If username, email, or password are not provided, use
+ randomly generated credentials.
+ """
+
+ # Generate a unique name to use if none provided
+ generated_username = uuid.uuid4().hex[0:30]
+
+ # Use the params from the request, otherwise use these defaults
+ username = request.GET.get('username', generated_username)
+ password = request.GET.get('password', username)
+ email = request.GET.get('email', username + "@example.com")
+ full_name = request.GET.get('full_name', username)
+ is_staff = str2bool(request.GET.get('staff', False))
+ is_superuser = str2bool(request.GET.get('superuser', False))
+ course_id = request.GET.get('course_id')
+ redirect_to = request.GET.get('redirect_to')
+ is_active = str2bool(request.GET.get('is_active', True))
+
+ # Valid modes: audit, credit, honor, no-id-professional, professional, verified
+ enrollment_mode = request.GET.get('enrollment_mode', 'honor')
+
+ # Parse roles, stripping whitespace, and filtering out empty strings
+ roles = _clean_roles(request.GET.get('roles', '').split(','))
+ course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(','))
+
+ redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to
+ login_when_done = 'no_login' not in request.GET
+
+ form = AccountCreationForm(
+ data={
+ 'username': username,
+ 'email': email,
+ 'password': password,
+ 'name': full_name,
+ },
+ tos_required=False
+ )
+
+ # Attempt to create the account.
+ # If successful, this will return a tuple containing
+ # the new user object.
+ try:
+ user, profile, reg = do_create_account(form)
+ except (AccountValidationError, ValidationError):
+ # Attempt to retrieve the existing user.
+ user = User.objects.get(username=username)
+ user.email = email
+ user.set_password(password)
+ user.is_active = is_active
+ user.save()
+ profile = UserProfile.objects.get(user=user)
+ reg = Registration.objects.get(user=user)
+ except PermissionDenied:
+ return HttpResponseForbidden(_('Account creation not allowed.'))
+
+ user.is_staff = is_staff
+ user.is_superuser = is_superuser
+ user.save()
+
+ if is_active:
+ reg.activate()
+ reg.save()
+
+ # ensure parental consent threshold is met
+ year = datetime.date.today().year
+ age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT
+ profile.year_of_birth = (year - age_limit) - 1
+ profile.save()
+
+ create_or_set_user_attribute_created_on_site(user, request.site)
+
+ # Enroll the user in a course
+ course_key = None
+ if course_id:
+ course_key = CourseLocator.from_string(course_id)
+ CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
+
+ # Apply the roles
+ for role in roles:
+ assign_role(course_key, user, role)
+
+ for role in course_access_roles:
+ CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role)
+
+ # Log in as the user
+ if login_when_done:
+ user = authenticate_new_user(request, username, password)
+ django_login(request, user)
+
+ create_comments_service_user(user)
+
+ if redirect_when_done:
+ if redirect_to:
+ # Redirect to page specified by the client
+ redirect_url = redirect_to
+ elif course_id:
+ # Redirect to the course homepage (in LMS) or outline page (in Studio)
+ try:
+ redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
+ except NoReverseMatch:
+ redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
+ else:
+ # Redirect to the learner dashboard (in LMS) or homepage (in Studio)
+ try:
+ redirect_url = reverse('dashboard')
+ except NoReverseMatch:
+ redirect_url = reverse('home')
+
+ return redirect(redirect_url)
+ else:
+ response = JsonResponse({
+ 'created_status': 'Logged in' if login_when_done else 'Created',
+ 'username': username,
+ 'email': email,
+ 'password': password,
+ 'user_id': user.id, # pylint: disable=no-member
+ 'anonymous_id': anonymous_id_for_user(user, None),
+ })
+ response.set_cookie('csrftoken', csrf(request)['csrf_token'])
+ return response
+
+
+class LogoutView(TemplateView):
+ """
+ Logs out user and redirects.
+
+ The template should load iframes to log the user out of OpenID Connect services.
+ See http://openid.net/specs/openid-connect-logout-1_0.html.
+ """
+ oauth_client_ids = []
+ template_name = 'logout.html'
+
+ # Keep track of the page to which the user should ultimately be redirected.
+ default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/'
+
+ @property
+ def target(self):
+ """
+ If a redirect_url is specified in the querystring for this request, and the value is a url
+ with the same host, the view will redirect to this page after rendering the template.
+ If it is not specified, we will use the default target url.
+ """
+ target_url = self.request.GET.get('redirect_url')
+
+ if target_url and is_safe_url(target_url, self.request.META.get('HTTP_HOST')):
+ return target_url
+ else:
+ return self.default_target
+
+ def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring
+ # We do not log here, because we have a handler registered to perform logging on successful logouts.
+ request.is_from_logout = True
+
+ # Get the list of authorized clients before we clear the session.
+ self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, [])
+
+ logout(request)
+
+ # If we don't need to deal with OIDC logouts, just redirect the user.
+ if self.oauth_client_ids:
+ response = super(LogoutView, self).dispatch(request, *args, **kwargs)
+ else:
+ response = redirect(self.target)
+
+ # Clear the cookie used by the edx.org marketing site
+ delete_logged_in_cookies(response)
+
+ return response
+
+ def _build_logout_url(self, url):
+ """
+ Builds a logout URL with the `no_redirect` query string parameter.
+
+ Args:
+ url (str): IDA logout URL
+
+ Returns:
+ str
+ """
+ scheme, netloc, path, query_string, fragment = urlsplit(url)
+ query_params = parse_qs(query_string)
+ query_params['no_redirect'] = 1
+ new_query_string = urlencode(query_params, doseq=True)
+ return urlunsplit((scheme, netloc, path, new_query_string, fragment))
+
+ def get_context_data(self, **kwargs):
+ context = super(LogoutView, self).get_context_data(**kwargs)
+
+ # Create a list of URIs that must be called to log the user out of all of the IDAs.
+ uris = Client.objects.filter(client_id__in=self.oauth_client_ids,
+ logout_uri__isnull=False).values_list('logout_uri', flat=True)
+
+ referrer = self.request.META.get('HTTP_REFERER', '').strip('/')
+ logout_uris = []
+
+ for uri in uris:
+ if not referrer or (referrer and not uri.startswith(referrer)):
+ logout_uris.append(self._build_logout_url(uri))
+
+ context.update({
+ 'target': self.target,
+ 'logout_uris': logout_uris,
+ })
+
+ return context
diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py
new file mode 100644
index 0000000000..28227b8e03
--- /dev/null
+++ b/common/djangoapps/student/views/management.py
@@ -0,0 +1,1498 @@
+"""
+Student Views
+"""
+
+import datetime
+import json
+import logging
+import uuid
+import warnings
+from collections import namedtuple
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth import authenticate, load_backend, login as django_login, logout
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import AnonymousUser, User
+from django.contrib.auth.views import password_reset_confirm
+from django.core import mail
+from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
+from django.core.validators import ValidationError, validate_email
+from django.db import IntegrityError, transaction
+from django.db.models.signals import post_save
+from django.dispatch import Signal, receiver
+from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+from django.shortcuts import redirect
+from django.template.context_processors import csrf
+from django.template.response import TemplateResponse
+from django.utils.encoding import force_bytes, force_text
+from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode
+from django.utils.translation import ugettext as _
+from django.utils.translation import get_language, ungettext
+from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
+from django.views.decorators.http import require_GET, require_POST
+from ipware.ip import get_ip
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from pytz import UTC
+from requests import HTTPError
+from six import text_type, iteritems
+from social_core.exceptions import AuthAlreadyAssociated, AuthException
+from social_django import utils as social_utils
+
+import analytics
+import dogstats_wrapper as dog_stats_api
+import openedx.core.djangoapps.external_auth.views
+import third_party_auth
+import track.views
+from bulk_email.models import Optout # pylint: disable=import-error
+from course_modes.models import CourseMode
+from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error
+from edxmako.shortcuts import render_to_response, render_to_string
+from eventtracking import tracker
+# Note that this lives in LMS, so this dependency should be refactored.
+from notification_prefs.views import enable_notifications
+from openedx.core.djangoapps import monitoring_utils
+from openedx.core.djangoapps.catalog.utils import (
+ get_programs_with_type,
+)
+from openedx.core.djangoapps.embargo import api as embargo_api
+from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
+from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
+from openedx.core.djangoapps.programs.models import ProgramsApiConfig
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.djangoapps.theming import helpers as theming_helpers
+from openedx.core.djangoapps.user_api import accounts as accounts_settings
+from openedx.core.djangoapps.user_api.preferences import api as preferences_api
+from openedx.core.djangolib.markup import HTML
+from student.cookies import set_logged_in_cookies
+from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
+from student.helpers import (
+ DISABLE_UNENROLL_CERT_STATES,
+ AccountValidationError,
+ auth_pipeline_urls,
+ authenticate_new_user,
+ cert_info,
+ create_or_set_user_attribute_created_on_site,
+ destroy_oauth_tokens,
+ do_create_account,
+ generate_activation_email_context,
+ get_next_url_for_login_page
+)
+from student.models import (
+ ALLOWEDTOENROLL_TO_ENROLLED,
+ CourseEnrollment,
+ CourseEnrollmentAllowed,
+ ManualEnrollmentAudit,
+ PasswordHistory,
+ PendingEmailChange,
+ Registration,
+ RegistrationCookieConfiguration,
+ UserAttribute,
+ UserProfile,
+ UserSignupSource,
+ UserStanding,
+ create_comments_service_user,
+)
+from student.signals import REFUND_ORDER
+from student.tasks import send_activation_email
+from student.text_me_the_app import TextMeTheAppFragmentView
+from third_party_auth import pipeline, provider
+from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY
+from util.bad_request_rate_limiter import BadRequestRateLimiter
+from util.db import outer_atomic
+from util.json_request import JsonResponse
+from util.password_policy_validators import validate_password_length, validate_password_strength
+from xmodule.modulestore.django import modulestore
+
+log = logging.getLogger("edx.student")
+
+AUDIT_LOG = logging.getLogger("audit")
+ReverifyInfo = namedtuple(
+ 'ReverifyInfo',
+ 'course_id course_name course_number date status display'
+) # pylint: disable=invalid-name
+SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
+# Used as the name of the user attribute for tracking affiliate registrations
+REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
+REGISTRATION_UTM_PARAMETERS = {
+ 'utm_source': 'registration_utm_source',
+ 'utm_medium': 'registration_utm_medium',
+ 'utm_campaign': 'registration_utm_campaign',
+ 'utm_term': 'registration_utm_term',
+ 'utm_content': 'registration_utm_content',
+}
+REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
+# used to announce a registration
+REGISTER_USER = Signal(providing_args=["user", "registration"])
+
+
+def csrf_token(context):
+ """
+ A csrf token that can be included in a form.
+ """
+ token = context.get('csrf_token', '')
+ if token == 'NOTPROVIDED':
+ return ''
+ return (u''.format(token))
+
+
+# NOTE: This view is not linked to directly--it is called from
+# branding/views.py:index(), which is cached for anonymous users.
+# This means that it should always return the same thing for anon
+# users. (in particular, no switching based on query params allowed)
+def index(request, extra_context=None, user=AnonymousUser()):
+ """
+ Render the edX main page.
+
+ extra_context is used to allow immediate display of certain modal windows, eg signup,
+ as used by external_auth.
+ """
+ if extra_context is None:
+ extra_context = {}
+
+ courses = get_courses(user)
+
+ if configuration_helpers.get_value(
+ "ENABLE_COURSE_SORTING_BY_START_DATE",
+ settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"],
+ ):
+ courses = sort_by_start_date(courses)
+ else:
+ courses = sort_by_announcement(courses)
+
+ context = {'courses': courses}
+
+ context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html')
+
+ # This appears to be an unused context parameter, at least for the master templates...
+ context['show_partners'] = configuration_helpers.get_value('show_partners', True)
+
+ # TO DISPLAY A YOUTUBE WELCOME VIDEO
+ # 1) Change False to True
+ context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False)
+
+ # Maximum number of courses to display on the homepage.
+ context['homepage_course_max'] = configuration_helpers.get_value(
+ 'HOMEPAGE_COURSE_MAX', settings.HOMEPAGE_COURSE_MAX
+ )
+
+ # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration
+ # Note: This value should be moved into a configuration setting and plumbed-through to the
+ # context via the site configuration workflow, versus living here
+ youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id")
+ context['homepage_promo_video_youtube_id'] = youtube_video_id
+
+ # allow for theme override of the courses list
+ context['courses_list'] = theming_helpers.get_template_path('courses_list.html')
+
+ # Insert additional context for use in the template
+ context.update(extra_context)
+
+ # Add marketable programs to the context.
+ context['programs_list'] = get_programs_with_type(request.site, include_hidden=False)
+
+ return render_to_response('index.html', context)
+
+
+@ensure_csrf_cookie
+def register_user(request, extra_context=None):
+ """
+ Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.
+ """
+ # Determine the URL to redirect to following login:
+ redirect_to = get_next_url_for_login_page(request)
+ if request.user.is_authenticated():
+ return redirect(redirect_to)
+
+ external_auth_response = external_auth_register(request)
+ if external_auth_response is not None:
+ return external_auth_response
+
+ context = {
+ 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
+ 'email': '',
+ 'name': '',
+ 'running_pipeline': None,
+ 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
+ 'platform_name': configuration_helpers.get_value(
+ 'platform_name',
+ settings.PLATFORM_NAME
+ ),
+ 'selected_provider': '',
+ 'username': '',
+ }
+
+ if extra_context is not None:
+ context.update(extra_context)
+
+ if context.get("extauth_domain", '').startswith(
+ openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
+ ):
+ return render_to_response('register-shib.html', context)
+
+ # If third-party auth is enabled, prepopulate the form with data from the
+ # selected provider.
+ if third_party_auth.is_enabled() and pipeline.running(request):
+ running_pipeline = pipeline.get(request)
+ current_provider = provider.Registry.get_from_pipeline(running_pipeline)
+ if current_provider is not None:
+ overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
+ overrides['running_pipeline'] = running_pipeline
+ overrides['selected_provider'] = current_provider.name
+ context.update(overrides)
+
+ return render_to_response('register.html', context)
+
+
+def compose_and_send_activation_email(user, profile, user_registration=None):
+ """
+ Construct all the required params and send the activation email
+ through celery task
+
+ Arguments:
+ user: current logged-in user
+ profile: profile object of the current logged-in user
+ user_registration: registration of the current logged-in user
+ """
+ dest_addr = user.email
+ if user_registration is None:
+ user_registration = Registration.objects.get(user=user)
+ context = generate_activation_email_context(user, user_registration)
+ subject = render_to_string('emails/activation_email_subject.txt', context)
+ # Email subject *must not* contain newlines
+ subject = ''.join(subject.splitlines())
+ message_for_activation = render_to_string('emails/activation_email.txt', context)
+ from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
+ from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
+ if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
+ dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
+ message_for_activation = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
+ '-' * 80 + '\n\n' + message_for_activation)
+ send_activation_email.delay(subject, message_for_activation, from_address, dest_addr)
+
+
+@login_required
+def course_run_refund_status(request, course_id):
+ """
+ Get Refundable status for a course.
+
+ Arguments:
+ request: The request object.
+ course_id (str): The unique identifier for the course.
+
+ Returns:
+ Json response.
+
+ """
+
+ try:
+ course_key = CourseKey.from_string(course_id)
+ course_enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
+
+ except InvalidKeyError:
+ logging.exception("The course key used to get refund status caused InvalidKeyError during look up.")
+
+ return JsonResponse({'course_refundable_status': ''}, status=406)
+
+ refundable_status = course_enrollment.refundable()
+ logging.info("Course refund status for course {0} is {1}".format(course_id, refundable_status))
+
+ return JsonResponse({'course_refundable_status': refundable_status}, status=200)
+
+
+def _update_email_opt_in(request, org):
+ """
+ Helper function used to hit the profile API if email opt-in is enabled.
+ """
+
+ email_opt_in = request.POST.get('email_opt_in')
+ if email_opt_in is not None:
+ email_opt_in_boolean = email_opt_in == 'true'
+ preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
+
+
+@transaction.non_atomic_requests
+@require_POST
+@outer_atomic(read_committed=True)
+def change_enrollment(request, check_access=True):
+ """
+ Modify the enrollment status for the logged-in user.
+
+ TODO: This is lms specific and does not belong in common code.
+
+ The request parameter must be a POST request (other methods return 405)
+ that specifies course_id and enrollment_action parameters. If course_id or
+ enrollment_action is not specified, if course_id is not valid, if
+ enrollment_action is something other than "enroll" or "unenroll", if
+ enrollment_action is "enroll" and enrollment is closed for the course, or
+ if enrollment_action is "unenroll" and the user is not enrolled in the
+ course, a 400 error will be returned. If the user is not logged in, 403
+ will be returned; it is important that only this case return 403 so the
+ front end can redirect the user to a registration or login page when this
+ happens. This function should only be called from an AJAX request, so
+ the error messages in the responses should never actually be user-visible.
+
+ Args:
+ request (`Request`): The Django request object
+
+ Keyword Args:
+ check_access (boolean): If True, we check that an accessible course actually
+ exists for the given course_key before we enroll the student.
+ The default is set to False to avoid breaking legacy code or
+ code with non-standard flows (ex. beta tester invitations), but
+ for any standard enrollment flow you probably want this to be True.
+
+ Returns:
+ Response
+
+ """
+ # Get the user
+ user = request.user
+
+ # Ensure the user is authenticated
+ if not user.is_authenticated():
+ return HttpResponseForbidden()
+
+ # Ensure we received a course_id
+ action = request.POST.get("enrollment_action")
+ if 'course_id' not in request.POST:
+ return HttpResponseBadRequest(_("Course id not specified"))
+
+ try:
+ course_id = CourseKey.from_string(request.POST.get("course_id"))
+ except InvalidKeyError:
+ log.warning(
+ u"User %s tried to %s with invalid course id: %s",
+ user.username,
+ action,
+ request.POST.get("course_id"),
+ )
+ return HttpResponseBadRequest(_("Invalid course id"))
+
+ # Allow us to monitor performance of this transaction on a per-course basis since we often roll-out features
+ # on a per-course basis.
+ monitoring_utils.set_custom_metric('course_id', text_type(course_id))
+
+ if action == "enroll":
+ # Make sure the course exists
+ # We don't do this check on unenroll, or a bad course id can't be unenrolled from
+ if not modulestore().has_course(course_id):
+ log.warning(
+ u"User %s tried to enroll in non-existent course %s",
+ user.username,
+ course_id
+ )
+ return HttpResponseBadRequest(_("Course id is invalid"))
+
+ # Record the user's email opt-in preference
+ if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
+ _update_email_opt_in(request, course_id.org)
+
+ available_modes = CourseMode.modes_for_course_dict(course_id)
+
+ # Check whether the user is blocked from enrolling in this course
+ # This can occur if the user's IP is on a global blacklist
+ # or if the user is enrolling in a country in which the course
+ # is not available.
+ redirect_url = embargo_api.redirect_if_blocked(
+ course_id, user=user, ip_address=get_ip(request),
+ url=request.path
+ )
+ if redirect_url:
+ return HttpResponse(redirect_url)
+
+ # Check that auto enrollment is allowed for this course
+ # (= the course is NOT behind a paywall)
+ if CourseMode.can_auto_enroll(course_id):
+ # Enroll the user using the default mode (audit)
+ # We're assuming that users of the course enrollment table
+ # will NOT try to look up the course enrollment model
+ # by its slug. If they do, it's possible (based on the state of the database)
+ # for no such model to exist, even though we've set the enrollment type
+ # to "audit".
+ try:
+ enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
+ if enroll_mode:
+ CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
+ except Exception: # pylint: disable=broad-except
+ return HttpResponseBadRequest(_("Could not enroll"))
+
+ # If we have more than one course mode or professional ed is enabled,
+ # then send the user to the choose your track page.
+ # (In the case of no-id-professional/professional ed, this will redirect to a page that
+ # funnels users directly into the verification / payment flow)
+ if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
+ return HttpResponse(
+ reverse("course_modes_choose", kwargs={'course_id': text_type(course_id)})
+ )
+
+ # Otherwise, there is only one mode available (the default)
+ return HttpResponse()
+ elif action == "unenroll":
+ enrollment = CourseEnrollment.get_enrollment(user, course_id)
+ if not enrollment:
+ return HttpResponseBadRequest(_("You are not enrolled in this course"))
+
+ certificate_info = cert_info(user, enrollment.course_overview)
+ if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES:
+ return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course"))
+
+ CourseEnrollment.unenroll(user, course_id)
+ REFUND_ORDER.send(sender=None, course_enrollment=enrollment)
+ return HttpResponse()
+ else:
+ return HttpResponseBadRequest(_("Enrollment action is invalid"))
+
+
+@require_GET
+@login_required
+@ensure_csrf_cookie
+def manage_user_standing(request):
+ """
+ Renders the view used to manage user standing. Also displays a table
+ of user accounts that have been disabled and who disabled them.
+ """
+ if not request.user.is_staff:
+ raise Http404
+ all_disabled_accounts = UserStanding.objects.filter(
+ account_status=UserStanding.ACCOUNT_DISABLED
+ )
+
+ all_disabled_users = [standing.user for standing in all_disabled_accounts]
+
+ headers = ['username', 'account_changed_by']
+ rows = []
+ for user in all_disabled_users:
+ row = [user.username, user.standing.changed_by]
+ rows.append(row)
+
+ context = {'headers': headers, 'rows': rows}
+
+ return render_to_response("manage_user_standing.html", context)
+
+
+@require_POST
+@login_required
+@ensure_csrf_cookie
+def disable_account_ajax(request):
+ """
+ Ajax call to change user standing. Endpoint of the form
+ in manage_user_standing.html
+ """
+ if not request.user.is_staff:
+ raise Http404
+ username = request.POST.get('username')
+ context = {}
+ if username is None or username.strip() == '':
+ context['message'] = _('Please enter a username')
+ return JsonResponse(context, status=400)
+
+ account_action = request.POST.get('account_action')
+ if account_action is None:
+ context['message'] = _('Please choose an option')
+ return JsonResponse(context, status=400)
+
+ username = username.strip()
+ try:
+ user = User.objects.get(username=username)
+ except User.DoesNotExist:
+ context['message'] = _("User with username {} does not exist").format(username)
+ return JsonResponse(context, status=400)
+ else:
+ user_account, _success = UserStanding.objects.get_or_create(
+ user=user, defaults={'changed_by': request.user},
+ )
+ if account_action == 'disable':
+ user_account.account_status = UserStanding.ACCOUNT_DISABLED
+ context['message'] = _("Successfully disabled {}'s account").format(username)
+ log.info(u"%s disabled %s's account", request.user, username)
+ elif account_action == 'reenable':
+ user_account.account_status = UserStanding.ACCOUNT_ENABLED
+ context['message'] = _("Successfully reenabled {}'s account").format(username)
+ log.info(u"%s reenabled %s's account", request.user, username)
+ else:
+ context['message'] = _("Unexpected account status")
+ return JsonResponse(context, status=400)
+ user_account.changed_by = request.user
+ user_account.standing_last_changed_at = datetime.datetime.now(UTC)
+ user_account.save()
+
+ return JsonResponse(context)
+
+
+@login_required
+@ensure_csrf_cookie
+def change_setting(request):
+ """
+ JSON call to change a profile setting: Right now, location
+ """
+ # TODO (vshnayder): location is no longer used
+ u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache
+ if 'location' in request.POST:
+ u_prof.location = request.POST['location']
+ u_prof.save()
+
+ return JsonResponse({
+ "success": True,
+ "location": u_prof.location,
+ })
+
+
+@receiver(post_save, sender=User)
+def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
+ """
+ Handler that saves the user Signup Source when the user is created
+ """
+ if 'created' in kwargs and kwargs['created']:
+ site = configuration_helpers.get_value('SITE_NAME')
+ if site:
+ user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
+ user_signup_source.save()
+ log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))
+
+
+def create_account_with_params(request, params):
+ """
+ Given a request and a dict of parameters (which may or may not have come
+ from the request), create an account for the requesting user, including
+ creating a comments service user object and sending an activation email.
+ This also takes external/third-party auth into account, updates that as
+ necessary, and authenticates the user for the request's session.
+
+ Does not return anything.
+
+ Raises AccountValidationError if an account with the username or email
+ specified by params already exists, or ValidationError if any of the given
+ parameters is invalid for any other reason.
+
+ Issues with this code:
+ * It is not transactional. If there is a failure part-way, an incomplete
+ account will be created and left in the database.
+ * Third-party auth passwords are not verified. There is a comment that
+ they are unused, but it would be helpful to have a sanity check that
+ they are sane.
+ * It is over 300 lines long (!) and includes disprate functionality, from
+ registration e-mails to all sorts of other things. It should be broken
+ up into semantically meaningful functions.
+ * The user-facing text is rather unfriendly (e.g. "Username must be a
+ minimum of two characters long" rather than "Please use a username of
+ at least two characters").
+ * Duplicate email raises a ValidationError (rather than the expected
+ AccountValidationError). Duplicate username returns an inconsistent
+ user message (i.e. "An account with the Public Username '{username}'
+ already exists." rather than "It looks like {username} belongs to an
+ existing account. Try again with a different username.") The two checks
+ occur at different places in the code; as a result, registering with
+ both a duplicate username and email raises only a ValidationError for
+ email only.
+ """
+ # Copy params so we can modify it; we can't just do dict(params) because if
+ # params is request.POST, that results in a dict containing lists of values
+ params = dict(params.items())
+
+ # allow to define custom set of required/optional/hidden fields via configuration
+ extra_fields = configuration_helpers.get_value(
+ 'REGISTRATION_EXTRA_FIELDS',
+ getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
+ )
+ # registration via third party (Google, Facebook) using mobile application
+ # doesn't use social auth pipeline (no redirect uri(s) etc involved).
+ # In this case all related info (required for account linking)
+ # is sent in params.
+ # `third_party_auth_credentials_in_api` essentially means 'request
+ # is made from mobile application'
+ third_party_auth_credentials_in_api = 'provider' in params
+
+ is_third_party_auth_enabled = third_party_auth.is_enabled()
+
+ if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
+ params["password"] = pipeline.make_random_password()
+
+ # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate
+ # error message
+ if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)):
+ raise ValidationError(
+ {'session_expired': [
+ _(u"Registration using {provider} has timed out.").format(
+ provider=params.get('social_auth_provider'))
+ ]}
+ )
+
+ # if doing signup for an external authorization, then get email, password, name from the eamap
+ # don't use the ones from the form, since the user could have hacked those
+ # unless originally we didn't get a valid email or name from the external auth
+ # TODO: We do not check whether these values meet all necessary criteria, such as email length
+ do_external_auth = 'ExternalAuthMap' in request.session
+ if do_external_auth:
+ eamap = request.session['ExternalAuthMap']
+ try:
+ validate_email(eamap.external_email)
+ params["email"] = eamap.external_email
+ except ValidationError:
+ pass
+ if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH:
+ params["name"] = eamap.external_name
+ params["password"] = eamap.internal_password
+ log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"])
+
+ extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', [])
+ enforce_password_policy = (
+ settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
+ not do_external_auth
+ )
+ # Can't have terms of service for certain SHIB users, like at Stanford
+ registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
+ tos_required = (
+ registration_fields.get('terms_of_service') != 'hidden' or
+ registration_fields.get('honor_code') != 'hidden'
+ ) and (
+ not settings.FEATURES.get("AUTH_USE_SHIB") or
+ not settings.FEATURES.get("SHIB_DISABLE_TOS") or
+ not do_external_auth or
+ not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX)
+ )
+
+ form = AccountCreationForm(
+ data=params,
+ extra_fields=extra_fields,
+ extended_profile_fields=extended_profile_fields,
+ enforce_username_neq_password=True,
+ enforce_password_policy=enforce_password_policy,
+ tos_required=tos_required,
+ )
+ custom_form = get_registration_extension_form(data=params)
+
+ # Perform operations within a transaction that are critical to account creation
+ with transaction.atomic():
+ # first, create the account
+ (user, profile, registration) = do_create_account(form, custom_form)
+
+ # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
+ # (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
+
+ # Note: this is orthogonal to the 3rd party authentication pipeline that occurs
+ # when the account is created via the browser and redirect URLs.
+
+ if is_third_party_auth_enabled and third_party_auth_credentials_in_api:
+ backend_name = params['provider']
+ request.social_strategy = social_utils.load_strategy(request)
+ redirect_uri = reverse('social:complete', args=(backend_name, ))
+ request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri)
+ social_access_token = params.get('access_token')
+ if not social_access_token:
+ raise ValidationError({
+ 'access_token': [
+ _("An access_token is required when passing value ({}) for provider.").format(
+ params['provider']
+ )
+ ]
+ })
+ request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API
+ pipeline_user = None
+ error_message = ""
+ try:
+ pipeline_user = request.backend.do_auth(social_access_token, user=user)
+ except AuthAlreadyAssociated:
+ error_message = _("The provided access_token is already associated with another user.")
+ except (HTTPError, AuthException):
+ error_message = _("The provided access_token is not valid.")
+ if not pipeline_user or not isinstance(pipeline_user, User):
+ # Ensure user does not re-enter the pipeline
+ request.social_strategy.clean_partial_pipeline(social_access_token)
+ raise ValidationError({'access_token': [error_message]})
+
+ # Perform operations that are non-critical parts of account creation
+ create_or_set_user_attribute_created_on_site(user, request.site)
+
+ preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())
+
+ if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
+ try:
+ enable_notifications(user)
+ except Exception: # pylint: disable=broad-except
+ log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))
+
+ dog_stats_api.increment("common.student.account_created")
+
+ # If the user is registering via 3rd party auth, track which provider they use
+ third_party_provider = None
+ running_pipeline = None
+ if is_third_party_auth_enabled and pipeline.running(request):
+ running_pipeline = pipeline.get(request)
+ third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
+
+ # Track the user's registration
+ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
+ tracking_context = tracker.get_tracker().resolve_context()
+ identity_args = [
+ user.id, # pylint: disable=no-member
+ {
+ 'email': user.email,
+ 'username': user.username,
+ 'name': profile.name,
+ # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey.
+ 'age': profile.age or -1,
+ 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year,
+ 'education': profile.level_of_education_display,
+ 'address': profile.mailing_address,
+ 'gender': profile.gender_display,
+ 'country': text_type(profile.country),
+ }
+ ]
+
+ if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'):
+ identity_args.append({
+ "MailChimp": {
+ "listId": settings.MAILCHIMP_NEW_USER_LIST_ID
+ }
+ })
+
+ analytics.identify(*identity_args)
+
+ analytics.track(
+ user.id,
+ "edx.bi.user.account.registered",
+ {
+ 'category': 'conversion',
+ 'label': params.get('course_id'),
+ 'provider': third_party_provider.name if third_party_provider else None
+ },
+ context={
+ 'ip': tracking_context.get('ip'),
+ 'Google Analytics': {
+ 'clientId': tracking_context.get('client_id')
+ }
+ }
+ )
+
+ # Announce registration
+ REGISTER_USER.send(sender=None, user=user, registration=registration)
+
+ create_comments_service_user(user)
+
+ # Check if we system is configured to skip activation email for the current user.
+ skip_email = skip_activation_email(
+ user, do_external_auth, running_pipeline, third_party_provider,
+ )
+
+ if skip_email:
+ registration.activate()
+ _enroll_user_in_pending_courses(user) # Enroll student in any pending courses
+ else:
+ compose_and_send_activation_email(user, profile, registration)
+
+ new_user = authenticate_new_user(request, user.username, params['password'])
+ django_login(request, new_user)
+ request.session.set_expiry(0)
+
+ try:
+ record_registration_attributions(request, new_user)
+ # Don't prevent a user from registering due to attribution errors.
+ except Exception: # pylint: disable=broad-except
+ log.exception('Error while attributing cookies to user registration.')
+
+ # TODO: there is no error checking here to see that the user actually logged in successfully,
+ # and is not yet an active user.
+ if new_user is not None:
+ AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
+
+ if do_external_auth:
+ eamap.user = new_user
+ eamap.dtsignup = datetime.datetime.now(UTC)
+ eamap.save()
+ AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username)
+ AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap)
+
+ if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
+ log.info('bypassing activation email')
+ new_user.is_active = True
+ new_user.save()
+ AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email))
+
+ return new_user
+
+
+def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider):
+ """
+ Return `True` if activation email should be skipped.
+
+ Skip email if we are:
+ 1. Doing load testing.
+ 2. Random user generation for other forms of testing.
+ 3. External auth bypassing activation.
+ 4. Have the platform configured to not require e-mail activation.
+ 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
+
+ Note that this feature is only tested as a flag set one way or
+ the other for *new* systems. we need to be careful about
+ changing settings on a running system to make sure no users are
+ left in an inconsistent state (or doing a migration if they are).
+
+ Arguments:
+ user (User): Django User object for the current user.
+ do_external_auth (bool): True if external authentication is in progress.
+ running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication.
+ third_party_provider (ProviderConfig): An instance of third party provider configuration.
+
+ Returns:
+ (bool): `True` if account activation email should be skipped, `False` if account activation email should be
+ sent.
+ """
+ sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email')
+
+ # Email is valid if the SAML assertion email matches the user account email or
+ # no email was provided in the SAML assertion. Some IdP's use a callback
+ # to retrieve additional user account information (including email) after the
+ # initial account creation.
+ valid_email = (
+ sso_pipeline_email == user.email or (
+ sso_pipeline_email is None and
+ third_party_provider and
+ getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY
+ )
+ )
+
+ # log the cases where skip activation email flag is set, but email validity check fails
+ if third_party_provider and third_party_provider.skip_email_verification and not valid_email:
+ log.info(
+ '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] '
+ 'Account activation email sent as user\'s system email differs from SSO email.',
+ user.email,
+ sso_pipeline_email,
+ getattr(third_party_provider, "provider_id", None),
+ getattr(third_party_provider, "identity_provider_type", None)
+ )
+
+ return (
+ settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or
+ settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or
+ (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or
+ (third_party_provider and third_party_provider.skip_email_verification and valid_email)
+ )
+
+
+def _enroll_user_in_pending_courses(student):
+ """
+ Enroll student in any pending courses he/she may have.
+ """
+ ceas = CourseEnrollmentAllowed.objects.filter(email=student.email)
+ for cea in ceas:
+ if cea.auto_enroll:
+ enrollment = CourseEnrollment.enroll(student, cea.course_id)
+ manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student.email)
+ if manual_enrollment_audit is not None:
+ # get the enrolled by user and reason from the ManualEnrollmentAudit table.
+ # then create a new ManualEnrollmentAudit table entry for the same email
+ # different transition state.
+ ManualEnrollmentAudit.create_manual_enrollment_audit(
+ manual_enrollment_audit.enrolled_by, student.email, ALLOWEDTOENROLL_TO_ENROLLED,
+ manual_enrollment_audit.reason, enrollment
+ )
+
+
+def record_affiliate_registration_attribution(request, user):
+ """
+ Attribute this user's registration to the referring affiliate, if
+ applicable.
+ """
+ affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
+ if user and affiliate_id:
+ UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
+
+
+def record_utm_registration_attribution(request, user):
+ """
+ Attribute this user's registration to the latest UTM referrer, if
+ applicable.
+ """
+ utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name
+ utm_cookie = request.COOKIES.get(utm_cookie_name)
+ if user and utm_cookie:
+ utm = json.loads(utm_cookie)
+ for utm_parameter_name in REGISTRATION_UTM_PARAMETERS:
+ utm_parameter = utm.get(utm_parameter_name)
+ if utm_parameter:
+ UserAttribute.set_user_attribute(
+ user,
+ REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name),
+ utm_parameter
+ )
+ created_at_unixtime = utm.get('created_at')
+ if created_at_unixtime:
+ # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds.
+ # PYTHON: time.time() => 1475590280.823698
+ # JS: new Date().getTime() => 1475590280823
+ created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC)
+ UserAttribute.set_user_attribute(
+ user,
+ REGISTRATION_UTM_CREATED_AT,
+ created_at_datetime
+ )
+
+
+def record_registration_attributions(request, user):
+ """
+ Attribute this user's registration based on referrer cookies.
+ """
+ record_affiliate_registration_attribution(request, user)
+ record_utm_registration_attribution(request, user)
+
+
+@csrf_exempt
+def create_account(request, post_override=None):
+ """
+ JSON call to create new edX account.
+ Used by form in signup_modal.html, which is included into header.html
+ """
+ # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
+ if not configuration_helpers.get_value(
+ 'ALLOW_PUBLIC_ACCOUNT_CREATION',
+ settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
+ ):
+ return HttpResponseForbidden(_("Account creation not allowed."))
+
+ warnings.warn("Please use RegistrationView instead.", DeprecationWarning)
+
+ try:
+ user = create_account_with_params(request, post_override or request.POST)
+ except AccountValidationError as exc:
+ return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400)
+ except ValidationError as exc:
+ field, error_list = next(iteritems(exc.message_dict))
+ return JsonResponse(
+ {
+ "success": False,
+ "field": field,
+ "value": error_list[0],
+ },
+ status=400
+ )
+
+ redirect_url = None # The AJAX method calling should know the default destination upon success
+
+ # Resume the third-party-auth pipeline if necessary.
+ if third_party_auth.is_enabled() and pipeline.running(request):
+ running_pipeline = pipeline.get(request)
+ redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
+
+ response = JsonResponse({
+ 'success': True,
+ 'redirect_url': redirect_url,
+ })
+ set_logged_in_cookies(request, response, user)
+ return response
+
+
+@ensure_csrf_cookie
+def activate_account(request, key):
+ """
+ When link in activation e-mail is clicked
+ """
+ # If request is in Studio call the appropriate view
+ if theming_helpers.get_project_root_name().lower() == u'cms':
+ return activate_account_studio(request, key)
+
+ try:
+ registration = Registration.objects.get(activation_key=key)
+ except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
+ messages.error(
+ request,
+ HTML(_(
+ '{html_start}Your account could not be activated{html_end}'
+ 'Something went wrong, please contact support to resolve this issue.'
+ )).format(
+ support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
+ html_start=HTML('
'),
+ html_end=HTML('
'),
+ ),
+ extra_tags='account-activation aa-icon'
+ )
+ else:
+ if not registration.user.is_active:
+ registration.activate()
+ # Success message for logged in users.
+ message = _('{html_start}Success{html_end} You have activated your account.')
+
+ if not request.user.is_authenticated():
+ # Success message for logged out users
+ message = _(
+ '{html_start}Success! You have activated your account.{html_end}'
+ 'You will now receive email updates and alerts from us related to'
+ ' the courses you are enrolled in. Sign In to continue.'
+ )
+
+ # Add message for later use.
+ messages.success(
+ request,
+ HTML(message).format(
+ html_start=HTML('
'),
+ ),
+ extra_tags='account-activation aa-icon',
+ )
+
+ # Enroll student in any pending courses he/she may have if auto_enroll flag is set
+ _enroll_user_in_pending_courses(registration.user)
+
+ return redirect('dashboard')
+
+
+@ensure_csrf_cookie
+def activate_account_studio(request, key):
+ """
+ When link in activation e-mail is clicked and the link belongs to studio.
+ """
+ try:
+ registration = Registration.objects.get(activation_key=key)
+ except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
+ return render_to_response(
+ "registration/activation_invalid.html",
+ {'csrf': csrf(request)['csrf_token']}
+ )
+ else:
+ user_logged_in = request.user.is_authenticated()
+ already_active = True
+ if not registration.user.is_active:
+ registration.activate()
+ already_active = False
+
+ # Enroll student in any pending courses he/she may have if auto_enroll flag is set
+ _enroll_user_in_pending_courses(registration.user)
+
+ return render_to_response(
+ "registration/activation_complete.html",
+ {
+ 'user_logged_in': user_logged_in,
+ 'already_active': already_active
+ }
+ )
+
+
+@csrf_exempt
+@require_POST
+def password_reset(request):
+ """
+ Attempts to send a password reset e-mail.
+ """
+ # Add some rate limiting here by re-using the RateLimitMixin as a helper class
+ limiter = BadRequestRateLimiter()
+ if limiter.is_rate_limit_exceeded(request):
+ AUDIT_LOG.warning("Rate limit exceeded in password_reset")
+ return HttpResponseForbidden()
+
+ form = PasswordResetFormNoActive(request.POST)
+ if form.is_valid():
+ form.save(use_https=request.is_secure(),
+ from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
+ request=request)
+ # When password change is complete, a "edx.user.settings.changed" event will be emitted.
+ # But because changing the password is multi-step, we also emit an event here so that we can
+ # track where the request was initiated.
+ tracker.emit(
+ SETTING_CHANGE_INITIATED,
+ {
+ "setting": "password",
+ "old": None,
+ "new": None,
+ "user_id": request.user.id,
+ }
+ )
+ destroy_oauth_tokens(request.user)
+ else:
+ # bad user? tick the rate limiter counter
+ AUDIT_LOG.info("Bad password_reset user passed in.")
+ limiter.tick_bad_request_counter(request)
+
+ return JsonResponse({
+ 'success': True,
+ 'value': render_to_string('registration/password_reset_done.html', {}),
+ })
+
+
+def uidb36_to_uidb64(uidb36):
+ """
+ Needed to support old password reset URLs that use base36-encoded user IDs
+ https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231
+ Args:
+ uidb36: base36-encoded user ID
+
+ Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID
+ """
+ try:
+ uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
+ except ValueError:
+ uidb64 = '1' # dummy invalid ID (incorrect padding for base64)
+ return uidb64
+
+
+def validate_password(password):
+ """
+ Validate password overall strength if ENFORCE_PASSWORD_POLICY is enable
+ otherwise only validate the length of the password.
+
+ Args:
+ password: the user's proposed new password.
+
+ Returns:
+ err_msg: an error message if there's a violation of one of the password
+ checks. Otherwise, `None`.
+ """
+
+ try:
+ if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False):
+ validate_password_strength(password)
+ else:
+ validate_password_length(password)
+
+ except ValidationError as err:
+ return _('Password: ') + '; '.join(err.messages)
+
+
+def validate_password_security_policy(user, password):
+ """
+ Tie in password policy enforcement as an optional level of
+ security protection
+
+ Args:
+ user: the user object whose password we're checking.
+ password: the user's proposed new password.
+
+ Returns:
+ err_msg: an error message if there's a violation of one of the password
+ checks. Otherwise, `None`.
+ """
+
+ err_msg = None
+ # also, check the password reuse policy
+ if not PasswordHistory.is_allowable_password_reuse(user, password):
+ if user.is_staff:
+ num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
+ else:
+ num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE']
+ # Because of how ngettext is, splitting the following into shorter lines would be ugly.
+ # pylint: disable=line-too-long
+ err_msg = ungettext(
+ "You are re-using a password that you have used recently. "
+ "You must have {num} distinct password before reusing a previous password.",
+ "You are re-using a password that you have used recently. "
+ "You must have {num} distinct passwords before reusing a previous password.",
+ num_distinct
+ ).format(num=num_distinct)
+
+ # also, check to see if passwords are getting reset too frequent
+ if PasswordHistory.is_password_reset_too_soon(user):
+ num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS']
+ # Because of how ngettext is, splitting the following into shorter lines would be ugly.
+ # pylint: disable=line-too-long
+ err_msg = ungettext(
+ "You are resetting passwords too frequently. Due to security policies, "
+ "{num} day must elapse between password resets.",
+ "You are resetting passwords too frequently. Due to security policies, "
+ "{num} days must elapse between password resets.",
+ num_days
+ ).format(num=num_days)
+
+ return err_msg
+
+
+def password_reset_confirm_wrapper(request, uidb36=None, token=None):
+ """
+ A wrapper around django.contrib.auth.views.password_reset_confirm.
+ Needed because we want to set the user as active at this step.
+ We also optionally do some additional password policy checks.
+ """
+ # convert old-style base36-encoded user id to base64
+ uidb64 = uidb36_to_uidb64(uidb36)
+ platform_name = {
+ "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
+ }
+ try:
+ uid_int = base36_to_int(uidb36)
+ user = User.objects.get(id=uid_int)
+ except (ValueError, User.DoesNotExist):
+ # if there's any error getting a user, just let django's
+ # password_reset_confirm function handle it.
+ return password_reset_confirm(
+ request, uidb64=uidb64, token=token, extra_context=platform_name
+ )
+
+ if request.method == 'POST':
+ password = request.POST['new_password1']
+ valid_link = False
+ error_message = validate_password_security_policy(user, password)
+ if not error_message:
+ # if security is not violated, we need to validate password
+ error_message = validate_password(password)
+ if error_message:
+ # password reset link will be valid if there is no security violation
+ valid_link = True
+
+ if error_message:
+ # We have a password reset attempt which violates some security
+ # policy, or any other validation. Use the existing Django template to communicate that
+ # back to the user.
+ context = {
+ 'validlink': valid_link,
+ 'form': None,
+ 'title': _('Password reset unsuccessful'),
+ 'err_msg': error_message,
+ }
+ context.update(platform_name)
+ return TemplateResponse(
+ request, 'registration/password_reset_confirm.html', context
+ )
+
+ # remember what the old password hash is before we call down
+ old_password_hash = user.password
+
+ response = password_reset_confirm(
+ request, uidb64=uidb64, token=token, extra_context=platform_name
+ )
+
+ # If password reset was unsuccessful a template response is returned (status_code 200).
+ # Check if form is invalid then show an error to the user.
+ # Note if password reset was successful we get response redirect (status_code 302).
+ if response.status_code == 200:
+ form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False
+ if not form_valid:
+ log.warning(
+ u'Unable to reset password for user [%s] because form is not valid. '
+ u'A possible cause is that the user had an invalid reset token',
+ user.username,
+ )
+ response.context_data['err_msg'] = _('Error in resetting your password. Please try again.')
+ return response
+
+ # get the updated user
+ updated_user = User.objects.get(id=uid_int)
+
+ # did the password hash change, if so record it in the PasswordHistory
+ if updated_user.password != old_password_hash:
+ entry = PasswordHistory()
+ entry.create(updated_user)
+
+ else:
+ response = password_reset_confirm(
+ request, uidb64=uidb64, token=token, extra_context=platform_name
+ )
+
+ response_was_successful = response.context_data.get('validlink')
+ if response_was_successful and not user.is_active:
+ user.is_active = True
+ user.save()
+
+ return response
+
+
+def validate_new_email(user, new_email):
+ """
+ Given a new email for a user, does some basic verification of the new address If any issues are encountered
+ with verification a ValueError will be thrown.
+ """
+ try:
+ validate_email(new_email)
+ except ValidationError:
+ 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:
+ raise ValueError(_('An account with this e-mail already exists.'))
+
+
+def do_email_change_request(user, new_email, activation_key=None):
+ """
+ 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.
+ """
+ pec_list = PendingEmailChange.objects.filter(user=user)
+ if len(pec_list) == 0:
+ pec = PendingEmailChange()
+ pec.user = user
+ else:
+ pec = pec_list[0]
+
+ # if activation_key is not passing as an argument, generate a random key
+ if not activation_key:
+ activation_key = uuid.uuid4().hex
+
+ pec.new_email = new_email
+ pec.activation_key = activation_key
+ pec.save()
+
+ context = {
+ 'key': pec.activation_key,
+ 'old_email': user.email,
+ 'new_email': pec.new_email
+ }
+
+ subject = render_to_string('emails/email_change_subject.txt', context)
+ subject = ''.join(subject.splitlines())
+
+ message = render_to_string('emails/email_change.txt', context)
+
+ from_address = configuration_helpers.get_value(
+ 'email_from_address',
+ settings.DEFAULT_FROM_EMAIL
+ )
+ try:
+ 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)
+ raise ValueError(_('Unable to send email activation link. Please try again later.'))
+
+ # When the email address change is complete, a "edx.user.settings.changed" event will be emitted.
+ # But because changing the email address is multi-step, we also emit an event here so that we can
+ # track where the request was initiated.
+ tracker.emit(
+ SETTING_CHANGE_INITIATED,
+ {
+ "setting": "email",
+ "old": context['old_email'],
+ "new": context['new_email'],
+ "user_id": user.id,
+ }
+ )
+
+
+@ensure_csrf_cookie
+def confirm_email_change(request, key): # pylint: disable=unused-argument
+ """
+ User requested a new e-mail. This is called when the activation
+ link is clicked. We confirm with the old e-mail, and update
+ """
+ with transaction.atomic():
+ try:
+ pec = PendingEmailChange.objects.get(activation_key=key)
+ except PendingEmailChange.DoesNotExist:
+ response = render_to_response("invalid_email_key.html", {})
+ transaction.set_rollback(True)
+ return response
+
+ user = pec.user
+ address_context = {
+ 'old_email': user.email,
+ 'new_email': pec.new_email
+ }
+
+ if len(User.objects.filter(email=pec.new_email)) != 0:
+ response = render_to_response("email_exists.html", {})
+ transaction.set_rollback(True)
+ return response
+
+ subject = render_to_string('emails/email_change_subject.txt', address_context)
+ subject = ''.join(subject.splitlines())
+ message = render_to_string('emails/confirm_email_change.txt', address_context)
+ u_prof = UserProfile.objects.get(user=user)
+ meta = u_prof.get_meta()
+ if 'old_emails' not in meta:
+ meta['old_emails'] = []
+ meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
+ u_prof.set_meta(meta)
+ u_prof.save()
+ # Send it to the old email...
+ try:
+ user.email_user(
+ subject,
+ message,
+ configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
+ )
+ except Exception: # pylint: disable=broad-except
+ log.warning('Unable to send confirmation email to old address', exc_info=True)
+ response = render_to_response("email_change_failed.html", {'email': user.email})
+ transaction.set_rollback(True)
+ return response
+
+ user.email = pec.new_email
+ user.save()
+ pec.delete()
+ # And send it to the new email...
+ try:
+ user.email_user(
+ subject,
+ message,
+ configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
+ )
+ except Exception: # pylint: disable=broad-except
+ log.warning('Unable to send confirmation email to new address', exc_info=True)
+ response = render_to_response("email_change_failed.html", {'email': pec.new_email})
+ transaction.set_rollback(True)
+ return response
+
+ response = render_to_response("email_change_successful.html", address_context)
+ return response
+
+
+@require_POST
+@login_required
+@ensure_csrf_cookie
+def change_email_settings(request):
+ """
+ Modify logged-in user's setting for receiving emails from a course.
+ """
+ user = request.user
+
+ course_id = request.POST.get("course_id")
+ course_key = CourseKey.from_string(course_id)
+ receive_emails = request.POST.get("receive_emails")
+ if receive_emails:
+ optout_object = Optout.objects.filter(user=user, course_id=course_key)
+ if optout_object:
+ optout_object.delete()
+ log.info(
+ u"User %s (%s) opted in to receive emails from course %s",
+ user.username,
+ user.email,
+ course_id,
+ )
+ track.views.server_track(
+ request,
+ "change-email-settings",
+ {"receive_emails": "yes", "course": course_id},
+ page='dashboard',
+ )
+ else:
+ Optout.objects.get_or_create(user=user, course_id=course_key)
+ log.info(
+ u"User %s (%s) opted out of receiving emails from course %s",
+ user.username,
+ user.email,
+ course_id,
+ )
+ track.views.server_track(
+ request,
+ "change-email-settings",
+ {"receive_emails": "no", "course": course_id},
+ page='dashboard',
+ )
+
+ return JsonResponse({"success": True})
+
+
+@ensure_csrf_cookie
+def text_me_the_app(request):
+ """
+ Text me the app view.
+ """
+ text_me_fragment = TextMeTheAppFragmentView().render_to_fragment(request)
+ context = {
+ 'nav_hidden': True,
+ 'show_dashboard_tabs': True,
+ 'show_program_listing': ProgramsApiConfig.is_enabled(),
+ 'fragment': text_me_fragment
+ }
+
+ return render_to_response('text-me-the-app.html', context)
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 094c9a6653..e09de2021a 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -1,4 +1,6 @@
-"""Base integration test for provider implementations."""
+"""
+Base integration test for provider implementations.
+"""
import unittest
diff --git a/lms/djangoapps/branding/tests/test_page.py b/lms/djangoapps/branding/tests/test_page.py
index 888d26e35e..ce8b14151a 100644
--- a/lms/djangoapps/branding/tests/test_page.py
+++ b/lms/djangoapps/branding/tests/test_page.py
@@ -192,7 +192,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
)
self.factory = RequestFactory()
- @patch('student.views.render_to_response', RENDER_MOCK)
+ @patch('student.views.management.render_to_response', RENDER_MOCK)
@patch('courseware.views.views.render_to_response', RENDER_MOCK)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
def test_course_discovery_off(self):
@@ -216,7 +216,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
# make sure we have the special css class on the section
self.assertIn('
', response.content)
self.assertIn('
", field_errors["name"]["user_message"])
@patch('django.core.mail.send_mail')
- @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
+ @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_update_sending_email_fails(self, send_mail):
"""Test what happens if all validation checks pass, but sending the email for email change fails."""
send_mail.side_effect = [Exception, None]
diff --git a/pavelib/quality.py b/pavelib/quality.py
index ef0a4ed1b8..ccf323962e 100644
--- a/pavelib/quality.py
+++ b/pavelib/quality.py
@@ -604,7 +604,8 @@ def _get_count_from_last_line(filename, file_type):
It is returning only the value (as a floating number).
"""
last_line = _get_report_contents(filename, last_line_only=True).strip()
- if file_type is "python_complexity":
+
+ if file_type == "python_complexity":
# Example of the last line of a complexity report: "Average complexity: A (1.93953443446)"
regex = r'\d+.\d+'
else: