views.py 88.3 KB
Newer Older
1 2 3
"""
Student Views
"""
4
import datetime
5
import logging
6
import uuid
7
import time
8
import json
9
import warnings
10
from collections import defaultdict
11
from pytz import UTC
12
from ipware.ip import get_ip
13

14
from django.conf import settings
Piotr Mitros committed
15
from django.contrib.auth import logout, authenticate, login
16
from django.contrib.auth.models import User, AnonymousUser
Calen Pennington committed
17
from django.contrib.auth.decorators import login_required
18
from django.contrib.auth.views import password_reset_confirm
19
from django.contrib import messages
Piotr Mitros committed
20
from django.core.context_processors import csrf
21
from django.core import mail
22
from django.core.urlresolvers import reverse
Andy Armstrong committed
23
from django.core.validators import validate_email, ValidationError
24
from django.db import IntegrityError, transaction
25
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
26
                         HttpResponseServerError, Http404)
27
from django.shortcuts import redirect
28
from django.utils.translation import ungettext
29
from django.utils.http import cookie_date, base36_to_int
30
from django.utils.translation import ugettext as _, get_language
31
from django.views.decorators.cache import never_cache
32
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
33
from django.views.decorators.http import require_POST, require_GET
34

35 36 37
from django.db.models.signals import post_save
from django.dispatch import receiver

38 39
from django.template.response import TemplateResponse

Diana Huang committed
40 41
from ratelimitbackend.exceptions import RateLimitException

42 43 44 45
from requests import HTTPError

from social.apps.django_app import utils as social_utils
from social.backends import oauth as social_oauth
46
from social.exceptions import AuthException, AuthAlreadyAssociated
47

David Baumgold committed
48
from edxmako.shortcuts import render_to_response, render_to_string
Piotr Mitros committed
49

50
from course_modes.models import CourseMode
51
from shoppingcart.api import order_history
52
from student.models import (
53
    Registration, UserProfile, PendingNameChange,
54
    PendingEmailChange, CourseEnrollment, unique_id_for_user,
55
    CourseEnrollmentAllowed, UserStanding, LoginFailures,
56
    create_comments_service_user, PasswordHistory, UserSignupSource,
57
    DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
58
from student.forms import AccountCreationForm, PasswordResetFormNoActive
59

60
from verify_student.models import SoftwareSecurePhotoVerification  # pylint: disable=import-error
Victor Shnayder committed
61
from certificates.models import CertificateStatuses, certificate_status_for_student
62
from certificates.api import get_certificate_url, get_active_web_certificate  # pylint: disable=import-error
63
from dark_lang.models import DarkLangConfig
64

65
from xmodule.modulestore.django import modulestore
66
from opaque_keys import InvalidKeyError
67
from opaque_keys.edx.locations import SlashSeparatedCourseKey
John Eskew committed
68
from opaque_keys.edx.locator import CourseLocator
69
from xmodule.modulestore import ModuleStoreEnum
Piotr Mitros committed
70

71
from collections import namedtuple
72

73
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date  # pylint: disable=import-error
74
from courseware.access import has_access
75

76 77
from django_comment_common.models import Role

78
from external_auth.models import ExternalAuthMap
79
import external_auth.views
80 81 82 83
from external_auth.login_and_register import (
    login as external_auth_login,
    register as external_auth_register
)
84

85
from bulk_email.models import Optout, CourseAuthorization
86
import shoppingcart
87
from lang_pref import LANGUAGE_KEY
88 89 90

import track.views

91
import dogstats_wrapper as dog_stats_api
92

93
from util.db import commit_on_success_with_read_committed
94
from util.json_request import JsonResponse
95
from util.bad_request_rate_limiter import BadRequestRateLimiter
96 97 98
from util.milestones_helpers import (
    get_pre_requisite_courses_not_completed,
)
99
from microsite_configuration import microsite
100

101 102 103 104 105
from util.password_policy_validators import (
    validate_password_length, validate_password_complexity,
    validate_password_dictionary
)

106
import third_party_auth
107
from third_party_auth import pipeline, provider
108 109 110 111
from student.helpers import (
    auth_pipeline_urls, set_logged_in_cookie,
    check_verify_status_by_course
)
112
from student.models import anonymous_id_for_user
113
from xmodule.error_module import ErrorDescriptor
114
from shoppingcart.models import DonationConfiguration, CourseRegistrationCode
115

116 117
from embargo import api as embargo_api

118 119 120
import analytics
from eventtracking import tracker

121 122 123 124 125 126
# Note that this lives in LMS, so this dependency should be refactored.
from notification_prefs.views import enable_notifications

# Note that this lives in openedx, so this dependency should be refactored.
from openedx.core.djangoapps.user_api.preferences import api as preferences_api

127

128
log = logging.getLogger("edx.student")
129 130
AUDIT_LOG = logging.getLogger("audit")

131
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display')  # pylint: disable=invalid-name
132

133 134
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'

Sarina Canelake committed
135

Piotr Mitros committed
136
def csrf_token(context):
137
    """A csrf token that can be included in a form."""
Sarina Canelake committed
138 139
    token = context.get('csrf_token', '')
    if token == 'NOTPROVIDED':
Piotr Mitros committed
140
        return ''
141
    return (u'<div style="display:none"><input type="hidden"'
Sarina Canelake committed
142
            ' name="csrfmiddlewaretoken" value="%s" /></div>' % (token))
Piotr Mitros committed
143

144

145 146 147 148
# 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)
Sarina Canelake committed
149
def index(request, extra_context=None, user=AnonymousUser()):
150
    """
ichuang committed
151 152 153 154
    Render the edX main page.

    extra_context is used to allow immediate display of certain modal windows, eg signup,
    as used by external_auth.
155
    """
Sarina Canelake committed
156 157
    if extra_context is None:
        extra_context = {}
158
    # The course selection work is done in courseware.courses.
159
    domain = settings.FEATURES.get('FORCE_UNIVERSITY_DOMAIN')  # normally False
Victor Shnayder committed
160
    # do explicit check, because domain=None is valid
161
    if domain is False:
162
        domain = request.META.get('HTTP_HOST')
163

164
    courses = get_courses(user, domain=domain)
165 166 167 168 169
    if microsite.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)
170

171
    context = {'courses': courses}
Chris Dodge committed
172

ichuang committed
173 174
    context.update(extra_context)
    return render_to_response('index.html', context)
175

176

177 178 179 180 181
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.
    """
182
    return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
183 184


185
def cert_info(user, course, course_mode):
186 187 188 189
    """
    Get the certificate info needed to render the dashboard section for the given
    student and course.  Returns a dictionary with keys:

190
    'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted'
191 192
    'show_download_url': bool
    'download_url': url, only present if show_download_url is True
193
    'show_disabled_download_button': bool -- true if state is 'generating'
194 195 196 197
    'show_survey_button': bool
    'survey_url': url, only if show_survey_button is True
    'grade': if status is not 'processing'
    """
198
    if not course.may_certify():
199 200
        return {}

201
    return _cert_info(user, course, certificate_status_for_student(user, course.id), course_mode)
202

Calen Pennington committed
203

204
def reverification_info(course_enrollment_pairs, user, statuses):
Julia Hansbrough committed
205
    """
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
    Returns reverification-related information for *all* of user's enrollments whose
    reverification status is in status_list

    Args:
        course_enrollment_pairs (list): list of (course, enrollment) tuples
        user (User): the user whose information we want
        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]:
225
            reverifications[status].sort(key=lambda x: x.date)
226 227 228
    return reverifications


Julia Hansbrough committed
229
def get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set):
230 231 232 233
    """
    Get the relevant set of (Course, CourseEnrollment) pairs to be displayed on
    a student's dashboard.
    """
Julia Hansbrough committed
234
    for enrollment in CourseEnrollment.enrollments_for_user(user):
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
        store = modulestore()
        with store.bulk_operations(enrollment.course_id):
            course = store.get_course(enrollment.course_id)
            if course and not isinstance(course, ErrorDescriptor):

                # if we are in a Microsite, then filter out anything that is not
                # attributed (by ORG) to that Microsite
                if course_org_filter and course_org_filter != course.location.org:
                    continue
                # Conversely, if we are not in a Microsite, then let's filter out any enrollments
                # with courses attributed (by ORG) to Microsites
                elif course.location.org in org_filter_out_set:
                    continue

                yield (course, enrollment)
            else:
251 252 253 254 255 256
                log.error(
                    u"User %s enrolled in %s course %s",
                    user.username,
                    "broken" if course else "non-existent",
                    enrollment.course_id
                )
