views.py 90.2 KB
Newer Older
1 2 3
"""
Student Views
"""
4
import datetime
5
import logging
6
import uuid
7
import json
8
import warnings
9
from collections import defaultdict
10
from pytz import UTC
11
from requests import HTTPError
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 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
from django.db.models.signals import post_save
from django.dispatch import receiver
36 37
from django.template.response import TemplateResponse

Diana Huang committed
38 39
from ratelimitbackend.exceptions import RateLimitException

40 41 42

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

David Baumgold committed
45
from edxmako.shortcuts import render_to_response, render_to_string
Piotr Mitros committed
46

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

57
from verify_student.models import SoftwareSecurePhotoVerification  # pylint: disable=import-error
Victor Shnayder committed
58
from certificates.models import CertificateStatuses, certificate_status_for_student
59 60 61 62
from certificates.api import (  # pylint: disable=import-error
    get_certificate_url,
    has_html_certificates_enabled,
)
63

64
from xmodule.modulestore.django import modulestore
65
from opaque_keys import InvalidKeyError
66
from opaque_keys.edx.keys import CourseKey
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
from lang_pref import LANGUAGE_KEY
87 88 89

import track.views

90
import dogstats_wrapper as dog_stats_api
91

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

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

105
import third_party_auth
106
from third_party_auth import pipeline, provider
107
from student.helpers import (
Will Daly committed
108
    check_verify_status_by_course,
109
    auth_pipeline_urls, get_next_url_for_login_page
110
)
Will Daly committed
111
from student.cookies import set_logged_in_cookies, delete_logged_in_cookies
112
from student.models import anonymous_id_for_user
113
from shoppingcart.models import DonationConfiguration, CourseRegistrationCode
114

115 116
from embargo import api as embargo_api

117 118 119
import analytics
from eventtracking import tracker

120 121 122 123 124 125
# 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

126

127
log = logging.getLogger("edx.student")
128
AUDIT_LOG = logging.getLogger("audit")
129
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display')  # pylint: disable=invalid-name
130 131
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'

Sarina Canelake committed
132

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

141

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

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

161
    courses = get_courses(user, domain=domain)
162 163 164 165 166
    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)
167

168
    context = {'courses': courses}
Chris Dodge committed
169

ichuang committed
170 171
    context.update(extra_context)
    return render_to_response('index.html', context)
172

173

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


182
def cert_info(user, course_overview, course_mode):
183 184
    """
    Get the certificate info needed to render the dashboard section for the given
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
    student and course.

    Arguments:
        user (User): A user.
        course_overview (CourseOverview): A course.
        course_mode (str): The enrollment mode (honor, verified, audit, etc.)

    Returns:
        dict: A dictionary with keys:
            'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted'
            'show_download_url': bool
            'download_url': url, only present if show_download_url is True
            'show_disabled_download_button': bool -- true if state is 'generating'
            'show_survey_button': bool
            'survey_url': url, only if show_survey_button is True
            'grade': if status is not 'processing'
201
    """
202
    if not course_overview.may_certify():
203
        return {}
204 205 206 207 208 209
    return _cert_info(
        user,
        course_overview,
        certificate_status_for_student(user, course_overview.id),
        course_mode
    )
210

Calen Pennington committed
211

212
def reverification_info(statuses):
Julia Hansbrough committed
213
    """
214
    Returns reverification-related information for *all* of user's enrollments whose
215
    reverification status is in statuses.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230

    Args:
        statuses (list): a list of reverification statuses we want information for
            example: ["must_reverify", "denied"]

    Returns:
        dictionary of lists: dictionary with one key per status, e.g.
            dict["must_reverify"] = []
            dict["must_reverify"] = [some information]
    """
    reverifications = defaultdict(list)

    # Sort the data by the reverification_end_date
    for status in statuses:
        if reverifications[status]:
231
            reverifications[status].sort(key=lambda x: x.date)
232 233 234
    return reverifications


235
def get_course_enrollments(user, org_to_include, orgs_to_exclude):
236
    """
237 238 239 240 241 242 243 244 245 246 247 248
    Given a user, return a filtered set of his or her course enrollments.

    Arguments:
        user (User): the user in question.
        org_to_include (str): for use in Microsites. If not None, ONLY courses
            of this org will be returned.
        orgs_to_exclude (list[str]): If org_to_include is not None, this
            argument is ignored. Else, courses of this org will be excluded.

    Returns:
        generator[CourseEnrollment]: a sequence of enrollments to be displayed
        on the user's dashboard.
249
    """
Julia Hansbrough committed
250 251
    for enrollment in CourseEnrollment.enrollments_for_user(user):

252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
        # If the course is missing or broken, log an error and skip it.
        course_overview = enrollment.course_overview
        if not course_overview:
            log.error(
                "User %s enrolled in broken or non-existent course %s",
                user.username,
                enrollment.course_id
            )
            continue

        # If we are in a Microsite, then filter out anything that is not
        # attributed (by ORG) to that Microsite.
        if org_to_include and course_overview.location.org != org_to_include:
            continue

        # Conversely, if we are not in a Microsite, then filter out any enrollments
        # with courses attributed (by ORG) to Microsites.
        elif course_overview.location.org in orgs_to_exclude:
            continue
Julia Hansbrough committed
271

272 273 274 275 276 277
        # Else, include the enrollment.
        else:
            yield enrollment


def _cert_info(user, course_overview, cert_status, course_mode):  # pylint: disable=unused-argument
278 279
    """
    Implements the logic for cert_info -- split out for testing.
280 281 282 283 284

    Arguments:
        user (User): A user.
        course_overview (CourseOverview): A course.
        course_mode (str): The enrollment mode (honor, verified, audit, etc.)
285
    """
286 287 288 289 290 291 292 293 294
    # 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',
    }

295
    default_status = 'processing'
296 297 298 299

    default_info = {'status': default_status,
                    'show_disabled_download_button': False,
                    'show_download_url': False,
300 301
                    'show_survey_button': False,
                    }
302

303
    if cert_status is None:
304
        return default_info
305

306 307
    is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing')

308
    if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status:
309 310
        return None

311 312
    status = template_state.get(cert_status['status'], default_status)

Sarina Canelake committed
313 314 315 316
    status_dict = {
        'status': status,
        'show_download_url': status == 'ready',
        'show_disabled_download_button': status == 'generating',
317 318
        'mode': cert_status.get('mode', None),
        'linked_in_url': None
Sarina Canelake committed
319
    }
320

321
    if (status in ('generating', 'ready', 'notpassing', 'restricted') and
322
            course_overview.end_of_course_survey_url is not None):
