Refactor student login for readability and fix Django 1.11 issues
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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('<div class="prerequisites">', 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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<error>[^/]*)$', student.views.login_user),
|
||||
url(r'^login_ajax$', views.login_user, name="login"),
|
||||
url(r'^login_ajax/(?P<error>[^/]*)$', views.login_user),
|
||||
|
||||
url(r'^email_confirm/(?P<key>[^/]*)$', student.views.confirm_email_change, name='confirm_email_change'),
|
||||
url(r'^email_confirm/(?P<key>[^/]*)$', views.confirm_email_change, name='confirm_email_change'),
|
||||
|
||||
url(r'^create_account$', student.views.create_account, name='create_account'),
|
||||
url(r'^activate/(?P<key>[^/]*)$', student.views.activate_account, name="activate"),
|
||||
url(r'^create_account$', views.create_account, name='create_account'),
|
||||
url(r'^activate/(?P<key>[^/]*)$', 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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
|
||||
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(
|
||||
|
||||
@@ -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'<div style="display:none"><input type="hidden"'
|
||||
' name="csrfmiddlewaretoken" value="%s" /></div>' % (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.<br /><br />'
|
||||
'We just sent an activation link to <strong>{email}</strong>. If '
|
||||
'you do not receive an email, check your spam folders or '
|
||||
'<a href="{support_url}">contact {platform} Support</a>.')
|
||||
|
||||
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 += "<br/><br/>"
|
||||
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 += "<br/><br/>"
|
||||
message += _(
|
||||
"If you don't have an {platform_name} account yet, "
|
||||
"click <strong>Register</strong> 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 "<unknown>"
|
||||
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 <a href="{support_url}">contact support</a> to resolve this issue.'
|
||||
)).format(
|
||||
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
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('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
extra_tags='account-activation aa-icon',
|
||||
)
|
||||
else:
|
||||
messages.info(
|
||||
request,
|
||||
HTML(_('{html_start}This account has already been activated.{html_end}')).format(
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
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)
|
||||
8
common/djangoapps/student/views/__init__.py
Normal file
8
common/djangoapps/student/views/__init__.py
Normal file
@@ -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 *
|
||||
764
common/djangoapps/student/views/dashboard.py
Normal file
764
common/djangoapps/student/views/dashboard.py
Normal file
@@ -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
|
||||
759
common/djangoapps/student/views/login.py
Normal file
759
common/djangoapps/student/views/login.py
Normal file
@@ -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 += "<br/><br/>"
|
||||
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 += "<br/><br/>"
|
||||
message += _(
|
||||
"If you don't have an {platform_name} account yet, "
|
||||
"click <strong>Register</strong> 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.<br /><br />'
|
||||
'We just sent an activation link to <strong>{email}</strong>. If '
|
||||
'you do not receive an email, check your spam folders or '
|
||||
'<a href="{support_url}">contact {platform} Support</a>.')
|
||||
|
||||
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 "<unknown>"
|
||||
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
|
||||
1498
common/djangoapps/student/views/management.py
Normal file
1498
common/djangoapps/student/views/management.py
Normal file
@@ -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'<div style="display:none"><input type="hidden"'
|
||||
' name="csrfmiddlewaretoken" value="{}" /></div>'.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 <a href="{support_url}">contact support</a> to resolve this issue.'
|
||||
)).format(
|
||||
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
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('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
extra_tags='account-activation aa-icon',
|
||||
)
|
||||
else:
|
||||
messages.info(
|
||||
request,
|
||||
HTML(_('{html_start}This account has already been activated.{html_end}')).format(
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
),
|
||||
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)
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Base integration test for provider implementations."""
|
||||
"""
|
||||
Base integration test for provider implementations.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -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('<div class="courses no-course-discovery"', response.content)
|
||||
|
||||
@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': True})
|
||||
def test_course_discovery_on(self):
|
||||
@@ -238,7 +238,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
|
||||
self.assertIn('<aside aria-label="Refine Your Search" class="search-facets phone-menu">', response.content)
|
||||
self.assertIn('<div class="courses"', response.content)
|
||||
|
||||
@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_cards_sorted_by_default_sorting(self):
|
||||
@@ -263,7 +263,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
|
||||
self.assertEqual(context['courses'][1].id, self.starting_later.id)
|
||||
self.assertEqual(context['courses'][2].id, self.course_with_default_start_date.id)
|
||||
|
||||
@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_SORTING_BY_START_DATE': False})
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False})
|
||||
|
||||
@@ -40,7 +40,6 @@ from openedx.core.djangoapps.user_api.errors import (
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
|
||||
from openedx.features.enterprise_support.api import enterprise_customer_for_request, get_enterprise_customer_for_learner
|
||||
from student.cookies import set_experiments_is_enterprise_cookie
|
||||
from openedx.features.enterprise_support.utils import (
|
||||
handle_enterprise_cookies_for_logistration,
|
||||
update_logistration_context_for_enterprise,
|
||||
@@ -48,8 +47,7 @@ from openedx.features.enterprise_support.utils import (
|
||||
)
|
||||
from student.helpers import destroy_oauth_tokens, get_next_url_for_login_page
|
||||
from student.models import UserProfile
|
||||
from student.views import register_user as old_register_view
|
||||
from student.views import signin_user as old_login_view
|
||||
from student.views import register_user as old_register_view, signin_user as old_login_view
|
||||
from third_party_auth import pipeline
|
||||
from third_party_auth.decorators import xframe_allow_whitelisted
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
|
||||
@@ -665,7 +665,7 @@ derived_collection_entry('DEFAULT_TEMPLATE_ENGINE', 'DIRS')
|
||||
###############################################################################################
|
||||
|
||||
# use the ratelimit backend to prevent brute force attacks
|
||||
AUTHENTICATION_BACKENDS = ['ratelimitbackend.backends.RateLimitModelBackend', ]
|
||||
AUTHENTICATION_BACKENDS = ['ratelimitbackend.backends.RateLimitModelBackend']
|
||||
STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB
|
||||
MAX_FILEUPLOADS_PER_INPUT = 20
|
||||
|
||||
|
||||
@@ -33,16 +33,9 @@ from util.testing import patch_testcase, patch_sessions
|
||||
patch_testcase()
|
||||
patch_sessions()
|
||||
|
||||
# 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',
|
||||
'*'
|
||||
]
|
||||
|
||||
# Silence noisy logs to make troubleshooting easier when tests fail.
|
||||
|
||||
@@ -61,7 +61,7 @@ urlpatterns = [
|
||||
|
||||
url(r'', include('student.urls')),
|
||||
# TODO: Move lms specific student views out of common code
|
||||
url(r'^dashboard/?$', student_views.dashboard, name='dashboard'),
|
||||
url(r'^dashboard/?$', student_views.student_dashboard, name='dashboard'),
|
||||
url(r'^change_enrollment$', student_views.change_enrollment, name='change_enrollment'),
|
||||
|
||||
# Event tracking endpoints
|
||||
|
||||
@@ -322,7 +322,7 @@ class ShibSPTest(CacheIsolationTestCase):
|
||||
'terms_of_service': u'true',
|
||||
'honor_code': u'true'}
|
||||
|
||||
with patch('student.views.AUDIT_LOG') as mock_audit_log:
|
||||
with patch('student.views.management.AUDIT_LOG') as mock_audit_log:
|
||||
self.client.post('/create_account', data=postvars)
|
||||
|
||||
mail = identity.get('mail')
|
||||
|
||||
@@ -200,7 +200,7 @@ class TestAccountApi(UserSettingsEventTestMixin, TestCase):
|
||||
self.assertIn("Full Name cannot contain the following characters: < >", 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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user