Julia Hansbrough committed
257 258


259
def _cert_info(user, course, cert_status, course_mode):
260 261 262
    """
    Implements the logic for cert_info -- split out for testing.
    """
263 264 265 266 267 268 269 270 271
    # simplify the status for the template using this lookup table
    template_state = {
        CertificateStatuses.generating: 'generating',
        CertificateStatuses.regenerating: 'generating',
        CertificateStatuses.downloadable: 'ready',
        CertificateStatuses.notpassing: 'notpassing',
        CertificateStatuses.restricted: 'restricted',
    }

272
    default_status = 'processing'
273 274 275 276

    default_info = {'status': default_status,
                    'show_disabled_download_button': False,
                    'show_download_url': False,
277 278
                    'show_survey_button': False,
                    }
279

280
    if cert_status is None:
281
        return default_info
282

283 284 285
    is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing')

    if course.certificates_display_behavior == 'early_no_info' and is_hidden_status:
286 287
        return None

288 289
    status = template_state.get(cert_status['status'], default_status)

Sarina Canelake committed
290 291 292 293
    status_dict = {
        'status': status,
        'show_download_url': status == 'ready',
        'show_disabled_download_button': status == 'generating',
294 295
        'mode': cert_status.get('mode', None),
        'linked_in_url': None
Sarina Canelake committed
296
    }
297

298
    if (status in ('generating', 'ready', 'notpassing', 'restricted') and
299
            course.end_of_course_survey_url is not None):
Sarina Canelake committed
300
        status_dict.update({
301 302
            'show_survey_button': True,
            'survey_url': process_survey_link(course.end_of_course_survey_url, user)})
303
    else:
Sarina Canelake committed
304
        status_dict['show_survey_button'] = False
305

306
    if status == 'ready':
307 308 309 310 311 312 313 314 315 316 317 318 319
        # showing the certificate web view button if certificate is ready state and feature flags are enabled.
        if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
            if get_active_web_certificate(course) is not None:
                status_dict.update({
                    'show_cert_web_view': True,
                    'cert_web_view_url': u'{url}'.format(
                        url=get_certificate_url(user_id=user.id, course_id=unicode(course.id))
                    )
                })
            else:
                # don't show download certificate button if we don't have an active certificate for course
                status_dict['show_download_url'] = False
        elif 'download_url' not in cert_status:
320 321 322 323 324
            log.warning(
                u"User %s has a downloadable cert for %s, but no download url",
                user.username,
                course.id
            )
325
            return default_info
326
        else:
Sarina Canelake committed
327
            status_dict['download_url'] = cert_status['download_url']
328

329 330 331 332 333 334
            # 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()
            if linkedin_config.enabled:
                status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
335
                    course.id,
336 337 338 339
                    course.display_name,
                    cert_status.get('mode'),
                    cert_status['download_url']
                )
340

341
    if status in ('generating', 'ready', 'notpassing', 'restricted'):
342 343 344 345
        if 'grade' not in cert_status:
            # 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.
346
            return default_info
347
        else:
Sarina Canelake committed
348
            status_dict['grade'] = cert_status['grade']
349

Sarina Canelake committed
350
    return status_dict
351

Calen Pennington committed
352

353
@ensure_csrf_cookie
354
def signin_user(request):
355 356 357 358
    """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
359 360 361
    if request.user.is_authenticated():
        return redirect(reverse('dashboard'))

362
    course_id = request.GET.get('course_id')
363
    email_opt_in = request.GET.get('email_opt_in')
364
    context = {
365
        'course_id': course_id,
366
        'email_opt_in': email_opt_in,
367
        'enrollment_action': request.GET.get('enrollment_action'),
368 369 370 371
        # 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',
372
        'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, course_id=course_id, email_opt_in=email_opt_in),
373
        'platform_name': microsite.get_value(
374 375 376
            'platform_name',
            settings.PLATFORM_NAME
        ),
377
    }
378

John Jarvis committed
379
    return render_to_response('login.html', context)
John Jarvis committed
380

381

382
@ensure_csrf_cookie
383
def register_user(request, extra_context=None):
384
    """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
385 386
    if request.user.is_authenticated():
        return redirect(reverse('dashboard'))
387 388 389 390

    external_auth_response = external_auth_register(request)
    if external_auth_response is not None:
        return external_auth_response
391

392
    course_id = request.GET.get('course_id')
393
    email_opt_in = request.GET.get('email_opt_in')
394

395
    context = {
396
        'course_id': course_id,
397
        'email_opt_in': email_opt_in,
398
        'email': '',
399
        'enrollment_action': request.GET.get('enrollment_action'),
400 401
        'name': '',
        'running_pipeline': None,
402
        'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, course_id=course_id, email_opt_in=email_opt_in),
403
        'platform_name': microsite.get_value(
404 405 406
            'platform_name',
            settings.PLATFORM_NAME
        ),
407 408
        'selected_provider': '',
        'username': '',
409
    }
410

411 412
    if extra_context is not None:
        context.update(extra_context)
413

414
    if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
415
        return render_to_response('register-shib.html', context)
416 417 418

    # If third-party auth is enabled, prepopulate the form with data from the
    # selected provider.
419
    if third_party_auth.is_enabled() and pipeline.running(request):
420 421 422 423 424 425 426
        running_pipeline = pipeline.get(request)
        current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
        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)

John Jarvis committed
427 428 429
    return render_to_response('register.html', context)


Will Daly committed
430
def complete_course_mode_info(course_id, enrollment, modes=None):
431 432 433 434 435 436 437 438
    """
    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
    """
Will Daly committed
439 440 441
    if modes is None:
        modes = CourseMode.modes_for_course_dict(course_id)

442 443 444 445 446 447
    mode_info = {'show_upsell': False, 'days_for_upsell': None}
    # we want to know if the user is already verified and if verified is an
    # option
    if 'verified' in modes and enrollment.mode != 'verified':
        mode_info['show_upsell'] = True
        # if there is an expiration date, find out how long from now it is
448
        if modes['verified'].expiration_datetime:
449
            today = datetime.datetime.now(UTC).date()
450
            mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
451 452 453 454

    return mode_info


455 456 457 458
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:
459 460 461
        # 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
462 463
        if redeemed_registration.invoice_item:
            if not getattr(redeemed_registration.invoice_item.invoice, 'is_valid'):
464 465 466
                blocked = True
                # disabling email notifications for unpaid registration courses
                Optout.objects.get_or_create(user=request.user, course_id=course_key)
467 468 469 470 471 472
                log.info(
                    u"User %s (%s) opted out of receiving emails from course %s",
                    request.user.username,
                    request.user.email,
                    course_key
                )
473 474
                track.views.server_track(request, "change-email1-settings", {"receive_emails": "no", "course": course_key.to_deprecated_string()}, page='dashboard')
                break
475 476 477

    return blocked

Sarina Canelake committed
478

479
@login_required
Matthew Mongeau committed
480 481
@ensure_csrf_cookie
def dashboard(request):
482 483
    user = request.user

484 485
    platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)

486 487
    # for microsites, we want to filter and only show enrollments for courses within
    # the microsites 'ORG'
488
    course_org_filter = microsite.get_value('course_org_filter')
489 490 491

    # Let's filter out any courses in an "org" that has been declared to be
    # in a Microsite
492
    org_filter_out_set = microsite.get_all_orgs()
493 494 495 496 497

    # remove our current Microsite from the "filter out" list, if applicable
    if course_org_filter:
        org_filter_out_set.remove(course_org_filter)

Julia Hansbrough committed
498 499 500
    # Build our (course, enrollment) list for the user, but ignore any courses that no
    # longer exist (because the course IDs have changed). Still, we don't delete those
    # enrollments, because it could have been a data push snafu.
501
    course_enrollment_pairs = list(get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set))
502

503 504 505
    # sort the enrollment pairs by the enrollment date
    course_enrollment_pairs.sort(key=lambda x: x[1].created, reverse=True)

Will Daly committed
506
    # Retrieve the course modes for each course
507 508
    enrolled_course_ids = [course.id for course, __ in course_enrollment_pairs]
    all_course_modes, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
Will Daly committed
509
    course_modes_by_course = {
510 511 512 513 514
        course_id: {
            mode.slug: mode
            for mode in modes
        }
        for course_id, modes in unexpired_course_modes.iteritems()
Will Daly committed
515 516 517 518 519 520 521
    }

    # 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_enrollment_pairs, course_modes_by_course
    )
522

523
    course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
524