Sarina Canelake committed
323
        status_dict.update({
324
            'show_survey_button': True,
325
            'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
326
    else:
Sarina Canelake committed
327
        status_dict['show_survey_button'] = False
328

329
    if status == 'ready':
330
        # showing the certificate web view button if certificate is ready state and feature flags are enabled.
331 332
        if has_html_certificates_enabled(course_overview.id, course_overview):
            if course_overview.has_any_active_web_certificate:
333 334
                certificate_url = get_certificate_url(
                    user_id=user.id,
335
                    course_id=unicode(course_overview.id),
336
                )
337 338
                status_dict.update({
                    'show_cert_web_view': True,
339
                    'cert_web_view_url': u'{url}'.format(url=certificate_url)
340 341 342 343 344
                })
            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:
345 346 347
            log.warning(
                u"User %s has a downloadable cert for %s, but no download url",
                user.username,
348
                course_overview.id
349
            )
350
            return default_info
351
        else:
Sarina Canelake committed
352
            status_dict['download_url'] = cert_status['download_url']
353

354 355 356 357 358 359
            # 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(
360 361
                    course_overview.id,
                    course_overview.display_name,
362 363 364
                    cert_status.get('mode'),
                    cert_status['download_url']
                )
365

366
    if status in ('generating', 'ready', 'notpassing', 'restricted'):
367 368 369 370
        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.
371
            return default_info
372
        else:
Sarina Canelake committed
373
            status_dict['grade'] = cert_status['grade']
374

Sarina Canelake committed
375
    return status_dict
376

Calen Pennington committed
377

378
@ensure_csrf_cookie
379
def signin_user(request):
380 381 382 383
    """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
384 385
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
386
    if request.user.is_authenticated():
387 388 389 390 391 392
        return redirect(redirect_to)

    third_party_auth_error = None
    for msg in messages.get_messages(request):
        if msg.extra_tags.split()[0] == "social-auth":
            # msg may or may not be translated. Try translating [again] in case we are able to:
393
            third_party_auth_error = _(unicode(msg))  # pylint: disable=translation-of-non-string
394
            break
395

396
    context = {
397
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
398 399 400 401
        # 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',
402
        'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
403
        'platform_name': microsite.get_value(
404 405 406
            'platform_name',
            settings.PLATFORM_NAME
        ),
407
        'third_party_auth_error': third_party_auth_error
408
    }
409

John Jarvis committed
410
    return render_to_response('login.html', context)
John Jarvis committed
411

412

413
@ensure_csrf_cookie
414
def register_user(request, extra_context=None):
415
    """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
416 417
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
418
    if request.user.is_authenticated():
419
        return redirect(redirect_to)
420 421 422 423

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

425
    context = {
426
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
427 428 429
        'email': '',
        'name': '',
        'running_pipeline': None,
430
        'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
431
        'platform_name': microsite.get_value(
432 433 434
            'platform_name',
            settings.PLATFORM_NAME
        ),
435 436
        'selected_provider': '',
        'username': '',
437
    }
438

439 440
    if extra_context is not None:
        context.update(extra_context)
441

442
    if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
443
        return render_to_response('register-shib.html', context)
444 445 446

    # If third-party auth is enabled, prepopulate the form with data from the
    # selected provider.
447
    if third_party_auth.is_enabled() and pipeline.running(request):
448
        running_pipeline = pipeline.get(request)
449
        current_provider = provider.Registry.get_from_pipeline(running_pipeline)
450 451 452 453 454
        if current_provider is not None:
            overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
            overrides['running_pipeline'] = running_pipeline
            overrides['selected_provider'] = current_provider.name
            context.update(overrides)
455

John Jarvis committed
456 457 458
    return render_to_response('register.html', context)


Will Daly committed
459
def complete_course_mode_info(course_id, enrollment, modes=None):
460 461 462 463 464 465 466 467
    """
    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
468 469 470
    if modes is None:
        modes = CourseMode.modes_for_course_dict(course_id)

471
    mode_info = {'show_upsell': False, 'days_for_upsell': None}
472 473 474
    # we want to know if the user is already enrolled as verified or credit and
    # if verified is an option.
    if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES:
475 476
        mode_info['show_upsell'] = True
        # if there is an expiration date, find out how long from now it is
477
        if modes['verified'].expiration_datetime:
478
            today = datetime.datetime.now(UTC).date()
479
            mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
480 481 482 483

    return mode_info


484 485 486 487
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:
488 489 490
        # 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
491 492
        if redeemed_registration.invoice_item:
            if not getattr(redeemed_registration.invoice_item.invoice, 'is_valid'):
493 494 495
                blocked = True
                # disabling email notifications for unpaid registration courses
                Optout.objects.get_or_create(user=request.user, course_id=course_key)
496 497 498 499 500 501
                log.info(
                    u"User %s (%s) opted out of receiving emails from course %s",
                    request.user.username,
                    request.user.email,
                    course_key
                )
502 503
                track.views.server_track(request, "change-email1-settings", {"receive_emails": "no", "course": course_key.to_deprecated_string()}, page='dashboard')
                break
504 505 506

    return blocked

Sarina Canelake committed
507

508
@login_required
Matthew Mongeau committed
509 510
@ensure_csrf_cookie
def dashboard(request):
511 512
    user = request.user

513 514
    platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)

515 516
    # for microsites, we want to filter and only show enrollments for courses within
    # the microsites 'ORG'
517
    course_org_filter = microsite.get_value('course_org_filter')
518 519 520

    # Let's filter out any courses in an "org" that has been declared to be
    # in a Microsite
521
    org_filter_out_set = microsite.get_all_orgs()
522 523 524 525 526

    # 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
527 528 529
    # 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.
530
    course_enrollments = list(get_course_enrollments(user, course_org_filter, org_filter_out_set))
531

532
    # sort the enrollment pairs by the enrollment date
533
    course_enrollments.sort(key=lambda x: x.created, reverse=True)
534

Will Daly committed
535
    # Retrieve the course modes for each course
536
    enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
537
    __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
Will Daly committed
538
    course_modes_by_course = {
539 540 541 542 543
        course_id: {
            mode.slug: mode
            for mode in modes
        }
        for course_id, modes in unexpired_course_modes.iteritems()
Will Daly committed
544 545 546 547 548
    }

    # 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(
549
        course_enrollments, course_modes_by_course
Will Daly committed
550
    )
551

552
    course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
553

554 555
    message = ""
    if not user.is_active:
556 557
        message = render_to_string(
            'registration/activate_account_notice.html',
558
            {'email': user.email, 'platform_name': platform_name}
559
        )
560

561 562
    # Global staff can see what courses errored on their dashboard
    staff_access = False
Victor Shnayder committed
563
    errored_courses = {}
564
    if has_access(user, 'staff', 'global'):
565 566 567 568
        # Show any courses that errored on load
        staff_access = True
        errored_courses = modulestore().get_errored_courses()

569
    show_courseware_links_for = frozenset(
570 571 572
        enrollment.course_id for enrollment in course_enrollments
        if has_access(request.user, 'load', enrollment.course_overview)
        and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
573
    )
574

Will Daly committed
575 576 577 578
    # 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 = {
579 580 581
        enrollment.course_id: complete_course_mode_info(
            enrollment.course_id, enrollment,
            modes=course_modes_by_course[enrollment.course_id]
Will Daly committed
582
        )
583
        for enrollment in course_enrollments
Will Daly committed
584 585
    }

586 587 588 589 590 591 592 593 594 595 596 597 598 599
    # 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.
600
    verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
Will Daly committed
601
    cert_statuses = {
602 603
        enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
        for enrollment in course_enrollments
Will Daly committed
604
    }
Victor Shnayder committed
605

606
    # only show email settings for Mongo course and when bulk email is turned on
607
    show_email_settings_for = frozenset(
608
        enrollment.course_id for enrollment in course_enrollments if (
609
            settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and
610 611
            modulestore().get_modulestore_type(enrollment.course_id) != ModuleStoreEnum.Type.xml and
            CourseAuthorization.instructor_email_enabled(enrollment.course_id)
612 613
        )
    )
614

615
    # Verification Attempts
616
    # Used to generate the "you must reverify for course x" banner
617
    verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user)
618

Julia Hansbrough committed
619
    # Gets data for midcourse reverifications, if any are necessary or have failed
620
    statuses = ["approved", "denied", "pending", "must_reverify"]
621
    reverifications = reverification_info(statuses)
622

623 624 625 626
    show_refund_option_for = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.refundable()
    )
627

628 629 630 631 632 633 634 635 636 637 638
    block_courses = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if is_course_blocked(
            request,
            CourseRegistrationCode.objects.filter(
                course_id=enrollment.course_id,
                registrationcoderedemption__redeemed_by=request.user
            ),
            enrollment.course_id
        )
    )
639

640 641 642 643
    enrolled_courses_either_paid = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.is_paid_course()
    )
644

645 646 647 648
    # 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"])

649 650 651
    # 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)

652
    # get list of courses having pre-requisites yet to be completed
653 654 655 656
    courses_having_prerequisites = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.course_overview.pre_requisite_courses
    )
657 658
    courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)

659
    if 'notlive' in request.GET:
660 661
        redirect_message = _("The course you are looking for does not start until {date}.").format(
            date=request.GET['notlive']
662 663 664 665
        )
    else:
        redirect_message = ''

666
    context = {
667
        'enrollment_message': enrollment_message,
668
        'redirect_message': redirect_message,
669
        'course_enrollments': course_enrollments,
670 671 672 673 674
        '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
675
        'all_course_modes': course_mode_info,
676
        'cert_statuses': cert_statuses,
677
        'credit_statuses': _credit_statuses(user, course_enrollments),
678 679 680
        'show_email_settings_for': show_email_settings_for,
        'reverifications': reverifications,
        'verification_status': verification_status,
681
        'verification_status_by_course': verify_status_by_course,
682 683
        'verification_msg': verification_msg,
        'show_refund_option_for': show_refund_option_for,
684
        'block_courses': block_courses,
685 686
        'denied_banner': denied_banner,
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
687 688
        'user': user,
        'logout_url': reverse(logout_user),
689
        'platform_name': platform_name,
690
        'enrolled_courses_either_paid': enrolled_courses_either_paid,
691
        'provider_states': [],
692 693
        'order_history_list': order_history_list,
        'courses_requirements_not_met': courses_requirements_not_met,
694
        'nav_hidden': True,
695
    }
696

697
    return render_to_response('dashboard.html', context)
698 699


700 701 702
def _create_recent_enrollment_message(course_enrollments, course_modes):  # pylint: disable=invalid-name
    """
    Builds a recent course enrollment message.
703

704 705
    Constructs a new message template based on any recent course enrollments
    for the student.
706 707

    Args:
708
        course_enrollments (list[CourseEnrollment]): a list of course enrollments.
Will Daly committed
709
        course_modes (dict): Mapping of course ID's to course mode dictionaries.
710 711 712

    Returns:
        A string representing the HTML message output from the message template.
Will Daly committed
713
        None if there are no recently enrolled courses.
714 715

    """
716
    recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
Will Daly committed
717 718 719 720

    if recently_enrolled_courses:
        messages = [
            {
721 722 723
                "course_id": enrollment.course_overview.id,
                "course_name": enrollment.course_overview.display_name,
                "allow_donation": _allow_donation(course_modes, enrollment.course_overview.id, enrollment)
Will Daly committed
724
            }
725
            for enrollment in recently_enrolled_courses
Will Daly committed
726 727
        ]

728 729
        platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME)

730 731
        return render_to_string(
            'enrollment/course_enrollment_message.html',
732
            {'course_enrollment_messages': messages, 'platform_name': platform_name}
733 734 735
        )


736 737 738
def _get_recently_enrolled_courses(course_enrollments):
    """
    Given a list of enrollments, filter out all but recent enrollments.
739 740

    Args:
741
        course_enrollments (list[CourseEnrollment]): A list of course enrollments.
742 743

    Returns:
744
        list[CourseEnrollment]: A list of recent course enrollments.
745 746 747 748
    """
    seconds = DashboardConfiguration.current().recent_enrollment_time_delta
    time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
    return [
749
        enrollment for enrollment in course_enrollments
750 751 752 753 754 755
        # 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
    ]


756
def _allow_donation(course_modes, course_id, enrollment):
757 758 759 760 761 762 763
    """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.
764
        enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
765 766 767 768 769

    Returns:
        True if the course is allowing donations.

    """
770
    donations_enabled = DonationConfiguration.current().enabled
771
    return donations_enabled and enrollment.mode in course_modes[course_id] and course_modes[course_id][enrollment.mode].min_price == 0
772 773


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

777 778 779
    email_opt_in = request.POST.get('email_opt_in')
    if email_opt_in is not None:
        email_opt_in_boolean = email_opt_in == 'true'
780
        preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
781 782


783
def _credit_statuses(user, course_enrollments):
784 785 786 787 788 789 790 791 792 793 794 795 796 797 798
    """
    Retrieve the status for credit courses.

    A credit course is a course for which a user can purchased
    college credit.  The current flow is:

    1. User becomes eligible for credit (submits verifications, passes the course, etc.)
    2. User purchases credit from a particular credit provider.
    3. User requests credit from the provider, usually creating an account on the provider's site.
    4. The credit provider notifies us whether the user's request for credit has been accepted or rejected.

    The dashboard is responsible for communicating the user's state in this flow.

    Arguments:
        user (User): The currently logged-in user.
799 800
        course_enrollments (list[CourseEnrollment]): List of enrollments for the
            user.
801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816

    Returns: dict

    The returned dictionary has keys that are `CourseKey`s and values that
    are dictionaries with:

        * eligible (bool): True if the user is eligible for credit in this course.
        * deadline (datetime): The deadline for purchasing and requesting credit for this course.
        * purchased (bool): Whether the user has purchased credit for this course.
        * provider_name (string): The display name of the credit provider.
        * provider_status_url (string): A URL the user can visit to check on their credit request status.
        * request_status (string): Either "pending", "approved", or "rejected"
        * error (bool): If true, an unexpected error occurred when retrieving the credit status,
            so the user should contact the support team.

    Example:
817
    >>> _credit_statuses(user, course_enrollments)
818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833
    {
        CourseKey.from_string("edX/DemoX/Demo_Course"): {
            "course_key": "edX/DemoX/Demo_Course",
            "eligible": True,
            "deadline": 2015-11-23 00:00:00 UTC,
            "purchased": True,
            "provider_name": "Hogwarts",
            "provider_status_url": "http://example.com/status",
            "request_status": "pending",
            "error": False
        }
    }

    """
    from openedx.core.djangoapps.credit import api as credit_api

834 835 836 837
    # Feature flag off
    if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"):
        return {}

838 839 840 841 842 843
    request_status_by_course = {
        request["course_key"]: request["status"]
        for request in credit_api.get_credit_requests_for_user(user.username)
    }

    credit_enrollments = {
844 845
        enrollment.course_id: enrollment
        for enrollment in course_enrollments
846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868
        if enrollment.mode == "credit"
    }

    # When a user purchases credit in a course, the user's enrollment
    # mode is set to "credit" and an enrollment attribute is set
    # with the ID of the credit provider.  We retrieve *all* such attributes
    # here to minimize the number of database queries.
    purchased_credit_providers = {
        attribute.enrollment.course_id: attribute.value
        for attribute in CourseEnrollmentAttribute.objects.filter(
            namespace="credit",
            name="provider_id",
            enrollment__in=credit_enrollments.values()
        ).select_related("enrollment")
    }

    provider_info_by_id = {
        provider["id"]: provider
        for provider in credit_api.get_credit_providers()
    }

    statuses = {}
    for eligibility in credit_api.get_eligibilities_for_user(user.username):
869
        course_key = CourseKey.from_string(unicode(eligibility["course_key"]))
870 871 872 873 874 875 876
        status = {
            "course_key": unicode(course_key),
            "eligible": True,
            "deadline": eligibility["deadline"],
            "purchased": course_key in credit_enrollments,
            "provider_name": None,
            "provider_status_url": None,
877
            "provider_id": None,
878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900
            "request_status": request_status_by_course.get(course_key),
            "error": False,
        }

        # If the user has purchased credit, then include information about the credit
        # provider from which the user purchased credit.
        # We retrieve the provider's ID from the an "enrollment attribute" set on the user's
        # enrollment when the user's order for credit is fulfilled by the E-Commerce service.
        if status["purchased"]:
            provider_id = purchased_credit_providers.get(course_key)
            if provider_id is None:
                status["error"] = True
                log.error(
                    u"Could not find credit provider associated with credit enrollment "
                    u"for user %s in course %s.  The user will not be able to see his or her "
                    u"credit request status on the student dashboard.  This attribute should "
                    u"have been set when the user purchased credit in the course.",
                    user.id, course_key
                )
            else:
                provider_info = provider_info_by_id.get(provider_id, {})
                status["provider_name"] = provider_info.get("display_name")
                status["provider_status_url"] = provider_info.get("status_url")
901
                status["provider_id"] = provider_id
902 903 904 905 906 907

        statuses[course_key] = status

    return statuses


908
@require_POST
909
@commit_on_success_with_read_committed
910
def change_enrollment(request, check_access=True):
911 912 913 914 915 916 917 918 919 920 921 922
    """
    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
923 924
    happens. This function should only be called from an AJAX request, so
    the error messages in the responses should never actually be user-visible.
925 926 927 928 929

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

    Keyword Args:
930 931 932 933 934
        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.
935 936 937 938

    Returns:
        Response

939
    """
940
    # Get the user
941
    user = request.user
942

Julia Hansbrough committed
943 944 945 946
    # Ensure the user is authenticated
    if not user.is_authenticated():
        return HttpResponseForbidden()

947
    # Ensure we received a course_id
948
    action = request.POST.get("enrollment_action")
949
    if 'course_id' not in request.POST:
David Baumgold committed
950
        return HttpResponseBadRequest(_("Course id not specified"))
951

Julia Hansbrough committed
952 953 954 955
    try:
        course_id = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get("course_id"))
    except InvalidKeyError:
        log.warning(
956 957 958 959
            u"User %s tried to %s with invalid course id: %s",
            user.username,
            action,
            request.POST.get("course_id"),
Julia Hansbrough committed
960 961 962
        )
        return HttpResponseBadRequest(_("Invalid course id"))

963
    if action == "enroll":
Don Mitchell committed
964 965 966
        # 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):
967 968 969 970 971
            log.warning(
                u"User %s tried to enroll in non-existent course %s",
                user.username,
                course_id
            )
Don Mitchell committed
972 973
            return HttpResponseBadRequest(_("Course id is invalid"))

974 975
        # Record the user's email opt-in preference
        if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
976
            _update_email_opt_in(request, course_id.org)
977

978 979
        available_modes = CourseMode.modes_for_course_dict(course_id)

980 981 982 983 984 985 986 987 988 989 990
        # 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)

991 992 993
        # Check that auto enrollment is allowed for this course
        # (= the course is NOT behind a paywall)
        if CourseMode.can_auto_enroll(course_id):
994 995 996 997 998 999
            # 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".
1000
            try:
1001
                CourseEnrollment.enroll(user, course_id, check_access=check_access)
1002 1003
            except Exception:
                return HttpResponseBadRequest(_("Could not enroll"))
1004

1005 1006
        # If we have more than one course mode or professional ed is enabled,
        # then send the user to the choose your track page.
1007
        # (In the case of no-id-professional/professional ed, this will redirect to a page that
1008
        # funnels users directly into the verification / payment flow)
1009
        if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
1010 1011 1012 1013 1014 1015
            return HttpResponse(
                reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
            )

        # Otherwise, there is only one mode available (the default)
        return HttpResponse()
1016
    elif action == "unenroll":
1017
        if not CourseEnrollment.is_enrolled(user, course_id):
1018
            return HttpResponseBadRequest(_("You are not enrolled in this course"))
1019 1020
        CourseEnrollment.unenroll(user, course_id)
        return HttpResponse()
1021
    else:
David Baumgold committed
1022
        return HttpResponseBadRequest(_("Enrollment action is invalid"))
1023

1024

1025
# Need different levels of logging
1026
@ensure_csrf_cookie
1027
def login_user(request, error=""):  # pylint: disable=too-many-statements,unused-argument
1028
    """AJAX request to log in the user."""
1029

1030 1031 1032 1033 1034 1035
    backend_name = None
    email = None
    password = None
    redirect_url = None
    response = None
    running_pipeline = None
1036
    third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
1037 1038 1039
    third_party_auth_successful = False
    trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
    user = None
1040
    platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)
1041 1042 1043 1044 1045 1046 1047 1048 1049

    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']
1050 1051
        third_party_uid = running_pipeline['kwargs']['uid']
        requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
1052 1053

        try:
1054
            user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
1055 1056 1057 1058 1059
            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))
1060
            return HttpResponse(
1061
                _("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
1062
                    platform_name=platform_name, provider_name=requested_provider.name
Sarina Canelake committed
1063 1064 1065
                )
                + "<br/><br/>" +
                _("Use your {platform_name} username and password to log into {platform_name} below, "
1066
                  "and then link your {platform_name} account with {provider_name} from your dashboard.").format(
1067
                      platform_name=platform_name, provider_name=requested_provider.name
David Baumgold committed
1068
                )
Sarina Canelake committed
1069
                + "<br/><br/>" +
1070 1071
                _("If you don't have an {platform_name} account yet, "
                  "click <strong>Register</strong> at the top of the page.").format(
1072
                      platform_name=platform_name),
1073
                content_type="text/plain",
1074
                status=403
1075
            )
1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093

    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
1094

1095 1096 1097
    # 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.
1098
    if settings.FEATURES.get('AUTH_USE_SHIB') and user:
1099 1100
        try:
            eamap = ExternalAuthMap.objects.get(user=user)
1101
            if eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
David Baumgold committed
1102 1103 1104 1105
                return JsonResponse({
                    "success": False,
                    "redirect": reverse('shib-login'),
                })  # TODO: this should be status code 301  # pylint: disable=fixme
1106 1107
        except ExternalAuthMap.DoesNotExist:
            # This is actually the common case, logging in user without external linked login
1108
            AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
1109

1110
    # see if account has been locked out due to excessive login failures
1111 1112 1113
    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
1114 1115 1116 1117
            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
1118

1119
    # see if the user must reset his/her password due to any policy settings
1120
    if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup):
1121 1122 1123 1124
        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
1125
                       '"Forgot Password" link on this page to reset your password before logging in again.'),
1126 1127
        })  # TODO: this should be status code 403  # pylint: disable=fixme

Diana Huang committed
1128 1129 1130 1131
    # 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 ""
1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142

    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
1143
    if user is None:
1144 1145 1146 1147
        # 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
1148 1149 1150
        # 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 != "":
1151 1152 1153 1154 1155
            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
1156 1157 1158 1159
        return JsonResponse({
            "success": False,
            "value": _('Email or password is incorrect.'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
1160

1161 1162 1163 1164
    # successful login, clear failed login attempts counters, if applicable
    if LoginFailures.is_feature_enabled():
        LoginFailures.clear_lockout_counter(user)

1165
    # Track the user's sign in
1166
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1167
        tracking_context = tracker.get_tracker().resolve_context()
1168
        analytics.identify(user.id, {
1169
            'email': email,
1170
            'username': username
1171 1172 1173 1174 1175 1176 1177
        })

        analytics.track(
            user.id,
            "edx.bi.user.account.authenticated",
            {
                'category': "conversion",
1178
                'label': request.POST.get('course_id'),
Julia Hansbrough committed
1179
                'provider': None
1180 1181 1182
            },
            context={
                'Google Analytics': {
Sarina Canelake committed
1183
                    'clientId': tracking_context.get('client_id')
1184 1185 1186 1187
                }
            }
        )

Piotr Mitros committed
1188
    if user is not None and user.is_active:
1189
        try:
1190 1191
            # We do not log here, because we have a handler registered
            # to perform logging on successful logins.
1192
            login(request, user)
1193
            if request.POST.get('remember') == 'true':
1194
                request.session.set_expiry(604800)
1195 1196 1197
                log.debug("Setting user session to never expire")
            else:
                request.session.set_expiry(0)
Sarina Canelake committed
1198
        except Exception as exc:  # pylint: disable=broad-except
1199
            AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
1200
            log.critical("Login failed - Could not create session. Is memcached running?")
Sarina Canelake committed
1201
            log.exception(exc)
1202
            raise
1203

1204
        redirect_url = None  # The AJAX method calling should know the default destination upon success
1205 1206 1207
        if third_party_auth_successful:
            redirect_url = pipeline.get_complete_url(backend_name)

David Baumgold committed
1208 1209 1210 1211
        response = JsonResponse({
            "success": True,
            "redirect_url": redirect_url,
        })
1212

1213 1214
        # Ensure that the external marketing site can
        # detect that the user is logged in.
Will Daly committed
1215
        return set_logged_in_cookies(request, response, user)
Victor Shnayder committed
1216

1217 1218 1219 1220
    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
1221

1222
    reactivation_email_for_user(user)
1223
    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
1224 1225 1226 1227
    return JsonResponse({
        "success": False,
        "value": not_activated_msg,
    })  # TODO: this should be status code 400  # pylint: disable=fixme
Piotr Mitros committed
1228

1229

1230
@csrf_exempt
1231 1232 1233 1234 1235 1236 1237 1238
@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.
    """
1239 1240
    warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)

