Files
edx-platform/common/djangoapps/student/views/management.py

1256 lines
49 KiB
Python

"""
Student Views
"""
from __future__ import absolute_import
import datetime
import logging
import uuid
from collections import namedtuple
import six
from django.conf import settings
from django.contrib import messages
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.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError, validate_email
from django.db import 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.urls import reverse
from django.utils.encoding import force_bytes, force_text
from django.utils.http import base36_to_int, 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_http_methods, require_POST
from edx_ace import ace
from edx_ace.recipient import Recipient
from edx_django_utils import monitoring as monitoring_utils
from eventtracking import tracker
from ipware.ip import get_ip
# Note that this lives in LMS, so this dependency should be refactored.
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from six import text_type
import track.views
from bulk_email.models import Optout
from course_modes.models import CourseMode
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date
from edxmako.shortcuts import marketing_link, render_to_response, render_to_string
from entitlements.models import CourseEntitlement
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
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.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.oauth_dispatch.api import destroy_oauth_tokens
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.theming.helpers import get_current_site
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
from openedx.core.djangoapps.user_api.errors import UserAPIInternalError, UserNotFound
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.journals.api import get_journals_context
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info, generate_activation_email_context
from student.message_types import EmailChange, EmailChangeConfirmation, PasswordReset, RecoveryEmailCreate
from student.models import (
AccountRecovery,
CourseEnrollment,
PendingEmailChange,
PendingSecondaryEmailChange,
Registration,
RegistrationCookieConfiguration,
UserAttribute,
UserProfile,
UserSignupSource,
UserStanding,
create_comments_service_user,
email_exists_or_retired
)
from student.signals import REFUND_ORDER
from student.tasks import send_activation_email
from student.text_me_the_app import TextMeTheAppFragmentView
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 normalize_password, validate_password
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'
)
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'
def csrf_token(context):
"""
A csrf token that can be included in a form.
"""
token = context.get('csrf_token', '')
if token == 'NOTPROVIDED':
return ''
return (HTML(u'<div style="display:none"><input type="hidden"'
' name="csrfmiddlewaretoken" value="{}" /></div>').format(Text(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.
"""
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)
# TODO: Course Listing Plugin required
context['journal_info'] = get_journals_context(request)
return render_to_response('index.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)
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})
@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)
if CourseEntitlement.check_for_existing_entitlement_and_enroll(user=user, course_run_key=course_id):
return HttpResponse(reverse('courseware', args=[six.text_type(course_id)]))
# 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))
@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 registration.user.is_active:
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',
)
elif waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
messages.error(
request,
HTML(u'{html_start}{message}{html_end}').format(
message=Text(SYSTEM_MAINTENANCE_MSG),
html_start=HTML('<p class="message-title">'),
html_end=HTML('</p>'),
),
extra_tags='account-activation aa-icon',
)
else:
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',
)
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:
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
return render_to_response('registration/activation_invalid.html',
{'csrf': csrf(request)['csrf_token']})
registration.activate()
already_active = False
return render_to_response(
"registration/activation_complete.html",
{
'user_logged_in': user_logged_in,
'already_active': already_active
}
)
@require_http_methods(['POST'])
def password_change_request_handler(request):
"""Handle password change requests originating from the account page.
Uses the Account API to email the user a link to the password reset page.
Note:
The next step in the password reset process (confirmation) is currently handled
by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's
password reset confirmation view.
Args:
request (HttpRequest)
Returns:
HttpResponse: 200 if the email was sent successfully
HttpResponse: 400 if there is no 'email' POST parameter
HttpResponse: 403 if the client has been rate limited
HttpResponse: 405 if using an unsupported HTTP method
Example usage:
POST /account/password
"""
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Password reset rate limit exceeded")
return HttpResponseForbidden()
user = request.user
# Prefer logged-in user's email
email = user.email if user.is_authenticated else request.POST.get('email')
if email:
try:
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
request_password_change(email, request.is_secure())
user = user if user.is_authenticated else get_user_from_email(email=email)
destroy_oauth_tokens(user)
except UserNotFound:
AUDIT_LOG.info("Invalid password reset attempt")
# Increment the rate limit counter
limiter.tick_bad_request_counter(request)
# If enabled, send an email saying that a password reset was attempted, but that there is
# no user associated with the email
if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL',
settings.FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']):
site = get_current_site()
message_context = get_base_template_context(site)
message_context.update({
'failed': True,
'request': request, # Used by google_analytics_tracking_pixel
'email_address': email,
})
msg = PasswordReset().personalize(
recipient=Recipient(username='', email_address=email),
language=settings.LANGUAGE_CODE,
user_context=message_context,
)
ace.send(msg)
except UserAPIInternalError as err:
log.exception('Error occured during password change for user {email}: {error}'
.format(email=email, error=err))
return HttpResponse(_("Some error occured during password change. Please try again"), status=500)
return HttpResponse(status=200)
else:
return HttpResponseBadRequest(_("No email address provided."))
def get_user_from_email(email):
"""
Find a user using given email and return it.
Arguments:
email (str): primary or secondary email address of the user.
Raises:
(User.ObjectNotFound): If no user is found with the given email.
(User.MultipleObjectsReturned): If more than one user is found with the given email.
Returns:
User: Django user object.
"""
try:
return User.objects.get(email=email)
except ObjectDoesNotExist:
return User.objects.filter(
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
).get()
@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 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)
}
# User can not get this link unless account recovery feature is enabled.
if 'is_account_recovery' in request.GET and not is_secondary_email_feature_enabled():
raise Http404
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 UserRetirementRequest.has_user_requested_retirement(user):
# Refuse to reset the password of any user that has requested retirement.
context = {
'validlink': True,
'form': None,
'title': _('Password reset unsuccessful'),
'err_msg': _('Error in resetting your password.'),
}
context.update(platform_name)
return TemplateResponse(
request, 'registration/password_reset_confirm.html', context
)
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
context = {
'validlink': False,
'form': None,
'title': _('Password reset unsuccessful'),
'err_msg': SYSTEM_MAINTENANCE_MSG,
}
context.update(platform_name)
return TemplateResponse(
request, 'registration/password_reset_confirm.html', context
)
if request.method == 'POST':
# We have to make a copy of request.POST because it is a QueryDict object which is immutable until copied.
# We have to use request.POST because the password_reset_confirm method takes in the request and a user's
# password is set to the request.POST['new_password1'] field. We have to also normalize the new_password2
# field so it passes the equivalence check that new_password1 == new_password2
# In order to switch out of having to do this copy, we would want to move the normalize_password code into
# a custom User model's set_password method to ensure it is always happening upon calling set_password.
request.POST = request.POST.copy()
request.POST['new_password1'] = normalize_password(request.POST['new_password1'])
request.POST['new_password2'] = normalize_password(request.POST['new_password2'])
password = request.POST['new_password1']
try:
validate_password(password, user=user)
except ValidationError as err:
# 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': True,
'form': None,
'title': _('Password reset unsuccessful'),
'err_msg': ' '.join(err.messages),
}
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
if 'is_account_recovery' in request.GET:
response = password_reset_confirm(
request,
uidb64=uidb64,
token=token,
extra_context=platform_name,
template_name='registration/password_reset_confirm.html',
post_reset_redirect='signin_user',
)
else:
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)
if 'is_account_recovery' in request.GET:
try:
updated_user.email = updated_user.account_recovery.secondary_email
updated_user.account_recovery.delete()
# emit an event that the user changed their secondary email to the primary email
tracker.emit(
SETTING_CHANGE_INITIATED,
{
"setting": "email",
"old": user.email,
"new": updated_user.email,
"user_id": updated_user.id,
}
)
except ObjectDoesNotExist:
log.error(
'Account recovery process initiated without AccountRecovery instance for user {username}'.format(
username=updated_user.username
)
)
updated_user.save()
if response.status_code == 302 and 'is_account_recovery' in request.GET:
messages.success(
request,
HTML(_(
'{html_start}Password Creation Complete{html_end}'
'Your password has been created. {bold_start}{email}{bold_end} is now your primary login email.'
)).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>'),
bold_start=HTML('<b>'),
bold_end=HTML('</b>'),
email=updated_user.email,
),
extra_tags='account-recovery aa-icon submission-success'
)
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.'))
def validate_secondary_email(account_recovery, new_email):
"""
Enforce valid email addresses.
"""
from openedx.core.djangoapps.user_api.accounts.api import get_email_validation_error, \
get_secondary_email_validation_error
if get_email_validation_error(new_email):
raise ValueError(_('Valid e-mail address required.'))
if new_email == account_recovery.secondary_email:
raise ValueError(_('Old email is the same as the new email.'))
# Make sure that secondary email address is not same as user's primary email.
if new_email == account_recovery.user.email:
raise ValueError(_('Cannot be same as your sign in email address.'))
# Make sure that secondary email address is not same as any of the primary emails currently in use or retired
if email_exists_or_retired(new_email):
raise ValueError(
_("It looks like {email} belongs to an existing account. Try again with a different email address.").format(
email=new_email
)
)
message = get_secondary_email_validation_error(new_email)
if message:
raise ValueError(message)
def do_email_change_request(user, new_email, activation_key=None, secondary_email_change_request=False):
"""
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.
"""
# if activation_key is not passing as an argument, generate a random key
if not activation_key:
activation_key = uuid.uuid4().hex
confirm_link = reverse('confirm_email_change', kwargs={'key': activation_key, })
if secondary_email_change_request:
PendingSecondaryEmailChange.objects.update_or_create(
user=user,
defaults={
'new_secondary_email': new_email,
'activation_key': activation_key,
}
)
confirm_link = reverse('activate_secondary_email', kwargs={'key': activation_key})
else:
PendingEmailChange.objects.update_or_create(
user=user,
defaults={
'new_email': new_email,
'activation_key': activation_key,
}
)
use_https = theming_helpers.get_current_request().is_secure()
site = Site.objects.get_current()
message_context = get_base_template_context(site)
message_context.update({
'old_email': user.email,
'new_email': new_email,
'confirm_link': '{protocol}://{site}{link}'.format(
protocol='https' if use_https else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=confirm_link,
),
})
if secondary_email_change_request:
msg = RecoveryEmailCreate().personalize(
recipient=Recipient(user.username, new_email),
language=preferences_api.get_user_preference(user, LANGUAGE_KEY),
user_context=message_context,
)
else:
msg = EmailChange().personalize(
recipient=Recipient(user.username, new_email),
language=preferences_api.get_user_preference(user, LANGUAGE_KEY),
user_context=message_context,
)
try:
ace.send(msg)
except Exception:
from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
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.'))
if not secondary_email_change_request:
# 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": message_context['old_email'],
"new": message_context['new_email'],
"user_id": user.id,
}
)
@ensure_csrf_cookie
def activate_secondary_email(request, key): # pylint: disable=unused-argument
"""
This is called when the activation link is clicked. We activate the secondary email
for the requested user.
"""
try:
pending_secondary_email_change = PendingSecondaryEmailChange.objects.get(activation_key=key)
except PendingSecondaryEmailChange.DoesNotExist:
return render_to_response("invalid_email_key.html", {})
try:
account_recovery_obj = AccountRecovery.objects.get(user_id=pending_secondary_email_change.user)
except AccountRecovery.DoesNotExist:
return render_to_response("secondary_email_change_failed.html", {
'secondary_email': pending_secondary_email_change.new_secondary_email
})
account_recovery_obj.is_active = True
account_recovery_obj.save()
return render_to_response("secondary_email_change_successful.html")
@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
"""
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
return render_to_response('email_change_failed.html', {'err_msg': SYSTEM_MAINTENANCE_MSG})
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
use_https = request.is_secure()
if settings.FEATURES['ENABLE_MKTG_SITE']:
contact_link = marketing_link('CONTACT')
else:
contact_link = '{protocol}://{site}{link}'.format(
protocol='https' if use_https else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=reverse('contact'),
)
site = Site.objects.get_current()
message_context = get_base_template_context(site)
message_context.update({
'old_email': user.email,
'new_email': pec.new_email,
'contact_link': contact_link,
'from_address': configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
})
msg = EmailChangeConfirmation().personalize(
recipient=Recipient(user.username, user.email),
language=preferences_api.get_user_preference(user, LANGUAGE_KEY),
user_context=message_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:
ace.send(msg)
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...
msg.recipient = Recipient(user.username, pec.new_email)
try:
ace.send(msg)
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)