525 526
    message = ""
    if not user.is_active:
527 528
        message = render_to_string(
            'registration/activate_account_notice.html',
529
            {'email': user.email, 'platform_name': platform_name}
530
        )
531

532 533
    # Global staff can see what courses errored on their dashboard
    staff_access = False
Victor Shnayder committed
534
    errored_courses = {}
535
    if has_access(user, 'staff', 'global'):
536 537 538 539
        # Show any courses that errored on load
        staff_access = True
        errored_courses = modulestore().get_errored_courses()

540 541 542 543 544
    show_courseware_links_for = frozenset(
        course.id for course, _enrollment in course_enrollment_pairs
        if has_access(request.user, 'load', course)
        and has_access(request.user, 'view_courseware_with_prerequisites', course)
    )
545

Will Daly committed
546 547 548 549 550 551 552 553 554 555 556
    # 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 = {
        course.id: complete_course_mode_info(
            course.id, enrollment,
            modes=course_modes_by_course[course.id]
        )
        for course, enrollment in course_enrollment_pairs
    }

557 558 559 560 561 562 563 564 565 566 567 568 569 570
    # 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.
571 572 573 574 575
    verify_status_by_course = check_verify_status_by_course(
        user,
        course_enrollment_pairs,
        all_course_modes
    )
Will Daly committed
576
    cert_statuses = {
577
        course.id: cert_info(request.user, course, _enrollment.mode)
Will Daly committed
578 579
        for course, _enrollment in course_enrollment_pairs
    }
Victor Shnayder committed
580

581
    # only show email settings for Mongo course and when bulk email is turned on
582
    show_email_settings_for = frozenset(
Julia Hansbrough committed
583
        course.id for course, _enrollment in course_enrollment_pairs if (
584
            settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
585
            modulestore().get_modulestore_type(course.id) != ModuleStoreEnum.Type.xml and
586 587 588
            CourseAuthorization.instructor_email_enabled(course.id)
        )
    )
589

590
    # Verification Attempts
591
    # Used to generate the "you must reverify for course x" banner
592
    verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user)
593

Julia Hansbrough committed
594
    # Gets data for midcourse reverifications, if any are necessary or have failed
595 596
    statuses = ["approved", "denied", "pending", "must_reverify"]
    reverifications = reverification_info(course_enrollment_pairs, user, statuses)
597

Julia Hansbrough committed
598
    show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs
599
                                       if _enrollment.refundable())
600

601 602 603
    block_courses = frozenset(course.id for course, enrollment in course_enrollment_pairs
                              if is_course_blocked(request, CourseRegistrationCode.objects.filter(course_id=course.id, registrationcoderedemption__redeemed_by=request.user), course.id))

604 605
    enrolled_courses_either_paid = frozenset(course.id for course, _enrollment in course_enrollment_pairs
                                             if _enrollment.is_paid_course())
606

607 608 609 610
    # 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"])

611 612 613
    # Populate the Order History for the side-bar.
    order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set)

614 615 616 617 618
    # get list of courses having pre-requisites yet to be completed
    courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs
                                             if course.pre_requisite_courses)
    courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)

cewing committed
619 620 621 622 623
    ccx_membership_triplets = []
    if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
        from ccx import ACTIVE_CCX_KEY
        from ccx.utils import get_ccx_membership_triplets
        ccx_membership_triplets = get_ccx_membership_triplets(
624 625
            user, course_org_filter, org_filter_out_set
        )
cewing committed
626
        # should we deselect any active CCX at this time so that we don't have
627
        # to change the URL for viewing a course?  I think so.
cewing committed
628
        request.session[ACTIVE_CCX_KEY] = None
629

630
    context = {
631
        'enrollment_message': enrollment_message,
632 633 634 635 636 637
        'course_enrollment_pairs': course_enrollment_pairs,
        'course_optouts': course_optouts,
        'message': message,
        'staff_access': staff_access,
        'errored_courses': errored_courses,
        'show_courseware_links_for': show_courseware_links_for,
Will Daly committed
638
        'all_course_modes': course_mode_info,
639 640 641 642
        'cert_statuses': cert_statuses,
        'show_email_settings_for': show_email_settings_for,
        'reverifications': reverifications,
        'verification_status': verification_status,
643
        'verification_status_by_course': verify_status_by_course,
644 645
        'verification_msg': verification_msg,
        'show_refund_option_for': show_refund_option_for,
646
        'block_courses': block_courses,
647 648
        'denied_banner': denied_banner,
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
649 650
        'user': user,
        'logout_url': reverse(logout_user),
651
        'platform_name': platform_name,
652
        'enrolled_courses_either_paid': enrolled_courses_either_paid,
653
        'provider_states': [],
654 655
        'order_history_list': order_history_list,
        'courses_requirements_not_met': courses_requirements_not_met,
cewing committed
656
        'ccx_membership_triplets': ccx_membership_triplets,
657
    }
658

659
    return render_to_response('dashboard.html', context)
660 661


Will Daly committed
662
def _create_recent_enrollment_message(course_enrollment_pairs, course_modes):
663 664 665 666 667 668
    """Builds a recent course enrollment message

    Constructs a new message template based on any recent course enrollments for the student.

    Args:
        course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.
Will Daly committed
669
        course_modes (dict): Mapping of course ID's to course mode dictionaries.
670 671 672

    Returns:
        A string representing the HTML message output from the message template.
Will Daly committed
673
        None if there are no recently enrolled courses.
674 675

    """
Will Daly committed
676 677 678 679 680 681 682
    recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollment_pairs)

    if recently_enrolled_courses:
        messages = [
            {
                "course_id": course.id,
                "course_name": course.display_name,
683
                "allow_donation": _allow_donation(course_modes, course.id, enrollment)
Will Daly committed
684
            }
685
            for course, enrollment in recently_enrolled_courses
Will Daly committed
686 687
        ]

688 689
        platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME)

690 691
        return render_to_string(
            'enrollment/course_enrollment_message.html',
692
            {'course_enrollment_messages': messages, 'platform_name': platform_name}
693 694 695 696 697 698 699 700 701 702 703 704
        )


def _get_recently_enrolled_courses(course_enrollment_pairs):
    """Checks to see if the student has recently enrolled in courses.

    Checks to see if any of the enrollments in the course_enrollment_pairs have been recently created and activated.

    Args:
        course_enrollment_pairs (list): A list of tuples containing courses, and the associated enrollment information.

    Returns:
Will Daly committed
705
        A list of courses
706 707 708 709 710

    """
    seconds = DashboardConfiguration.current().recent_enrollment_time_delta
    time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
    return [
711
        (course, enrollment) for course, enrollment in course_enrollment_pairs
712 713 714 715 716 717
        # 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
    ]


718
def _allow_donation(course_modes, course_id, enrollment):
719 720 721 722 723 724 725
    """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.
726
        enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
727 728 729 730 731

    Returns:
        True if the course is allowing donations.

    """
732
    donations_enabled = DonationConfiguration.current().enabled
733
    return donations_enabled and enrollment.mode in course_modes[course_id] and course_modes[course_id][enrollment.mode].min_price == 0
734 735


736 737
def try_change_enrollment(request):
    """
738
    This method calls change_enrollment if the necessary POST
739
    parameters are present, but does not return anything in most cases. It
740 741 742 743 744 745
    simply logs the result or exception. This is usually
    called after a registration or login, as secondary action.
    It should not interrupt a successful registration or login.
    """
    if 'enrollment_action' in request.POST:
        try:
746
            enrollment_response = change_enrollment(request)
747 748
            # There isn't really a way to display the results to the user, so we just log it
            # We expect the enrollment to be a success, and will show up on the dashboard anyway
749
            log.info(
750 751 752
                u"Attempted to automatically enroll after login. Response code: %s; response body: %s",
                enrollment_response.status_code,
                enrollment_response.content
753
            )
754 755 756 757
            # Hack: since change_enrollment delivers its redirect_url in the content
            # of its response, we check here that only the 200 codes with content
            # will return redirect_urls.
            if enrollment_response.status_code == 200 and enrollment_response.content != '':
758
                return enrollment_response.content
Sarina Canelake committed
759
        except Exception as exc:  # pylint: disable=broad-except
760
            log.exception(u"Exception automatically enrolling after login: %s", exc)
761

762

763
def _update_email_opt_in(request, org):
764
    """Helper function used to hit the profile API if email opt-in is enabled."""
Andy Armstrong committed
765

