relied on the old configuration values and old way of validating passwords. Also improved registration page by always showing error messages rather than hiding them on leaving the field.
1100 lines
42 KiB
Python
1100 lines
42 KiB
Python
"""
|
|
Student Views
|
|
"""
|
|
|
|
import datetime
|
|
import logging
|
|
import uuid
|
|
from collections import namedtuple
|
|
|
|
from bulk_email.models import Optout
|
|
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date
|
|
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.urls import reverse
|
|
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.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_POST, require_http_methods
|
|
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
|
|
from xmodule.modulestore.django import modulestore
|
|
import track.views
|
|
from course_modes.models import CourseMode
|
|
from edx_ace import ace
|
|
from edx_ace.recipient import Recipient
|
|
from edxmako.shortcuts import 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.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
|
|
from openedx.core.djangoapps.user_api.errors import UserNotFound, UserAPIInternalError
|
|
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,
|
|
auth_pipeline_urls,
|
|
cert_info,
|
|
create_or_set_user_attribute_created_on_site,
|
|
do_create_account,
|
|
generate_activation_email_context,
|
|
get_next_url_for_login_page
|
|
)
|
|
from student.message_types import EmailChange, PasswordReset
|
|
from student.models import (
|
|
CourseEnrollment,
|
|
PasswordHistory,
|
|
PendingEmailChange,
|
|
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 validate_password
|
|
|
|
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'
|
|
# 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)
|
|
|
|
# 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=[unicode(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 User.objects.get(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."))
|
|
|
|
|
|
@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)
|
|
}
|
|
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':
|
|
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
|
|
|
|
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.'))
|
|
|
|
|
|
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()
|
|
|
|
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': pec.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=reverse('confirm_email_change', kwargs={
|
|
'key': pec.activation_key,
|
|
}),
|
|
),
|
|
})
|
|
|
|
msg = EmailChange().personalize(
|
|
recipient=Recipient(user.username, pec.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.'))
|
|
|
|
# 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 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
|
|
|
|
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)
|