1241
    backend = request.backend
1242 1243 1244
    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
1245
            request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
1246 1247 1248
            user = None
            try:
                user = backend.do_auth(request.POST["access_token"])
1249
            except (HTTPError, AuthException):
1250 1251 1252
                pass
            # do_auth can return a non-User object if it fails
            if user and isinstance(user, User):
1253
                login(request, user)
1254 1255 1256 1257 1258 1259 1260 1261 1262
                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

1263

1264
@ensure_csrf_cookie
Piotr Mitros committed
1265
def logout_user(request):
1266
    """
1267 1268 1269
    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
1270
    """
1271 1272
    # We do not log here, because we have a handler registered
    # to perform logging on successful logouts.
Piotr Mitros committed
1273
    logout(request)
1274
    if settings.FEATURES.get('AUTH_USE_CAS'):
1275 1276 1277 1278
        target = reverse('cas-logout')
    else:
        target = '/'
    response = redirect(target)
Will Daly committed
1279 1280

    delete_logged_in_cookies(response)
1281
    return response
Piotr Mitros committed
1282

David Baumgold committed
1283

1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309
@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)

1310

1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323
@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() == '':
1324
        context['message'] = _('Please enter a username')
1325 1326 1327 1328
        return JsonResponse(context, status=400)

    account_action = request.POST.get('account_action')
    if account_action is None:
1329
        context['message'] = _('Please choose an option')
1330 1331 1332 1333 1334 1335
        return JsonResponse(context, status=400)

    username = username.strip()
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
1336
        context['message'] = _("User with username {} does not exist").format(username)
1337 1338
        return JsonResponse(context, status=400)
    else:
1339
        user_account, _success = UserStanding.objects.get_or_create(
1340 1341 1342 1343
            user=user, defaults={'changed_by': request.user},
        )
        if account_action == 'disable':
            user_account.account_status = UserStanding.ACCOUNT_DISABLED
1344
            context['message'] = _("Successfully disabled {}'s account").format(username)
1345
            log.info(u"%s disabled %s's account", request.user, username)
1346 1347
        elif account_action == 'reenable':
            user_account.account_status = UserStanding.ACCOUNT_ENABLED
1348
            context['message'] = _("Successfully reenabled {}'s account").format(username)
1349
            log.info(u"%s reenabled %s's account", request.user, username)
1350
        else:
1351
            context['message'] = _("Unexpected account status")
1352 1353
            return JsonResponse(context, status=400)
        user_account.changed_by = request.user
1354
        user_account.standing_last_changed_at = datetime.datetime.now(UTC)
1355 1356 1357 1358
        user_account.save()

    return JsonResponse(context)