766 767 768
    email_opt_in = request.POST.get('email_opt_in')
    if email_opt_in is not None:
        email_opt_in_boolean = email_opt_in == 'true'
769
        preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
770 771


772
@require_POST
773
@commit_on_success_with_read_committed
774
def change_enrollment(request, check_access=True):
775 776 777 778 779 780 781 782 783 784 785 786 787 788 789
    """
    Modify the enrollment status for the logged-in user.

    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 or
    as a post-login/registration helper, so the error messages in the responses
    should never actually be user-visible.
790 791 792 793 794

    Args:
        request (`Request`): The Django request object

    Keyword Args:
795 796 797 798 799
        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.
800 801 802 803

    Returns:
        Response

804
    """
805
    # Get the user
806
    user = request.user
807

Julia Hansbrough committed
808 809 810 811
    # Ensure the user is authenticated
    if not user.is_authenticated():
        return HttpResponseForbidden()

812
    # Ensure we received a course_id
813
    action = request.POST.get("enrollment_action")
814
    if 'course_id' not in request.POST:
David Baumgold committed
815
        return HttpResponseBadRequest(_("Course id not specified"))
816

Julia Hansbrough committed
817 818 819 820
    try:
        course_id = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get("course_id"))
    except InvalidKeyError:
        log.warning(
821 822 823 824
            u"User %s tried to %s with invalid course id: %s",
            user.username,
            action,
            request.POST.get("course_id"),
Julia Hansbrough committed
825 826 827
        )
        return HttpResponseBadRequest(_("Invalid course id"))

828
    if action == "enroll":
Don Mitchell committed
829 830 831
        # 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):
832 833 834 835 836
            log.warning(
                u"User %s tried to enroll in non-existent course %s",
                user.username,
                course_id
            )
Don Mitchell committed
837 838
            return HttpResponseBadRequest(_("Course id is invalid"))

839 840
        # Record the user's email opt-in preference
        if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
841
            _update_email_opt_in(request, course_id.org)
842

843 844
        available_modes = CourseMode.modes_for_course_dict(course_id)

845 846 847 848 849 850 851 852 853 854 855
        # 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)

856 857 858
        # Check that auto enrollment is allowed for this course
        # (= the course is NOT behind a paywall)
        if CourseMode.can_auto_enroll(course_id):
859 860 861 862 863 864
            # Enroll the user using the default mode (honor)
            # 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 "honor".
865
            try:
866
                CourseEnrollment.enroll(user, course_id, check_access=check_access)
867 868
            except Exception:
                return HttpResponseBadRequest(_("Could not enroll"))
869

870 871
        # If we have more than one course mode or professional ed is enabled,
        # then send the user to the choose your track page.
872
        # (In the case of no-id-professional/professional ed, this will redirect to a page that
873
        # funnels users directly into the verification / payment flow)
874
        if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
875 876 877 878 879 880
            return HttpResponse(
                reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
            )

        # Otherwise, there is only one mode available (the default)
        return HttpResponse()
881

882 883 884 885 886 887 888 889
    elif action == "add_to_cart":
        # Pass the request handling to shoppingcart.views
        # The view in shoppingcart.views performs error handling and logs different errors.  But this elif clause
        # is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
        # This means there's no good way to display error messages to the user.  So we log the errors and send
        # the user to the shopping cart page always, where they can reasonably discern the status of their cart,
        # whether things got added, etc

890
        shoppingcart.views.add_course_to_cart(request, course_id.to_deprecated_string())
891 892 893 894
        return HttpResponse(
            reverse("shoppingcart.views.show_cart")
        )

895
    elif action == "unenroll":
896
        if not CourseEnrollment.is_enrolled(user, course_id):
897
            return HttpResponseBadRequest(_("You are not enrolled in this course"))
898 899
        CourseEnrollment.unenroll(user, course_id)
        return HttpResponse()
900
    else:
David Baumgold committed
901
        return HttpResponseBadRequest(_("Enrollment action is invalid"))
902

903

904
@never_cache
905
@ensure_csrf_cookie
906
def accounts_login(request):
907 908 909 910
    """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
911

912
    redirect_to = request.GET.get('next')
913
    context = {
914
        'pipeline_running': 'false',
915
        'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
916 917 918
        'platform_name': settings.PLATFORM_NAME,
    }
    return render_to_response('login.html', context)
919

920

921
# Need different levels of logging
922
@ensure_csrf_cookie
923
def login_user(request, error=""):  # pylint: disable-msg=too-many-statements,unused-argument
924
    """AJAX request to log in the user."""
925

926 927 928 929 930 931
    backend_name = None
    email = None
    password = None
    redirect_url = None
    response = None
    running_pipeline = None
932
    third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953
    third_party_auth_successful = False
    trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
    user = None

    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']
        requested_provider = provider.Registry.get_by_backend_name(backend_name)

        try:
            user = pipeline.get_authenticated_user(username, backend_name)
            third_party_auth_successful = True
        except User.DoesNotExist:
            AUDIT_LOG.warning(
                u'Login failed - user with username {username} has no social auth with backend_name {backend_name}'.format(
                    username=username, backend_name=backend_name))
954
            return HttpResponse(
955
                _("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
Sarina Canelake committed
956 957 958 959
                    platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
                )
                + "<br/><br/>" +
                _("Use your {platform_name} username and password to log into {platform_name} below, "
960
                  "and then link your {platform_name} account with {provider_name} from your dashboard.").format(
Sarina Canelake committed
961
                      platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
David Baumgold committed
962
                )
Sarina Canelake committed
963 964 965 966
                + "<br/><br/>" +
                _("If you don't have an {platform_name} account yet, click <strong>Register Now</strong> at the top of the page.").format(
                    platform_name=settings.PLATFORM_NAME
                ),
967
                content_type="text/plain",
968
                status=403
969
            )
970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987

    else:

        if 'email' not in request.POST or 'password' not in request.POST:
            return JsonResponse({
                "success": False,
                "value": _('There was an error receiving your login information. Please email us.'),  # TODO: User error message
            })  # TODO: this should be status code 400  # pylint: disable=fixme

        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))
Piotr Mitros committed
988

989 990 991
    # 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.
992
    if settings.FEATURES.get('AUTH_USE_SHIB') and user:
993 994
        try:
            eamap = ExternalAuthMap.objects.get(user=user)
995
            if eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
David Baumgold committed
996 997 998 999
                return JsonResponse({
                    "success": False,
                    "redirect": reverse('shib-login'),
                })  # TODO: this should be status code 301  # pylint: disable=fixme
1000 1001
        except ExternalAuthMap.DoesNotExist:
            # This is actually the common case, logging in user without external linked login
1002
            AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
1003

1004
    # see if account has been locked out due to excessive login failures
1005 1006 1007
    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):
David Baumgold committed
1008 1009 1010 1011
            return JsonResponse({
                "success": False,
                "value": _('This account has been temporarily locked due to excessive login failures. Try again later.'),
            })  # TODO: this should be status code 429  # pylint: disable=fixme
1012

1013
    # see if the user must reset his/her password due to any policy settings
1014
    if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup):
1015 1016 1017 1018
        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 '
Jay Zoldak committed
1019
                       '"Forgot Password" link on this page to reset your password before logging in again.'),
1020 1021
        })  # TODO: this should be status code 403  # pylint: disable=fixme

Diana Huang committed
1022 1023 1024 1025
    # 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 ""
1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036

    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

Piotr Mitros committed
1037
    if user is None:
1038 1039 1040 1041
        # 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)

Diana Huang committed
1042 1043 1044
        # 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 != "":
1045 1046 1047 1048 1049
            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))
David Baumgold committed
1050 1051 1052 1053
        return JsonResponse({
            "success": False,
            "value": _('Email or password is incorrect.'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
1054

1055 1056 1057 1058
    # successful login, clear failed login attempts counters, if applicable
    if LoginFailures.is_feature_enabled():
        LoginFailures.clear_lockout_counter(user)

1059 1060 1061
    # Track the user's sign in
    if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
        tracking_context = tracker.get_tracker().resolve_context()
1062
        analytics.identify(user.id, {
1063 1064 1065 1066 1067 1068 1069 1070 1071
            'email': email,
            'username': username,
        })

        analytics.track(
            user.id,
            "edx.bi.user.account.authenticated",
            {
                'category': "conversion",
1072
                'label': request.POST.get('course_id'),
Julia Hansbrough committed
1073
                'provider': None
1074 1075 1076
            },
            context={
                'Google Analytics': {
Sarina Canelake committed
1077
                    'clientId': tracking_context.get('client_id')
1078 1079 1080 1081
                }
            }
        )

Piotr Mitros committed
1082
    if user is not None and user.is_active:
1083
        try:
1084 1085
            # We do not log here, because we have a handler registered
            # to perform logging on successful logins.
1086
            login(request, user)
1087
            if request.POST.get('remember') == 'true':
1088
                request.session.set_expiry(604800)
1089 1090 1091
                log.debug("Setting user session to never expire")
            else:
                request.session.set_expiry(0)
Sarina Canelake committed
1092
        except Exception as exc:  # pylint: disable=broad-except
1093
            AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
1094
            log.critical("Login failed - Could not create session. Is memcached running?")
Sarina Canelake committed
1095
            log.exception(exc)
1096
            raise
1097

1098
        redirect_url = try_change_enrollment(request)
Victor Shnayder committed
1099

1100 1101 1102
        if third_party_auth_successful:
            redirect_url = pipeline.get_complete_url(backend_name)

David Baumgold committed
1103 1104 1105 1106
        response = JsonResponse({
            "success": True,
            "redirect_url": redirect_url,
        })
1107

1108 1109 1110
        # Ensure that the external marketing site can
        # detect that the user is logged in.
        return set_logged_in_cookie(request, response)
Victor Shnayder committed
1111

1112 1113 1114 1115
    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))
Victor Shnayder committed
1116

1117
    reactivation_email_for_user(user)
1118
    not_activated_msg = _("This account has not been activated. We have sent another activation message. Please check your email for the activation instructions.")
David Baumgold committed
1119 1120 1121 1122
    return JsonResponse({
        "success": False,
        "value": not_activated_msg,
    })  # TODO: this should be status code 400  # pylint: disable=fixme
Piotr Mitros committed
1123

1124

1125
@csrf_exempt
1126 1127 1128 1129 1130 1131 1132 1133
@require_POST
@social_utils.strategy("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.
    """