1359

1360
@login_required
1361
@ensure_csrf_cookie
1362
def change_setting(request):
1363
    """JSON call to change a profile setting: Right now, location"""
1364
    # TODO (vshnayder): location is no longer used
Sarina Canelake committed
1365
    u_prof = UserProfile.objects.get(user=request.user)  # request.user.profile_cache
Piotr Mitros committed
1366
    if 'location' in request.POST:
Sarina Canelake committed
1367 1368
        u_prof.location = request.POST['location']
    u_prof.save()
1369

David Baumgold committed
1370 1371
    return JsonResponse({
        "success": True,
Sarina Canelake committed
1372
        "location": u_prof.location,
David Baumgold committed
1373
    })
1374

Calen Pennington committed
1375

1376 1377 1378 1379 1380
class AccountValidationError(Exception):
    def __init__(self, message, field):
        super(AccountValidationError, self).__init__(message)
        self.field = field

1381 1382

@receiver(post_save, sender=User)
1383
def user_signup_handler(sender, **kwargs):  # pylint: disable=unused-argument
1384 1385 1386 1387 1388 1389 1390
    """
    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:
1391
            user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
1392 1393 1394 1395
            user_signup_source.save()
            log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))


1396
def _do_create_account(form):
1397 1398 1399 1400 1401 1402 1403 1404
    """
    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.
    """
1405 1406 1407 1408 1409 1410 1411 1412 1413
    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"])
1414
    registration = Registration()
1415

1416 1417 1418
    # 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:
1419
        user.save()
1420 1421
    except IntegrityError:
        # Figure out the cause of the integrity error
1422
        if len(User.objects.filter(username=user.username)) > 0:
1423
            raise AccountValidationError(
1424
                _("An account with the Public Username '{username}' already exists.").format(username=user.username),
1425
                field="username"
Sarina Canelake committed
1426
            )
1427
        elif len(User.objects.filter(email=user.email)) > 0:
1428
            raise AccountValidationError(
1429
                _("An account with the Email '{email}' already exists.").format(email=user.email),
1430
                field="email"
Sarina Canelake committed
1431
            )
1432 1433
        else:
            raise
1434

1435 1436 1437 1438 1439
    # 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)

1440
    registration.register(user)
1441

1442 1443 1444 1445 1446 1447 1448 1449 1450
    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
1451 1452
    if extended_profile:
        profile.meta = json.dumps(extended_profile)
1453
    try:
1454
        profile.save()
Sarina Canelake committed
1455
    except Exception:  # pylint: disable=broad-except
David Baumgold committed
1456
        log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
1457
        raise
1458

1459
    return (user, profile, registration)
1460

1461

1462
def create_account_with_params(request, params):
1463
    """
1464 1465 1466 1467 1468
    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
1469

1470
    Does not return anything.
Matthew Mongeau committed
1471

1472 1473 1474
    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.
1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487

    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").
1488 1489 1490 1491
    """
    # 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())
1492 1493 1494 1495 1496 1497

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

1499 1500 1501 1502 1503 1504 1505 1506
    # 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)):
1507
        params["password"] = pipeline.make_random_password()
1508

ichuang committed
1509 1510
    # 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
1511
    # unless originally we didn't get a valid email or name from the external auth
1512
    # TODO: We do not check whether these values meet all necessary criteria, such as email length
Sarina Canelake committed
1513 1514
    do_external_auth = 'ExternalAuthMap' in request.session
    if do_external_auth:
ichuang committed
1515
        eamap = request.session['ExternalAuthMap']
1516 1517
        try:
            validate_email(eamap.external_email)
1518
            params["email"] = eamap.external_email
1519
        except ValidationError:
1520 1521 1522 1523 1524
            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
1525

1526 1527 1528 1529 1530
    extended_profile_fields = microsite.get_value('extended_profile_fields', [])
    enforce_password_policy = (
        settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
        not do_external_auth
    )
1531
    # Can't have terms of service for certain SHIB users, like at Stanford
1532 1533 1534
    tos_required = (
        not settings.FEATURES.get("AUTH_USE_SHIB") or
        not settings.FEATURES.get("SHIB_DISABLE_TOS") or
Sarina Canelake committed
1535
        not do_external_auth or
1536 1537 1538 1539
        not eamap.external_domain.startswith(
            external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
        )
    )
1540

1541
    form = AccountCreationForm(
1542
        data=params,
1543 1544 1545 1546
        extra_fields=extra_fields,
        extended_profile_fields=extended_profile_fields,
        enforce_username_neq_password=True,
        enforce_password_policy=enforce_password_policy,
1547
        tos_required=tos_required,
1548
    )
1549

1550
    # Perform operations within a transaction that are critical to account creation
1551
    with transaction.commit_on_success():
1552 1553 1554
        # first, create the account
        (user, profile, registration) = _do_create_account(form)

1555 1556
        # next, link the account with social auth, if provided via the API.
        # (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
1557
        if should_link_with_social_auth:
1558 1559 1560 1561
            backend_name = params['provider']
            request.social_strategy = social_utils.load_strategy(request)
            redirect_uri = reverse('social:complete', args=(backend_name, ))
            request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri)
1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574
            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:
1575
                pipeline_user = request.backend.do_auth(social_access_token, user=user)
1576 1577 1578 1579 1580 1581 1582 1583
            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]})
1584

1585 1586 1587
    # Perform operations that are non-critical parts of account creation
    preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())

1588 1589 1590 1591 1592 1593
    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))

1594
    dog_stats_api.increment("common.student.account_created")
1595

1596 1597 1598 1599 1600 1601 1602
    # If the user is registering via 3rd party auth, track which provider they use
    third_party_provider = None
    running_pipeline = None
    if third_party_auth.is_enabled() and pipeline.running(request):
        running_pipeline = pipeline.get(request)
        third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)

1603
    # Track the user's registration
1604
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1605
        tracking_context = tracker.get_tracker().resolve_context()
1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627
        identity_args = [
            user.id,  # pylint: disable=no-member
            {
                'email': user.email,
                'username': user.username,
                'name': profile.name,
                'age': profile.age,
                'education': profile.level_of_education_display,
                'address': profile.mailing_address,
                'gender': profile.gender_display,
                'country': profile.country,
            }
        ]

        if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'):
            identity_args.append({
                "MailChimp": {
                    "listId": settings.MAILCHIMP_NEW_USER_LIST_ID
                }
            })

        analytics.identify(*identity_args)
1628 1629 1630

        analytics.track(
            user.id,
Sarina Canelake committed
1631
            "edx.bi.user.account.registered",
1632
            {
Julia Hansbrough committed
1633
                'category': 'conversion',
1634
                'label': params.get('course_id'),
1635
                'provider': third_party_provider.name if third_party_provider else None
1636 1637 1638
            },
            context={
                'Google Analytics': {
Sarina Canelake committed
1639
                    'clientId': tracking_context.get('client_id')
1640 1641 1642 1643
                }
            }
        )

1644 1645
    create_comments_service_user(user)

1646 1647 1648 1649 1650 1651
    # 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.
1652
    # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
1653 1654 1655 1656 1657
    #
    # 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).
1658
    send_email = (
1659
        not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
1660
        not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
1661 1662 1663 1664 1665
        not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and
        not (
            third_party_provider and third_party_provider.skip_email_verification and
            user.email == running_pipeline['kwargs'].get('details', {}).get('email')
        )
1666 1667
    )
    if send_email:
1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678
        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)

1679
        from_address = microsite.get_value(
1680 1681 1682
            'email_from_address',
            settings.DEFAULT_FROM_EMAIL
        )
1683
        try:
1684 1685
            if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
                dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
1686 1687
                message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
                           '-' * 80 + '\n\n' + message)
1688
                mail.send_mail(subject, message, from_address, [dest_addr], fail_silently=False)
1689
            else:
David Baumgold committed
1690 1691
                user.email_user(subject, message, from_address)
        except Exception:  # pylint: disable=broad-except
1692
            log.error(u'Unable to send activation email to user from "%s"', from_address, exc_info=True)
1693 1694
    else:
        registration.activate()
1695

1696 1697 1698
    # 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.
1699
    new_user = authenticate(username=user.username, password=params['password'])
Sarina Canelake committed
1700
    login(request, new_user)
1701 1702
    request.session.set_expiry(0)

1703 1704
    # 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
1705 1706
    if new_user is not None:
        AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
1707

Sarina Canelake committed
1708 1709
    if do_external_auth:
        eamap.user = new_user
1710
        eamap.dtsignup = datetime.datetime.now(UTC)
ichuang committed
1711
        eamap.save()
1712 1713
        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
1714

1715
        if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
1716
            log.info('bypassing activation email')
Sarina Canelake committed
1717 1718 1719
            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
1720

Will Daly committed
1721 1722
    return new_user

1723 1724 1725 1726 1727 1728 1729

@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
    """