1134 1135
    warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)

1136 1137 1138 1139
    backend = request.social_strategy.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
1140
            request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
1141 1142 1143 1144 1145 1146 1147
            user = None
            try:
                user = backend.do_auth(request.POST["access_token"])
            except HTTPError:
                pass
            # do_auth can return a non-User object if it fails
            if user and isinstance(user, User):
1148
                login(request, user)
1149 1150 1151 1152 1153 1154 1155 1156 1157
                return JsonResponse(status=204)
            else:
                # Ensure user does not re-enter the pipeline
                request.social_strategy.clean_partial_pipeline()
                return JsonResponse({"error": "invalid_token"}, status=401)
        else:
            return JsonResponse({"error": "invalid_request"}, status=400)
    raise Http404

1158

1159
@ensure_csrf_cookie
Piotr Mitros committed
1160
def logout_user(request):
1161
    """
1162 1163 1164
    HTTP request to log out the user. Redirects to marketing page.
    Deletes both the CSRF and sessionid cookies so the marketing
    site can determine the logged in state of the user
1165
    """
1166 1167
    # We do not log here, because we have a handler registered
    # to perform logging on successful logouts.
Piotr Mitros committed
1168
    logout(request)
1169
    if settings.FEATURES.get('AUTH_USE_CAS'):
1170 1171 1172 1173
        target = reverse('cas-logout')
    else:
        target = '/'
    response = redirect(target)
David Baumgold committed
1174 1175 1176 1177
    response.delete_cookie(
        settings.EDXMKTG_COOKIE_NAME,
        path='/', domain=settings.SESSION_COOKIE_DOMAIN,
    )
1178
    return response
Piotr Mitros committed
1179

David Baumgold committed
1180

1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206
@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.all()[0].changed_by]
        rows.append(row)

    context = {'headers': headers, 'rows': rows}

    return render_to_response("manage_user_standing.html", context)

1207

1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220
@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() == '':
1221
        context['message'] = _('Please enter a username')
1222 1223 1224 1225
        return JsonResponse(context, status=400)

    account_action = request.POST.get('account_action')
    if account_action is None:
1226
        context['message'] = _('Please choose an option')
1227 1228 1229 1230 1231 1232
        return JsonResponse(context, status=400)

    username = username.strip()
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
1233
        context['message'] = _("User with username {} does not exist").format(username)
1234 1235
        return JsonResponse(context, status=400)
    else:
1236
        user_account, _success = UserStanding.objects.get_or_create(
1237 1238 1239 1240
            user=user, defaults={'changed_by': request.user},
        )
        if account_action == 'disable':
            user_account.account_status = UserStanding.ACCOUNT_DISABLED
1241
            context['message'] = _("Successfully disabled {}'s account").format(username)
1242
            log.info(u"%s disabled %s's account", request.user, username)
1243 1244
        elif account_action == 'reenable':
            user_account.account_status = UserStanding.ACCOUNT_ENABLED
1245
            context['message'] = _("Successfully reenabled {}'s account").format(username)
1246
            log.info(u"%s reenabled %s's account", request.user, username)
1247
        else:
1248
            context['message'] = _("Unexpected account status")
1249 1250
            return JsonResponse(context, status=400)
        user_account.changed_by = request.user
1251
        user_account.standing_last_changed_at = datetime.datetime.now(UTC)
1252 1253 1254 1255
        user_account.save()

    return JsonResponse(context)

1256

1257
@login_required
1258
@ensure_csrf_cookie
1259
def change_setting(request):
1260
    """JSON call to change a profile setting: Right now, location"""
1261
    # TODO (vshnayder): location is no longer used
Sarina Canelake committed
1262
    u_prof = UserProfile.objects.get(user=request.user)  # request.user.profile_cache
Piotr Mitros committed
1263
    if 'location' in request.POST:
Sarina Canelake committed
1264 1265
        u_prof.location = request.POST['location']
    u_prof.save()
1266

David Baumgold committed
1267 1268
    return JsonResponse({
        "success": True,
Sarina Canelake committed
1269
        "location": u_prof.location,
David Baumgold committed
1270
    })
1271

Calen Pennington committed
1272

1273 1274 1275 1276 1277
class AccountValidationError(Exception):
    def __init__(self, message, field):
        super(AccountValidationError, self).__init__(message)
        self.field = field

1278 1279

@receiver(post_save, sender=User)
1280
def user_signup_handler(sender, **kwargs):  # pylint: disable=unused-argument
1281 1282 1283 1284 1285 1286 1287
    """
    handler that saves the user Signup Source
    when the user is created
    """
    if 'created' in kwargs and kwargs['created']:
        site = microsite.get_value('SITE_NAME')
        if site:
1288
            user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
1289 1290 1291 1292
            user_signup_source.save()
            log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))


1293
def _do_create_account(form):
1294 1295 1296 1297 1298 1299 1300 1301
    """
    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.
    """
1302 1303 1304 1305 1306 1307 1308 1309 1310
    if not form.is_valid():
        raise ValidationError(form.errors)

    user = User(
        username=form.cleaned_data["username"],
        email=form.cleaned_data["email"],
        is_active=False
    )
    user.set_password(form.cleaned_data["password"])
1311
    registration = Registration()
1312

1313 1314 1315
    # 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:
1316
        user.save()
1317 1318
    except IntegrityError:
        # Figure out the cause of the integrity error
1319
        if len(User.objects.filter(username=user.username)) > 0:
1320
            raise AccountValidationError(
1321
                _("An account with the Public Username '{username}' already exists.").format(username=user.username),
1322
                field="username"
Sarina Canelake committed
1323
            )
1324
        elif len(User.objects.filter(email=user.email)) > 0:
1325
            raise AccountValidationError(
1326
                _("An account with the Email '{email}' already exists.").format(email=user.email),
1327
                field="email"
Sarina Canelake committed
1328
            )
1329 1330
        else:
            raise
1331

1332 1333 1334 1335 1336
    # 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)

1337
    registration.register(user)
1338

1339 1340 1341 1342 1343 1344 1345 1346 1347
    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
1348 1349
    if extended_profile:
        profile.meta = json.dumps(extended_profile)
1350
    try:
1351
        profile.save()
Sarina Canelake committed
1352
    except Exception:  # pylint: disable=broad-except
David Baumgold committed
1353
        log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
1354
        raise
1355

1356
    return (user, profile, registration)
1357

1358

1359
def create_account_with_params(request, params):
1360
    """
1361 1362 1363 1364 1365
    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.
Matthew Mongeau committed
1366

1367
    Does not return anything.
Matthew Mongeau committed
1368

1369 1370 1371
    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.
1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384

    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").
1385 1386 1387 1388
    """
    # 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())