1730 1731
    warnings.warn("Please use RegistrationView instead.", DeprecationWarning)

1732
    try:
Will Daly committed
1733
        user = create_account_with_params(request, post_override or request.POST)
1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746
    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
        )

1747
    redirect_url = None  # The AJAX method calling should know the default destination upon success
1748 1749

    # Resume the third-party-auth pipeline if necessary.
1750
    if third_party_auth.is_enabled() and pipeline.running(request):
1751 1752 1753
        running_pipeline = pipeline.get(request)
        redirect_url = pipeline.get_complete_url(running_pipeline['backend'])

1754 1755
    response = JsonResponse({
        'success': True,
1756
        'redirect_url': redirect_url,
1757
    })
Will Daly committed
1758
    set_logged_in_cookies(request, response, user)
1759 1760
    return response

Matthew Mongeau committed
1761

1762
def auto_auth(request):
1763
    """
1764
    Create or configure a user account, then log in as that user.
Matthew Mongeau committed
1765

1766 1767
    Enabled only when
    settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
1768

1769 1770 1771 1772 1773
    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`
1774
    * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
1775
    * `no_login`: Define this to create the user but not login
Matthew Mongeau committed
1776

1777 1778 1779
    If username, email, or password are not provided, use
    randomly generated credentials.
    """
Matthew Mongeau committed
1780

1781 1782
    # Generate a unique name to use if none provided
    unique_name = uuid.uuid4().hex[0:30]
1783 1784

    # Use the params from the request, otherwise use these defaults
1785 1786 1787 1788 1789 1790
    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)
1791 1792 1793 1794

    # mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit'
    enrollment_mode = request.GET.get('enrollment_mode', 'honor')

1795 1796
    course_key = None
    if course_id:
John Eskew committed
1797
        course_key = CourseLocator.from_string(course_id)
1798
    role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()]
1799
    login_when_done = 'no_login' not in request.GET
1800

1801 1802 1803 1804 1805 1806 1807 1808 1809
    form = AccountCreationForm(
        data={
            'username': username,
            'email': email,
            'password': password,
            'name': full_name,
        },
        tos_required=False
    )
1810

1811 1812
    # Attempt to create the account.
    # If successful, this will return a tuple containing
1813 1814
    # the new user object.
    try:
1815
        user, profile, reg = _do_create_account(form)
1816 1817
    except AccountValidationError:
        # Attempt to retrieve the existing user.
1818
        user = User.objects.get(username=username)
1819 1820 1821
        user.email = email
        user.set_password(password)
        user.save()
1822
        profile = UserProfile.objects.get(user=user)
1823
        reg = Registration.objects.get(user=user)
1824 1825 1826 1827 1828

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

1830 1831 1832
    # Activate the user
    reg.activate()
    reg.save()
1833

1834 1835 1836 1837 1838 1839
    # 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()

1840
    # Enroll the user in a course
1841
    if course_key is not None:
1842
        CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
1843

1844 1845
    # Apply the roles
    for role_name in role_names:
1846
        role = Role.objects.get(name=role_name, course_id=course_key)
1847 1848
        user.roles.add(role)

1849
    # Log in as the user
1850 1851 1852
    if login_when_done:
        user = authenticate(username=username, password=password)
        login(request, user)
1853

1854 1855
    create_comments_service_user(user)

1856 1857
    # Provide the user with a valid CSRF token
    # then return a 200 response
1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872
    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)
1873 1874
    response.set_cookie('csrftoken', csrf(request)['csrf_token'])
    return response
1875

1876

1877
@ensure_csrf_cookie
Piotr Mitros committed
1878
def activate_account(request, key):
1879
    """When link in activation e-mail is clicked"""
Sarina Canelake committed
1880 1881
    regs = Registration.objects.filter(activation_key=key)
    if len(regs) == 1:
1882 1883
        user_logged_in = request.user.is_authenticated()
        already_active = True
Sarina Canelake committed
1884 1885
        if not regs[0].user.is_active:
            regs[0].activate()
1886
            already_active = False
1887

1888
        # Enroll student in any pending courses he/she may have if auto_enroll flag is set
Sarina Canelake committed
1889
        student = User.objects.filter(id=regs[0].user_id)
1890 1891 1892 1893
        if student:
            ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
            for cea in ceas:
                if cea.auto_enroll:
1894 1895 1896 1897 1898 1899 1900 1901 1902 1903
                    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
                        )
1904 1905 1906 1907 1908 1909 1910 1911

        resp = render_to_response(
            "registration/activation_complete.html",
            {
                'user_logged_in': user_logged_in,
                'already_active': already_active
            }
        )
1912
        return resp
Sarina Canelake committed
1913
    if len(regs) == 0:
1914 1915 1916 1917
        return render_to_response(
            "registration/activation_invalid.html",
            {'csrf': csrf(request)['csrf_token']}
        )
1918
    return HttpResponseServerError(_("Unknown error. Please e-mail us to let us know how it happened."))
Piotr Mitros committed
1919

1920

1921
@csrf_exempt
1922
@require_POST
Piotr Mitros committed
1923
def password_reset(request):
1924
    """ Attempts to send a password reset e-mail. """
1925 1926
    # Add some rate limiting here by re-using the RateLimitMixin as a helper class
    limiter = BadRequestRateLimiter()
1927
    if limiter.is_rate_limit_exceeded(request):
1928 1929 1930
        AUDIT_LOG.warning("Rate limit exceeded in password_reset")
        return HttpResponseForbidden()

1931
    form = PasswordResetFormNoActive(request.POST)
Piotr Mitros committed
1932
    if form.is_valid():
Calen Pennington committed
1933
        form.save(use_https=request.is_secure(),
1934
                  from_email=microsite.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
Calen Pennington committed
1935 1936
                  request=request,
                  domain_override=request.get_host())
1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948
        # 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,
            }
        )
1949 1950 1951 1952 1953
    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
1954 1955 1956 1957
    return JsonResponse({
        'success': True,
        'value': render_to_string('registration/password_reset_done.html', {}),
    })
David Baumgold committed
1958