1389 1390 1391 1392 1393 1394

    # allow for microsites to define their own set of required/optional/hidden fields
    extra_fields = microsite.get_value(
        'REGISTRATION_EXTRA_FIELDS',
        getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
    )
Matthew Mongeau committed
1395

1396 1397 1398 1399 1400 1401 1402 1403
    # Boolean of whether a 3rd party auth provider and credentials were provided in
    # the API so the newly created account can link with the 3rd party account.
    #
    # Note: this is orthogonal to the 3rd party authentication pipeline that occurs
    # when the account is created via the browser and redirect URLs.
    should_link_with_social_auth = third_party_auth.is_enabled() and 'provider' in params

    if should_link_with_social_auth or (third_party_auth.is_enabled() and pipeline.running(request)):
1404
        params["password"] = pipeline.make_random_password()
1405

ichuang committed
1406 1407
    # 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
1408
    # unless originally we didn't get a valid email or name from the external auth
1409
    # TODO: We do not check whether these values meet all necessary criteria, such as email length
Sarina Canelake committed
1410 1411
    do_external_auth = 'ExternalAuthMap' in request.session
    if do_external_auth:
ichuang committed
1412
        eamap = request.session['ExternalAuthMap']
1413 1414
        try:
            validate_email(eamap.external_email)
1415
            params["email"] = eamap.external_email
1416
        except ValidationError:
1417 1418 1419 1420 1421
            pass
        if eamap.external_name.strip() != '':
            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"])
Piotr Mitros committed
1422

1423 1424 1425 1426 1427
    extended_profile_fields = microsite.get_value('extended_profile_fields', [])
    enforce_password_policy = (
        settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
        not do_external_auth
    )
1428
    # Can't have terms of service for certain SHIB users, like at Stanford
1429 1430 1431
    tos_required = (
        not settings.FEATURES.get("AUTH_USE_SHIB") or
        not settings.FEATURES.get("SHIB_DISABLE_TOS") or
Sarina Canelake committed
1432
        not do_external_auth or
1433 1434 1435 1436
        not eamap.external_domain.startswith(
            external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
        )
    )
1437

1438
    form = AccountCreationForm(
1439
        data=params,
1440 1441 1442 1443
        extra_fields=extra_fields,
        extended_profile_fields=extended_profile_fields,
        enforce_username_neq_password=True,
        enforce_password_policy=enforce_password_policy,
1444
        tos_required=tos_required,
1445
    )
1446

1447
    # Perform operations within a transaction that are critical to account creation
1448
    with transaction.commit_on_success():
1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476
        # first, create the account
        (user, profile, registration) = _do_create_account(form)

        # next, link the account with social auth, if provided
        if should_link_with_social_auth:
            request.social_strategy = social_utils.load_strategy(backend=params['provider'], request=request)
            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.social_strategy.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()
                raise ValidationError({'access_token': [error_message]})
1477

1478 1479 1480
    # Perform operations that are non-critical parts of account creation
    preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())

1481 1482 1483 1484 1485 1486
    if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
        try:
            enable_notifications(user)
        except Exception:
            log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))

1487
    dog_stats_api.increment("common.student.account_created")
1488 1489 1490 1491

    # Track the user's registration
    if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
        tracking_context = tracker.get_tracker().resolve_context()
1492
        analytics.identify(user.id, {
1493 1494
            'email': user.email,
            'username': user.username,
1495 1496
        })

Julia Hansbrough committed
1497 1498
        # If the user is registering via 3rd party auth, track which provider they use
        provider_name = None
1499
        if third_party_auth.is_enabled() and pipeline.running(request):
Julia Hansbrough committed
1500 1501 1502 1503
            running_pipeline = pipeline.get(request)
            current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
            provider_name = current_provider.NAME

1504 1505
        analytics.track(
            user.id,
Sarina Canelake committed
1506
            "edx.bi.user.account.registered",
1507
            {
Julia Hansbrough committed
1508
                'category': 'conversion',
1509
                'label': params.get('course_id'),
Julia Hansbrough committed
1510
                'provider': provider_name
1511 1512 1513
            },
            context={
                'Google Analytics': {
Sarina Canelake committed
1514
                    'clientId': tracking_context.get('client_id')
1515 1516 1517 1518
                }
            }
        )

1519 1520
    create_comments_service_user(user)

1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531
    # Don't send 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.
    #
    # 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).
1532
    send_email = (
1533
        not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
1534 1535 1536 1537
        not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
        not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'))
    )
    if send_email:
1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548
        context = {
            'name': profile.name,
            'key': registration.activation_key,
        }

        # composes activation email
        subject = render_to_string('emails/activation_email_subject.txt', context)
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
        message = render_to_string('emails/activation_email.txt', context)

1549
        from_address = microsite.get_value(
1550 1551 1552
            'email_from_address',
            settings.DEFAULT_FROM_EMAIL
        )
1553
        try:
1554 1555
            if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
                dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
1556 1557
                message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
                           '-' * 80 + '\n\n' + message)
1558
                mail.send_mail(subject, message, from_address, [dest_addr], fail_silently=False)
1559
            else:
David Baumgold committed
1560 1561
                user.email_user(subject, message, from_address)
        except Exception:  # pylint: disable=broad-except
1562
            log.error(u'Unable to send activation email to user from "%s"', from_address, exc_info=True)
1563 1564
    else:
        registration.activate()
1565

1566 1567 1568
    # 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.
1569
    new_user = authenticate(username=user.username, password=params['password'])
Sarina Canelake committed
1570
    login(request, new_user)
1571 1572
    request.session.set_expiry(0)

1573 1574
    # TODO: there is no error checking here to see that the user actually logged in successfully,
    # and is not yet an active user.
Sarina Canelake committed
1575 1576
    if new_user is not None:
        AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
1577

Sarina Canelake committed
1578 1579
    if do_external_auth:
        eamap.user = new_user
1580
        eamap.dtsignup = datetime.datetime.now(UTC)
ichuang committed
1581
        eamap.save()
1582 1583
        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)
ichuang committed
1584

1585
        if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
1586
            log.info('bypassing activation email')
Sarina Canelake committed
1587 1588 1589
            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))
Victor Shnayder committed
1590

1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624

def set_marketing_cookie(request, response):
    """
    Set the login cookie for the edx marketing site on the given response. Its
    expiration will match that of the given request's session.
    """
    if request.session.get_expire_at_browser_close():
        max_age = None
        expires = None
    else:
        max_age = request.session.get_expiry_age()
        expires_time = time.time() + max_age
        expires = cookie_date(expires_time)

    # we want this cookie to be accessed via javascript
    # so httponly is set to None
    response.set_cookie(
        settings.EDXMKTG_COOKIE_NAME,
        'true',
        max_age=max_age,
        expires=expires,
        domain=settings.SESSION_COOKIE_DOMAIN,
        path='/',
        secure=None,
        httponly=None
    )


@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 navigation.html
    """
1625 1626
    warnings.warn("Please use RegistrationView instead.", DeprecationWarning)

1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641
    try:
        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
        )

1642 1643 1644
    redirect_url = try_change_enrollment(request)

    # Resume the third-party-auth pipeline if necessary.
1645
    if third_party_auth.is_enabled() and pipeline.running(request):
1646 1647 1648
        running_pipeline = pipeline.get(request)
        redirect_url = pipeline.get_complete_url(running_pipeline['backend'])

1649 1650
    response = JsonResponse({
        'success': True,
1651
        'redirect_url': redirect_url,
1652
    })
1653
    set_marketing_cookie(request, response)
1654 1655
    return response

Matthew Mongeau committed
1656

1657
def auto_auth(request):
1658
    """
1659
    Create or configure a user account, then log in as that user.
Matthew Mongeau committed
1660

1661 1662
    Enabled only when
    settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
1663

1664 1665 1666 1667 1668
    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`
1669
    * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
1670
    * `no_login`: Define this to create the user but not login
Matthew Mongeau committed
1671

1672 1673 1674
    If username, email, or password are not provided, use
    randomly generated credentials.
    """
Matthew Mongeau committed
1675

1676 1677
    # Generate a unique name to use if none provided
    unique_name = uuid.uuid4().hex[0:30]
1678 1679

    # Use the params from the request, otherwise use these defaults
1680 1681 1682 1683 1684 1685
    username = request.GET.get('username', unique_name)
    password = request.GET.get('password', unique_name)
    email = request.GET.get('email', unique_name + "@example.com")
    full_name = request.GET.get('full_name', username)
    is_staff = request.GET.get('staff', None)
    course_id = request.GET.get('course_id', None)
1686 1687
    course_key = None
    if course_id:
John Eskew committed
1688
        course_key = CourseLocator.from_string(course_id)
1689
    role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()]
1690
    login_when_done = 'no_login' not in request.GET
1691

1692 1693 1694 1695 1696 1697 1698 1699 1700
    form = AccountCreationForm(
        data={
            'username': username,
            'email': email,
            'password': password,
            'name': full_name,
        },
        tos_required=False
    )
1701

1702 1703
    # Attempt to create the account.
    # If successful, this will return a tuple containing
1704 1705
    # the new user object.
    try:
1706
        user, profile, reg = _do_create_account(form)
1707 1708
    except AccountValidationError:
        # Attempt to retrieve the existing user.
1709
        user = User.objects.get(username=username)
1710 1711 1712
        user.email = email
        user.set_password(password)
        user.save()
1713
        profile = UserProfile.objects.get(user=user)
1714
        reg = Registration.objects.get(user=user)
1715 1716 1717 1718 1719

    # Set the user's global staff bit
    if is_staff is not None:
        user.is_staff = (is_staff == "true")
        user.save()
1720

1721 1722 1723
    # Activate the user
    reg.activate()
    reg.save()
1724

1725 1726 1727 1728 1729 1730
    # 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()

1731
    # Enroll the user in a course
1732 1733
    if course_key is not None:
        CourseEnrollment.enroll(user, course_key)
1734

1735 1736
    # Apply the roles
    for role_name in role_names:
1737
        role = Role.objects.get(name=role_name, course_id=course_key)
1738 1739
        user.roles.add(role)

1740
    # Log in as the user
1741 1742 1743
    if login_when_done:
        user = authenticate(username=username, password=password)
        login(request, user)
1744

1745 1746
    create_comments_service_user(user)

1747 1748
    # Provide the user with a valid CSRF token
    # then return a 200 response
1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763
    if request.META.get('HTTP_ACCEPT') == 'application/json':
        response = JsonResponse({
            'created_status': u"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),
        })
    else:
        success_msg = u"{} user {} ({}) with password {} and user_id {}".format(
            u"Logged in" if login_when_done else "Created",
            username, email, password, user.id  # pylint: disable=no-member
        )
        response = HttpResponse(success_msg)
1764 1765
    response.set_cookie('csrftoken', csrf(request)['csrf_token'])
    return response
1766

1767

1768
@ensure_csrf_cookie
Piotr Mitros committed
1769
def activate_account(request, key):
1770
    """When link in activation e-mail is clicked"""
Sarina Canelake committed
1771 1772
    regs = Registration.objects.filter(activation_key=key)
    if len(regs) == 1:
1773 1774
        user_logged_in = request.user.is_authenticated()
        already_active = True
Sarina Canelake committed
1775 1776
        if not regs[0].user.is_active:
            regs[0].activate()
1777
            already_active = False
1778

1779
        # Enroll student in any pending courses he/she may have if auto_enroll flag is set
Sarina Canelake committed
1780
        student = User.objects.filter(id=regs[0].user_id)
1781 1782 1783 1784
        if student:
            ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
            for cea in ceas:
                if cea.auto_enroll:
1785 1786 1787 1788 1789 1790 1791 1792 1793 1794
                    enrollment = CourseEnrollment.enroll(student[0], cea.course_id)
                    manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student[0].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[0].email, ALLOWEDTOENROLL_TO_ENROLLED,
                            manual_enrollment_audit.reason, enrollment
                        )
1795

cewing committed
1796 1797 1798
            # enroll student in any pending CCXs he/she may have if auto_enroll flag is set
            if settings.FEATURES.get('CUSTOM_COURSES_EDX'):
                from ccx.models import CcxMembership, CcxFutureMembership
1799
                ccxfms = CcxFutureMembership.objects.filter(
1800 1801
                    email=student[0].email
                )
1802 1803 1804
                for ccxfm in ccxfms:
                    if ccxfm.auto_enroll:
                        CcxMembership.auto_enroll(student[0], ccxfm)
1805

1806 1807 1808 1809 1810 1811 1812
        resp = render_to_response(
            "registration/activation_complete.html",
            {
                'user_logged_in': user_logged_in,
                'already_active': already_active
            }
        )
1813
        return resp
Sarina Canelake committed
1814
    if len(regs) == 0:
1815 1816 1817 1818
        return render_to_response(
            "registration/activation_invalid.html",
            {'csrf': csrf(request)['csrf_token']}
        )
1819
    return HttpResponseServerError(_("Unknown error. Please e-mail us to let us know how it happened."))
Piotr Mitros committed
1820

1821

1822
@csrf_exempt
1823
@require_POST
Piotr Mitros committed
1824
def password_reset(request):
1825
    """ Attempts to send a password reset e-mail. """
1826 1827
    # Add some rate limiting here by re-using the RateLimitMixin as a helper class
    limiter = BadRequestRateLimiter()
1828
    if limiter.is_rate_limit_exceeded(request):
1829 1830 1831
        AUDIT_LOG.warning("Rate limit exceeded in password_reset")
        return HttpResponseForbidden()

1832
    form = PasswordResetFormNoActive(request.POST)
Piotr Mitros committed
1833
    if form.is_valid():
Calen Pennington committed
1834 1835 1836 1837
        form.save(use_https=request.is_secure(),
                  from_email=settings.DEFAULT_FROM_EMAIL,
                  request=request,
                  domain_override=request.get_host())
1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849
        # 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,
            }
        )
1850 1851 1852 1853 1854
    else:
        # bad user? tick the rate limiter counter
        AUDIT_LOG.info("Bad password_reset user passed in.")
        limiter.tick_bad_request_counter(request)

David Baumgold committed
1855 1856 1857 1858
    return JsonResponse({
        'success': True,
        'value': render_to_string('registration/password_reset_done.html', {}),
    })
David Baumgold committed
1859

1860

1861 1862 1863 1864 1865
def password_reset_confirm_wrapper(
    request,
    uidb36=None,
    token=None,
):
1866
    """ A wrapper around django.contrib.auth.views.password_reset_confirm.
1867
        Needed because we want to set the user as active at this step.
1868
    """
1869
    # cribbed from django.contrib.auth.views.password_reset_confirm
1870 1871 1872 1873 1874 1875 1876
    try:
        uid_int = base36_to_int(uidb36)
        user = User.objects.get(id=uid_int)
        user.is_active = True
        user.save()
    except (ValueError, User.DoesNotExist):
        pass
1877

1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897
    # tie in password strength enforcement as an optional level of
    # security protection
    err_msg = None

    if request.method == 'POST':
        password = request.POST['new_password1']
        if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False):
            try:
                validate_password_length(password)
                validate_password_complexity(password)
                validate_password_dictionary(password)
            except ValidationError, err:
                err_msg = _('Password: ') + '; '.join(err.messages)

        # 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']
1898 1899 1900 1901 1902
            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)
1903 1904 1905 1906

        # 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']
1907
            err_msg = ungettext(
1908 1909
                "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.",
1910 1911
                num_days
            ).format(num=num_days)
1912 1913 1914 1915 1916 1917 1918 1919 1920

    if err_msg:
        # We have an password reset attempt which violates some security policy, use the
        # existing Django template to communicate this back to the user
        context = {
            'validlink': True,
            'form': None,
            'title': _('Password reset unsuccessful'),
            'err_msg': err_msg,
1921
            'platform_name': settings.PLATFORM_NAME,
1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948
        }
        return TemplateResponse(request, 'registration/password_reset_confirm.html', context)
    else:
        # we also want to pass settings.PLATFORM_NAME in as extra_context
        extra_context = {"platform_name": settings.PLATFORM_NAME}

        if request.method == 'POST':
            # remember what the old password hash is before we call down
            old_password_hash = user.password

            result = password_reset_confirm(
                request, uidb36=uidb36, token=token, extra_context=extra_context
            )

            # 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)

            return result
        else:
            return password_reset_confirm(
                request, uidb36=uidb36, token=token, extra_context=extra_context
            )
1949

Calen Pennington committed
1950

1951
def reactivation_email_for_user(user):
1952 1953 1954
    try:
        reg = Registration.objects.get(user=user)
    except Registration.DoesNotExist:
David Baumgold committed
1955 1956 1957 1958
        return JsonResponse({
            "success": False,
            "error": _('No inactive user with this e-mail exists'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
1959

David Baumgold committed
1960 1961 1962 1963
    context = {
        'name': user.profile.name,
        'key': reg.activation_key,
    }
Matthew Mongeau committed
1964

David Baumgold committed
1965
    subject = render_to_string('emails/activation_email_subject.txt', context)
1966
    subject = ''.join(subject.splitlines())
David Baumgold committed
1967
    message = render_to_string('emails/activation_email.txt', context)
1968

1969
    try:
David Baumgold committed
1970 1971
        user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
    except Exception:  # pylint: disable=broad-except
1972
        log.error(u'Unable to send reactivation email from "%s"', settings.DEFAULT_FROM_EMAIL, exc_info=True)
David Baumgold committed
1973 1974 1975 1976
        return JsonResponse({
            "success": False,
            "error": _('Unable to send reactivation email')
        })  # TODO: this should be status code 500  # pylint: disable=fixme
Matthew Mongeau committed
1977

David Baumgold committed
1978
    return JsonResponse({"success": True})
Victor Shnayder committed
1979

1980

1981
def validate_new_email(user, new_email):
1982
    """
1983 1984
    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.
1985 1986 1987 1988 1989 1990 1991 1992
    """
    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.'))
1993

1994
    if User.objects.filter(email=new_email).count() != 0:
1995
        raise ValueError(_('An account with this e-mail already exists.'))
1996

1997

1998
def do_email_change_request(user, new_email, activation_key=None):
1999 2000 2001 2002 2003
    """
    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.
    """
2004
    pec_list = PendingEmailChange.objects.filter(user=user)
Matthew Mongeau committed
2005
    if len(pec_list) == 0:
2006 2007
        pec = PendingEmailChange()
        pec.user = user
2008
    else:
2009 2010
        pec = pec_list[0]

2011 2012 2013 2014
    # if activation_key is not passing as an argument, generate a random key
    if not activation_key:
        activation_key = uuid.uuid4().hex

2015 2016
    pec.new_email = new_email
    pec.activation_key = activation_key
2017 2018
    pec.save()

2019 2020 2021 2022 2023
    context = {
        'key': pec.activation_key,
        'old_email': user.email,
        'new_email': pec.new_email
    }
2024

2025
    subject = render_to_string('emails/email_change_subject.txt', context)
2026
    subject = ''.join(subject.splitlines())
2027

2028 2029
    message = render_to_string('emails/email_change.txt', context)

2030
    from_address = microsite.get_value(
2031 2032 2033
        'email_from_address',
        settings.DEFAULT_FROM_EMAIL
    )
2034
    try:
2035
        mail.send_mail(subject, message, from_address, [pec.new_email])
2036
    except Exception:  # pylint: disable=broad-except
2037
        log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
2038
        raise ValueError(_('Unable to send email activation link. Please try again later.'))
2039

2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052
    # 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,
        }
    )

2053 2054

@ensure_csrf_cookie
2055
@transaction.commit_manually
Sarina Canelake committed
2056 2057 2058
def confirm_email_change(request, key):  # pylint: disable=unused-argument
    """
    User requested a new e-mail. This is called when the activation
2059
    link is clicked. We confirm with the old e-mail, and update
2060
    """
2061
    try:
2062 2063 2064
        try:
            pec = PendingEmailChange.objects.get(activation_key=key)
        except PendingEmailChange.DoesNotExist:
2065
            response = render_to_response("invalid_email_key.html", {})
2066
            transaction.rollback()
2067
            return response
2068 2069 2070 2071 2072 2073

        user = pec.user
        address_context = {
            'old_email': user.email,
            'new_email': pec.new_email
        }
2074

2075
        if len(User.objects.filter(email=pec.new_email)) != 0:
2076
            response = render_to_response("email_exists.html", {})
2077
            transaction.rollback()
2078
            return response
2079 2080 2081 2082

        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)
Sarina Canelake committed
2083 2084
        u_prof = UserProfile.objects.get(user=user)
        meta = u_prof.get_meta()
2085 2086
        if 'old_emails' not in meta:
            meta['old_emails'] = []
2087
        meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
Sarina Canelake committed
2088 2089
        u_prof.set_meta(meta)
        u_prof.save()
2090 2091 2092
        # Send it to the old email...
        try:
            user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
Sarina Canelake committed
2093
        except Exception:    # pylint: disable=broad-except
2094
            log.warning('Unable to send confirmation email to old address', exc_info=True)
2095 2096 2097
            response = render_to_response("email_change_failed.html", {'email': user.email})
            transaction.rollback()
            return response
2098

2099 2100 2101 2102 2103 2104
        user.email = pec.new_email
        user.save()
        pec.delete()
        # And send it to the new email...
        try:
            user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
Sarina Canelake committed
2105
        except Exception:  # pylint: disable=broad-except
2106
            log.warning('Unable to send confirmation email to new address', exc_info=True)
2107 2108 2109
            response = render_to_response("email_change_failed.html", {'email': pec.new_email})
            transaction.rollback()
            return response
2110

2111
        response = render_to_response("email_change_successful.html", address_context)
2112
        transaction.commit()
2113
        return response
Sarina Canelake committed
2114
    except Exception:  # pylint: disable=broad-except
2115 2116 2117
        # If we get an unexpected exception, be sure to rollback the transaction
        transaction.rollback()
        raise
2118

2119

2120
# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
2121
@ensure_csrf_cookie
2122
@require_POST
2123
def change_name_request(request):
2124
    """ Log a request for a new name. """
2125
    if not request.user.is_authenticated():
2126
        raise Http404
Matthew Mongeau committed
2127 2128

    try:
2129
        pnc = PendingNameChange.objects.get(user=request.user.id)
2130
    except PendingNameChange.DoesNotExist:
2131 2132
        pnc = PendingNameChange()
    pnc.user = request.user
2133
    pnc.new_name = request.POST['new_name'].strip()
2134
    pnc.rationale = request.POST['rationale']
2135
    if len(pnc.new_name) < 2:
David Baumgold committed
2136 2137 2138 2139
        return JsonResponse({
            "success": False,
            "error": _('Name required'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
2140
    pnc.save()
2141 2142 2143 2144 2145

    # The following automatically accepts name change requests. Remove this to
    # go back to the old system where it gets queued up for admin approval.
    accept_name_change_by_id(pnc.id)

David Baumgold committed
2146
    return JsonResponse({"success": True})
2147

2148

2149
# TODO: DELETE AFTER NEW ACCOUNT PAGE DONE
Sarina Canelake committed
2150 2151 2152 2153 2154
def accept_name_change_by_id(uid):
    """
    Accepts the pending name change request for the user represented
    by user id `uid`.
    """
Matthew Mongeau committed
2155
    try:
Sarina Canelake committed
2156
        pnc = PendingNameChange.objects.get(id=uid)
Matthew Mongeau committed
2157
    except PendingNameChange.DoesNotExist:
David Baumgold committed
2158 2159 2160 2161
        return JsonResponse({
            "success": False,
            "error": _('Invalid ID'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
2162

Sarina Canelake committed
2163 2164
    user = pnc.user
    u_prof = UserProfile.objects.get(user=user)
2165 2166

    # Save old name
Sarina Canelake committed
2167
    meta = u_prof.get_meta()
2168 2169
    if 'old_names' not in meta:
        meta['old_names'] = []
2170
    meta['old_names'].append([u_prof.name, pnc.rationale, datetime.datetime.now(UTC).isoformat()])
Sarina Canelake committed
2171
    u_prof.set_meta(meta)
2172

Sarina Canelake committed
2173 2174
    u_prof.name = pnc.new_name
    u_prof.save()
2175 2176
    pnc.delete()

David Baumgold committed
2177
    return JsonResponse({"success": True})
2178 2179


2180 2181
@require_POST
@login_required
2182 2183 2184 2185 2186 2187
@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")
2188
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2189 2190
    receive_emails = request.POST.get("receive_emails")
    if receive_emails:
2191
        optout_object = Optout.objects.filter(user=user, course_id=course_key)
2192 2193
        if optout_object:
            optout_object.delete()
2194 2195 2196 2197 2198 2199
        log.info(
            u"User %s (%s) opted in to receive emails from course %s",
            user.username,
            user.email,
            course_id
        )
2200 2201
        track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard')
    else:
2202
        Optout.objects.get_or_create(user=user, course_id=course_key)
2203 2204 2205 2206 2207 2208
        log.info(
            u"User %s (%s) opted out of receiving emails from course %s",
            user.username,
            user.email,
            course_id
        )
2209 2210
        track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')

David Baumgold committed
2211
    return JsonResponse({"success": True})