1959

1960 1961 1962 1963 1964
def password_reset_confirm_wrapper(
    request,
    uidb36=None,
    token=None,
):
1965
    """ A wrapper around django.contrib.auth.views.password_reset_confirm.
1966
        Needed because we want to set the user as active at this step.
1967
    """
1968
    # cribbed from django.contrib.auth.views.password_reset_confirm
1969 1970 1971 1972 1973 1974 1975
    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
1976

1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996
    # 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']
1997 1998 1999 2000 2001
            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)
2002 2003 2004 2005

        # 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']
2006
            err_msg = ungettext(
2007 2008
                "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.",
2009 2010
                num_days
            ).format(num=num_days)
2011 2012 2013 2014 2015 2016 2017 2018 2019

    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,
2020
            'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
2021 2022 2023 2024
        }
        return TemplateResponse(request, 'registration/password_reset_confirm.html', context)
    else:
        # we also want to pass settings.PLATFORM_NAME in as extra_context
2025
        extra_context = {"platform_name": microsite.get_value('platform_name', settings.PLATFORM_NAME)}
2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047

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

Calen Pennington committed
2049

2050
def reactivation_email_for_user(user):
2051 2052 2053
    try:
        reg = Registration.objects.get(user=user)
    except Registration.DoesNotExist:
David Baumgold committed
2054 2055 2056 2057
        return JsonResponse({
            "success": False,
            "error": _('No inactive user with this e-mail exists'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
2058

David Baumgold committed
2059 2060 2061 2062
    context = {
        'name': user.profile.name,
        'key': reg.activation_key,
    }
Matthew Mongeau committed
2063

David Baumgold committed
2064
    subject = render_to_string('emails/activation_email_subject.txt', context)
2065
    subject = ''.join(subject.splitlines())
David Baumgold committed
2066
    message = render_to_string('emails/activation_email.txt', context)
2067

2068
    try:
David Baumgold committed
2069 2070
        user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
    except Exception:  # pylint: disable=broad-except
2071
        log.error(u'Unable to send reactivation email from "%s"', settings.DEFAULT_FROM_EMAIL, exc_info=True)
David Baumgold committed
2072 2073 2074 2075
        return JsonResponse({
            "success": False,
            "error": _('Unable to send reactivation email')
        })  # TODO: this should be status code 500  # pylint: disable=fixme
Matthew Mongeau committed
2076

David Baumgold committed
2077
    return JsonResponse({"success": True})
Victor Shnayder committed
2078

2079

2080
def validate_new_email(user, new_email):
2081
    """
2082 2083
    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.
2084 2085 2086 2087 2088 2089 2090 2091
    """
    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.'))
2092

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

2096

2097
def do_email_change_request(user, new_email, activation_key=None):
2098 2099 2100 2101 2102
    """
    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.
    """
2103
    pec_list = PendingEmailChange.objects.filter(user=user)
Matthew Mongeau committed
2104
    if len(pec_list) == 0:
2105 2106
        pec = PendingEmailChange()
        pec.user = user
2107
    else:
2108 2109
        pec = pec_list[0]

2110 2111 2112 2113
    # if activation_key is not passing as an argument, generate a random key
    if not activation_key:
        activation_key = uuid.uuid4().hex

2114 2115
    pec.new_email = new_email
    pec.activation_key = activation_key
2116 2117
    pec.save()

2118 2119 2120 2121 2122
    context = {
        'key': pec.activation_key,
        'old_email': user.email,
        'new_email': pec.new_email
    }
2123

2124
    subject = render_to_string('emails/email_change_subject.txt', context)
2125
    subject = ''.join(subject.splitlines())
2126

2127 2128
    message = render_to_string('emails/email_change.txt', context)

2129
    from_address = microsite.get_value(
2130 2131 2132
        'email_from_address',
        settings.DEFAULT_FROM_EMAIL
    )
2133
    try:
2134
        mail.send_mail(subject, message, from_address, [pec.new_email])
2135
    except Exception:  # pylint: disable=broad-except
2136
        log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
2137
        raise ValueError(_('Unable to send email activation link. Please try again later.'))
2138

2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151
    # 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,
        }
    )

2152 2153

@ensure_csrf_cookie
2154
@transaction.commit_manually
Sarina Canelake committed
2155 2156 2157
def confirm_email_change(request, key):  # pylint: disable=unused-argument
    """
    User requested a new e-mail. This is called when the activation
2158
    link is clicked. We confirm with the old e-mail, and update
2159
    """
2160
    try:
2161 2162 2163
        try:
            pec = PendingEmailChange.objects.get(activation_key=key)
        except PendingEmailChange.DoesNotExist:
2164
            response = render_to_response("invalid_email_key.html", {})
2165
            transaction.rollback()
2166
            return response
2167 2168 2169 2170 2171 2172

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

2174
        if len(User.objects.filter(email=pec.new_email)) != 0:
2175
            response = render_to_response("email_exists.html", {})
2176
            transaction.rollback()
2177
            return response
2178 2179 2180 2181

        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
2182 2183
        u_prof = UserProfile.objects.get(user=user)
        meta = u_prof.get_meta()
2184 2185
        if 'old_emails' not in meta:
            meta['old_emails'] = []
2186
        meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
Sarina Canelake committed
2187 2188
        u_prof.set_meta(meta)
        u_prof.save()
2189 2190 2191
        # Send it to the old email...
        try:
            user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
Sarina Canelake committed
2192
        except Exception:    # pylint: disable=broad-except
2193
            log.warning('Unable to send confirmation email to old address', exc_info=True)
2194 2195 2196
            response = render_to_response("email_change_failed.html", {'email': user.email})
            transaction.rollback()
            return response
2197

2198 2199 2200 2201 2202 2203
        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
2204
        except Exception:  # pylint: disable=broad-except
2205
            log.warning('Unable to send confirmation email to new address', exc_info=True)
2206 2207 2208
            response = render_to_response("email_change_failed.html", {'email': pec.new_email})
            transaction.rollback()
            return response
2209

2210
        response = render_to_response("email_change_successful.html", address_context)
2211
        transaction.commit()
2212
        return response
Sarina Canelake committed
2213
    except Exception:  # pylint: disable=broad-except
2214 2215 2216
        # If we get an unexpected exception, be sure to rollback the transaction
        transaction.rollback()
        raise
2217

2218

2219 2220
@require_POST
@login_required
2221 2222 2223 2224 2225 2226
@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")
2227
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
2228 2229
    receive_emails = request.POST.get("receive_emails")
    if receive_emails:
2230
        optout_object = Optout.objects.filter(user=user, course_id=course_key)
2231 2232
        if optout_object:
            optout_object.delete()
2233 2234 2235 2236 2237 2238
        log.info(
            u"User %s (%s) opted in to receive emails from course %s",
            user.username,
            user.email,
            course_id
        )
2239 2240
        track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard')
    else:
2241
        Optout.objects.get_or_create(user=user, course_id=course_key)
2242 2243 2244 2245 2246 2247
        log.info(
            u"User %s (%s) opted out of receiving emails from course %s",
            user.username,
            user.email,
            course_id
        )
2248 2249
        track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')

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