views.py 123 KB
Newer Older
1 2 3
"""
Student Views
"""
4

5
import datetime
6
import json
7
import logging
8
import uuid
9
import warnings
10
from collections import defaultdict, namedtuple
11
from urlparse import parse_qs, urlsplit, urlunsplit
12

13
import analytics
14
import edx_oauth2_provider
15
from django.conf import settings
16 17
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
Calen Pennington committed
18
from django.contrib.auth.decorators import login_required
19
from django.contrib.auth.models import AnonymousUser, User
20
from django.contrib.auth.views import password_reset_confirm
21
from django.core import mail
22
from django.template.context_processors import csrf
23 24 25
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
from django.core.validators import ValidationError, validate_email
26
from django.db import IntegrityError, transaction
27 28
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
29
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
30
from django.shortcuts import redirect
31
from django.template.response import TemplateResponse
32
from django.utils.encoding import force_bytes, force_text
33 34 35
from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode
from django.utils.translation import ugettext as _
from django.utils.translation import get_language, ungettext
36
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
37 38 39 40 41 42
from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView
from ipware.ip import get_ip
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
43
from provider.oauth2.models import Client
44
from pytz import UTC
Diana Huang committed
45
from ratelimitbackend.exceptions import RateLimitException
46
from requests import HTTPError
47 48
from social_core.backends import oauth as social_oauth
from social_core.exceptions import AuthAlreadyAssociated, AuthException
49
from social_django import utils as social_utils
50

51 52 53
import dogstats_wrapper as dog_stats_api
import openedx.core.djangoapps.external_auth.views
import third_party_auth
54
from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY
55 56 57
import track.views
from bulk_email.models import BulkEmailFlag, Optout  # pylint: disable=import-error
from certificates.api import get_certificate_url, has_html_certificates_enabled  # pylint: disable=import-error
58
from certificates.models import (  # pylint: disable=import-error
59 60 61
    CertificateStatuses,
    GeneratedCertificate,
    certificate_status_for_student
62
)
63
from course_modes.models import CourseMode
64
from courseware.access import has_access
65
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date  # pylint: disable=import-error
66
from django_comment_common.models import assign_role
67
from edxmako.shortcuts import render_to_response, render_to_string
68
from eventtracking import tracker
69
from lms.djangoapps.commerce.utils import EcommerceService  # pylint: disable=import-error
70
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
71
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification  # pylint: disable=import-error
72 73
# Note that this lives in LMS, so this dependency should be refactored.
from notification_prefs.views import enable_notifications
74
from openedx.core.djangoapps import monitoring_utils
75
from openedx.core.djangoapps.catalog.utils import get_programs_with_type
76
from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course
77
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
78 79 80 81
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login
from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
82 83
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
84
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
85
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
asadiqbal committed
86
from openedx.core.djangoapps.theming import helpers as theming_helpers
87
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
88 89
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import course_home_url_name
90 91 92 93 94
from openedx.features.enterprise_support.api import (
    consent_needed_for_course,
    enterprise_customer_for_request,
    get_dashboard_consent_notification
)
95 96 97 98 99 100 101 102 103 104 105 106 107
from shoppingcart.api import order_history
from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
from student.cookies import delete_logged_in_cookies, set_logged_in_cookies, set_user_info_cookie
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from student.helpers import (
    DISABLE_UNENROLL_CERT_STATES,
    auth_pipeline_urls,
    check_verify_status_by_course,
    destroy_oauth_tokens,
    get_next_url_for_login_page
)
from student.models import (
    ALLOWEDTOENROLL_TO_ENROLLED,
108
    CourseAccessRole,
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
    CourseEnrollment,
    CourseEnrollmentAllowed,
    CourseEnrollmentAttribute,
    DashboardConfiguration,
    LinkedInAddToProfileConfiguration,
    LoginFailures,
    ManualEnrollmentAudit,
    PasswordHistory,
    PendingEmailChange,
    Registration,
    RegistrationCookieConfiguration,
    UserAttribute,
    UserProfile,
    UserSignupSource,
    UserStanding,
    anonymous_id_for_user,
    create_comments_service_user,
126
    unique_id_for_user
127
)
128
from student.signals import REFUND_ORDER
129 130 131 132 133 134
from student.tasks import send_activation_email
from third_party_auth import pipeline, provider
from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.db import outer_atomic
from util.json_request import JsonResponse
from util.milestones_helpers import get_pre_requisite_courses_not_completed
135
from util.password_policy_validators import validate_password_length, validate_password_strength
136
from xmodule.modulestore.django import modulestore
137

138
log = logging.getLogger("edx.student")
139
AUDIT_LOG = logging.getLogger("audit")
140
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display')  # pylint: disable=invalid-name
141
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
142 143
# Used as the name of the user attribute for tracking affiliate registrations
REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
144 145 146 147 148 149 150 151
REGISTRATION_UTM_PARAMETERS = {
    'utm_source': 'registration_utm_source',
    'utm_medium': 'registration_utm_medium',
    'utm_campaign': 'registration_utm_campaign',
    'utm_term': 'registration_utm_term',
    'utm_content': 'registration_utm_content',
}
REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
152
# used to announce a registration
153
REGISTER_USER = Signal(providing_args=["user", "registration"])
154

155 156 157
# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint
# pylint: disable=logging-format-interpolation

Sarina Canelake committed
158

Piotr Mitros committed
159
def csrf_token(context):
160
    """A csrf token that can be included in a form."""
Sarina Canelake committed
161 162
    token = context.get('csrf_token', '')
    if token == 'NOTPROVIDED':
Piotr Mitros committed
163
        return ''
164
    return (u'<div style="display:none"><input type="hidden"'
Sarina Canelake committed
165
            ' name="csrfmiddlewaretoken" value="%s" /></div>' % (token))
Piotr Mitros committed
166

167

168 169 170 171
# 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
172
def index(request, extra_context=None, user=AnonymousUser()):
173
    """
ichuang committed
174 175 176 177
    Render the edX main page.

    extra_context is used to allow immediate display of certain modal windows, eg signup,
    as used by external_auth.
178
    """
Sarina Canelake committed
179 180
    if extra_context is None:
        extra_context = {}
181

182
    programs_list = []
Renzo Lucioni committed
183 184
    courses = get_courses(user)

185 186 187 188
    if configuration_helpers.get_value(
            "ENABLE_COURSE_SORTING_BY_START_DATE",
            settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"],
    ):
189 190 191
        courses = sort_by_start_date(courses)
    else:
        courses = sort_by_announcement(courses)
192

193
    context = {'courses': courses}
Chris Dodge committed
194

195
    context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html')
196 197

    # This appears to be an unused context parameter, at least for the master templates...
198
    context['show_partners'] = configuration_helpers.get_value('show_partners', True)
199 200 201

    # TO DISPLAY A YOUTUBE WELCOME VIDEO
    # 1) Change False to True
202
    context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False)
203

204 205 206 207 208
    # Maximum number of courses to display on the homepage.
    context['homepage_course_max'] = configuration_helpers.get_value(
        'HOMEPAGE_COURSE_MAX', settings.HOMEPAGE_COURSE_MAX
    )

209
    # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration
210
    # Note: This value should be moved into a configuration setting and plumbed-through to the
211 212
    # context via the site configuration workflow, versus living here
    youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id")
213 214
    context['homepage_promo_video_youtube_id'] = youtube_video_id

215 216
    # allow for theme override of the courses list
    context['courses_list'] = theming_helpers.get_template_path('courses_list.html')
217 218

    # Insert additional context for use in the template
ichuang committed
219
    context.update(extra_context)
220

221 222
    # Add marketable programs to the context.
    context['programs_list'] = get_programs_with_type(request.site, include_hidden=False)
223

ichuang committed
224
    return render_to_response('index.html', context)
225

226

227 228 229 230 231
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.
    """
232
    return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
233 234


235
def cert_info(user, course_overview, course_mode):
236 237
    """
    Get the certificate info needed to render the dashboard section for the given
238 239 240 241 242 243 244 245
    student and course.

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

    Returns:
246
        dict: A dictionary with keys:
247 248
            'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or
                'certificate_earned_but_not_available'
249 250 251 252
            'download_url': url, only present if show_download_url is True
            'show_survey_button': bool
            'survey_url': url, only if show_survey_button is True
            'grade': if status is not 'processing'
253
            'can_unenroll': if status allows for unenrollment
254
    """
255 256 257 258 259 260
    return _cert_info(
        user,
        course_overview,
        certificate_status_for_student(user, course_overview.id),
        course_mode
    )
261

Calen Pennington committed
262

263
def reverification_info(statuses):
Julia Hansbrough committed
264
    """
265
    Returns reverification-related information for *all* of user's enrollments whose
266
    reverification status is in statuses.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281

    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]:
282
            reverifications[status].sort(key=lambda x: x.date)
283 284 285
    return reverifications


286
def get_course_enrollments(user, org_whitelist, org_blacklist):
287
    """
288 289 290 291
    Given a user, return a filtered set of his or her course enrollments.

    Arguments:
        user (User): the user in question.
292 293
        org_whitelist (list[str]): If not None, ONLY courses of these orgs will be returned.
        org_blacklist (list[str]): Courses of these orgs will be excluded.
294 295 296 297

    Returns:
        generator[CourseEnrollment]: a sequence of enrollments to be displayed
        on the user's dashboard.
298
    """
299
    for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user):
Julia Hansbrough committed
300

301 302 303 304 305 306 307 308 309 310
        # 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

311 312
        # Filter out anything that is not in the whitelist.
        if org_whitelist and course_overview.location.org not in org_whitelist:
313 314
            continue

315 316
        # Conversely, filter out any enrollments in the blacklist.
        elif org_blacklist and course_overview.location.org in org_blacklist:
317
            continue
Julia Hansbrough committed
318

319 320 321 322 323
        # Else, include the enrollment.
        else:
            yield enrollment


324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
def get_org_black_and_whitelist_for_site(user):
    """
    Returns the org blacklist and whitelist for the current site.

    Returns:
        (org_whitelist, org_blacklist): A tuple of lists of orgs that serve as
            either a blacklist or a whitelist of orgs for the current site. The
            whitelist takes precedence, and the blacklist is used if the
            whitelist is None.
    """
    # Default blacklist is empty.
    org_blacklist = None
    # Whitelist the orgs configured for the current site.  Each site outside
    # of edx.org has a list of orgs associated with its configuration.
    org_whitelist = configuration_helpers.get_current_site_orgs()

    if not org_whitelist:
        # If there is no whitelist, the blacklist will include all orgs that
        # have been configured for any other sites. This applies to edx.org,
        # where it is easier to blacklist all other orgs.
        org_blacklist = configuration_helpers.get_all_orgs()

    return (org_whitelist, org_blacklist)


349
def _cert_info(user, course_overview, cert_status, course_mode):  # pylint: disable=unused-argument
350 351
    """
    Implements the logic for cert_info -- split out for testing.
352 353 354 355 356

    Arguments:
        user (User): A user.
        course_overview (CourseOverview): A course.
        course_mode (str): The enrollment mode (honor, verified, audit, etc.)
357
    """
358 359 360
    # simplify the status for the template using this lookup table
    template_state = {
        CertificateStatuses.generating: 'generating',
361
        CertificateStatuses.downloadable: 'downloadable',
362 363
        CertificateStatuses.notpassing: 'notpassing',
        CertificateStatuses.restricted: 'restricted',
Bill DeRusha committed
364
        CertificateStatuses.auditing: 'auditing',
365 366
        CertificateStatuses.audit_passing: 'auditing',
        CertificateStatuses.audit_notpassing: 'auditing',
367
        CertificateStatuses.unverified: 'unverified',
368 369
    }

370
    certificate_earned_but_not_available_status = 'certificate_earned_but_not_available'
371
    default_status = 'processing'
372

373 374 375 376 377
    default_info = {
        'status': default_status,
        'show_survey_button': False,
        'can_unenroll': True,
    }
378

379
    if cert_status is None:
380
        return default_info
381

382 383
    status = template_state.get(cert_status['status'], default_status)
    is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing')
384

385 386 387 388 389 390
    if (
        not certificates_viewable_for_course(course_overview) and
        (status in CertificateStatuses.PASSED_STATUSES) and
        course_overview.certificate_available_date
    ):
        status = certificate_earned_but_not_available_status
391

392 393 394 395 396
    if (
        course_overview.certificates_display_behavior == 'early_no_info' and
        is_hidden_status
    ):
        return default_info
397

Sarina Canelake committed
398 399
    status_dict = {
        'status': status,
400
        'mode': cert_status.get('mode', None),
401 402
        'linked_in_url': None,
        'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
Sarina Canelake committed
403
    }
404

405
    if not status == default_status and course_overview.end_of_course_survey_url is not None:
Sarina Canelake committed
406
        status_dict.update({
407
            'show_survey_button': True,
408
            'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
409
    else:
Sarina Canelake committed
410
        status_dict['show_survey_button'] = False
411

412 413
    if status == 'downloadable':
        # showing the certificate web view button if certificate is downloadable state and feature flags are enabled.
414
        if has_html_certificates_enabled(course_overview):
415
            if course_overview.has_any_active_web_certificate:
416 417
                status_dict.update({
                    'show_cert_web_view': True,
418
                    'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid'])
419 420 421
                })
            else:
                # don't show download certificate button if we don't have an active certificate for course
422
                status_dict['status'] = 'unavailable'
423
        elif 'download_url' not in cert_status:
424 425 426
            log.warning(
                u"User %s has a downloadable cert for %s, but no download url",
                user.username,
427
                course_overview.id
428
            )
429
            return default_info
430
        else:
Sarina Canelake committed
431
            status_dict['download_url'] = cert_status['download_url']
432

433 434 435 436
            # 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()
437 438

            # posting certificates to LinkedIn is not currently
439 440
            # supported in White Labels
            if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site():
441
                status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
442 443
                    course_overview.id,
                    course_overview.display_name,
444 445 446
                    cert_status.get('mode'),
                    cert_status['download_url']
                )
447

448
    if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}:
449 450
        cert_grade_percent = -1
        persisted_grade_percent = -1
451
        persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False)
452
        if persisted_grade is not None:
453 454 455 456 457 458
            persisted_grade_percent = persisted_grade.percent

        if 'grade' in cert_status:
            cert_grade_percent = float(cert_status['grade'])

        if cert_grade_percent == -1 and persisted_grade_percent == -1:
459 460 461
            # 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.
462
            return default_info
463

464 465
        status_dict['grade'] = unicode(max(cert_grade_percent, persisted_grade_percent))

Sarina Canelake committed
466
    return status_dict
467

Calen Pennington committed
468

469
@ensure_csrf_cookie
470
def signin_user(request):
471 472 473 474
    """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
475 476
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
477
    if request.user.is_authenticated():
478 479 480 481 482 483
        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:
484
            third_party_auth_error = _(unicode(msg))  # pylint: disable=translation-of-non-string
485
            break
486

487
    context = {
488
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
489 490 491 492
        # 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',
493
        'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
494
        'platform_name': configuration_helpers.get_value(
495 496 497
            'platform_name',
            settings.PLATFORM_NAME
        ),
498
        'third_party_auth_error': third_party_auth_error
499
    }
500

John Jarvis committed
501
    return render_to_response('login.html', context)
John Jarvis committed
502

503

504
@ensure_csrf_cookie
505
def register_user(request, extra_context=None):
506
    """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
507 508
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
509
    if request.user.is_authenticated():
510
        return redirect(redirect_to)
511 512 513 514

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

516
    context = {
517
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
518 519 520
        'email': '',
        'name': '',
        'running_pipeline': None,
521
        'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
522
        'platform_name': configuration_helpers.get_value(
523 524 525
            'platform_name',
            settings.PLATFORM_NAME
        ),
526 527
        'selected_provider': '',
        'username': '',
528
    }
529

530 531
    if extra_context is not None:
        context.update(extra_context)
532

533 534 535
    if context.get("extauth_domain", '').startswith(
            openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
    ):
536
        return render_to_response('register-shib.html', context)
537 538 539

    # If third-party auth is enabled, prepopulate the form with data from the
    # selected provider.
540
    if third_party_auth.is_enabled() and pipeline.running(request):
541
        running_pipeline = pipeline.get(request)
542
        current_provider = provider.Registry.get_from_pipeline(running_pipeline)
543 544 545 546 547
        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)
548

John Jarvis committed
549 550 551
    return render_to_response('register.html', context)


Will Daly committed
552
def complete_course_mode_info(course_id, enrollment, modes=None):
553 554 555 556 557 558 559 560
    """
    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
561 562 563
    if modes is None:
        modes = CourseMode.modes_for_course_dict(course_id)

564
    mode_info = {'show_upsell': False, 'days_for_upsell': None}
565 566 567
    # 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:
568
        mode_info['show_upsell'] = True
vkaracic committed
569
        mode_info['verified_sku'] = modes['verified'].sku
570
        mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku
571
        # if there is an expiration date, find out how long from now it is
572
        if modes['verified'].expiration_datetime:
573
            today = datetime.datetime.now(UTC).date()
574
            mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
575 576 577 578

    return mode_info


579 580 581 582
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:
583 584 585
        # 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
586
        if redeemed_registration.invoice_item:
587
            if not redeemed_registration.invoice_item.invoice.is_valid:
588 589 590
                blocked = True
                # disabling email notifications for unpaid registration courses
                Optout.objects.get_or_create(user=request.user, course_id=course_key)
591 592 593 594
                log.info(
                    u"User %s (%s) opted out of receiving emails from course %s",
                    request.user.username,
                    request.user.email,
595 596 597 598 599 600 601
                    course_key,
                )
                track.views.server_track(
                    request,
                    "change-email1-settings",
                    {"receive_emails": "no", "course": course_key.to_deprecated_string()},
                    page='dashboard',
602
                )
603
                break
604 605 606

    return blocked

Sarina Canelake committed
607

608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625
def generate_activation_email_context(user, registration):
    """
    Constructs a dictionary for use in activation email contexts

    Arguments:
        user (User): Currently logged-in user
        registration (Registration): Registration object for the currently logged-in user
    """
    return {
        'name': user.profile.name,
        'key': registration.activation_key,
        'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
        'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
        'support_url': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
        'support_email': configuration_helpers.get_value('CONTACT_EMAIL', settings.CONTACT_EMAIL),
    }


626 627 628 629 630 631 632 633 634 635 636 637 638
def compose_and_send_activation_email(user, profile, user_registration=None):
    """
    Construct all the required params and send the activation email
    through celery task

    Arguments:
        user: current logged-in user
        profile: profile object of the current logged-in user
        user_registration: registration of the current logged-in user
    """
    dest_addr = user.email
    if user_registration is None:
        user_registration = Registration.objects.get(user=user)
639
    context = generate_activation_email_context(user, user_registration)
640 641 642 643
    subject = render_to_string('emails/activation_email_subject.txt', context)
    # Email subject *must not* contain newlines
    subject = ''.join(subject.splitlines())
    message_for_activation = render_to_string('emails/activation_email.txt', context)
644 645
    from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
    from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
646 647 648 649 650 651 652
    if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
        dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL']
        message_for_activation = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
                                  '-' * 80 + '\n\n' + message_for_activation)
    send_activation_email.delay(subject, message_for_activation, from_address, dest_addr)


653
@login_required
Matthew Mongeau committed
654 655
@ensure_csrf_cookie
def dashboard(request):
656 657 658 659 660 661 662 663 664 665 666 667
    """
    Provides the LMS dashboard view

    TODO: This is lms specific and does not belong in common code.

    Arguments:
        request: The request object.

    Returns:
        The dashboard response.

    """
668
    user = request.user
669 670
    if not UserProfile.objects.filter(user=user).exists():
        return redirect(reverse('account_settings'))
671

672
    platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
673 674 675 676 677 678 679 680
    enable_verified_certificates = configuration_helpers.get_value(
        'ENABLE_VERIFIED_CERTIFICATES',
        settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES')
    )
    display_course_modes_on_dashboard = configuration_helpers.get_value(
        'DISPLAY_COURSE_MODES_ON_DASHBOARD',
        settings.FEATURES.get('DISPLAY_COURSE_MODES_ON_DASHBOARD', True)
    )
681
    activation_email_support_link = configuration_helpers.get_value(
682 683
        'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK
    ) or settings.SUPPORT_SITE_LINK
684

685 686 687
    # get the org whitelist or the org blacklist for the current site
    site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site(user)
    course_enrollments = list(get_course_enrollments(user, site_org_whitelist, site_org_blacklist))
688

689 690
    # Record how many courses there are so that we can get a better
    # understanding of usage patterns on prod.
691
    monitoring_utils.accumulate('num_courses', len(course_enrollments))
692

693
    # sort the enrollment pairs by the enrollment date
694
    course_enrollments.sort(key=lambda x: x.created, reverse=True)
695

Will Daly committed
696
    # Retrieve the course modes for each course
697
    enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
698
    __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
Will Daly committed
699
    course_modes_by_course = {
700 701 702 703 704
        course_id: {
            mode.slug: mode
            for mode in modes
        }
        for course_id, modes in unexpired_course_modes.iteritems()
Will Daly committed
705 706 707 708 709
    }

    # 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(
710
        course_enrollments, course_modes_by_course
Will Daly committed
711
    )
712

713
    course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
714

715 716 717 718 719 720 721 722 723 724 725 726
    sidebar_account_activation_message = ''
    banner_account_activation_message = ''
    display_account_activation_message_on_sidebar = configuration_helpers.get_value(
        'DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR',
        settings.FEATURES.get('DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR', False)
    )

    # Display activation message in sidebar if DISPLAY_ACCOUNT_ACTIVATION_MESSAGE_ON_SIDEBAR
    # flag is active. Otherwise display existing message at the top.
    if display_account_activation_message_on_sidebar and not user.is_active:
        sidebar_account_activation_message = render_to_string(
            'registration/account_activation_sidebar_notice.html',
727 728 729 730 731
            {
                'email': user.email,
                'platform_name': platform_name,
                'activation_email_support_link': activation_email_support_link
            }
732 733 734
        )
    elif not user.is_active:
        banner_account_activation_message = render_to_string(
735
            'registration/activate_account_notice.html',
736
            {'email': user.email}
737
        )
738

739 740
    enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments)

741 742 743 744 745 746 747 748 749 750
    enterprise_customer = enterprise_customer_for_request(request)
    consent_required_courses = set()
    enterprise_customer_name = None
    if enterprise_customer:
        consent_required_courses = {
            enrollment.course_id for enrollment in course_enrollments
            if consent_needed_for_course(request, request.user, str(enrollment.course_id), True)
        }
        enterprise_customer_name = enterprise_customer['name']

751 752 753 754 755
    # Account activation message
    account_activation_messages = [
        message for message in messages.get_messages(request) if 'account-activation' in message.tags
    ]

756
    # Global staff can see what courses encountered an error on their dashboard
757
    staff_access = False
Victor Shnayder committed
758
    errored_courses = {}
759
    if has_access(user, 'staff', 'global'):
760
        # Show any courses that encountered an error on load
761 762 763
        staff_access = True
        errored_courses = modulestore().get_errored_courses()

764
    show_courseware_links_for = frozenset(
765 766
        enrollment.course_id for enrollment in course_enrollments
        if has_access(request.user, 'load', enrollment.course_overview)
767
    )
768

769
    # Find programs associated with course runs being displayed. This information
770 771
    # is passed in the template context to allow rendering of program-related
    # information on the dashboard.
772
    meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
773 774
    inverted_programs = meter.invert_programs()

Will Daly committed
775 776 777 778
    # 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 = {
779 780 781
        enrollment.course_id: complete_course_mode_info(
            enrollment.course_id, enrollment,
            modes=course_modes_by_course[enrollment.course_id]
Will Daly committed
782
        )
783
        for enrollment in course_enrollments
Will Daly committed
784 785
    }

786 787 788 789 790 791 792 793 794 795 796 797 798 799
    # 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.
800
    verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
Will Daly committed
801
    cert_statuses = {
802 803
        enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
        for enrollment in course_enrollments
Will Daly committed
804
    }
Victor Shnayder committed
805

806
    # only show email settings for Mongo course and when bulk email is turned on
807
    show_email_settings_for = frozenset(
808
        enrollment.course_id for enrollment in course_enrollments if (
809
            BulkEmailFlag.feature_enabled(enrollment.course_id)
810 811
        )
    )
812

813
    # Verification Attempts
814
    # Used to generate the "you must reverify for course x" banner
815 816
    verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user)
    verification_errors = get_verification_error_reasons_for_display(verification_error_codes)
817

Julia Hansbrough committed
818
    # Gets data for midcourse reverifications, if any are necessary or have failed
819
    statuses = ["approved", "denied", "pending", "must_reverify"]
820
    reverifications = reverification_info(statuses)
821

822 823 824 825 826 827 828 829 830 831 832
    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
        )
    )
833

834 835 836 837
    enrolled_courses_either_paid = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.is_paid_course()
    )
838

839 840 841 842
    # 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"])

843
    # Populate the Order History for the side-bar.
844
    order_history_list = order_history(user, course_org_filter=site_org_whitelist, org_filter_out_set=site_org_blacklist)
845

846
    # get list of courses having pre-requisites yet to be completed
847 848 849 850
    courses_having_prerequisites = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.course_overview.pre_requisite_courses
    )
851 852
    courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)

853
    if 'notlive' in request.GET:
854 855
        redirect_message = _("The course you are looking for does not start until {date}.").format(
            date=request.GET['notlive']
856
        )
857 858 859 860
    elif 'course_closed' in request.GET:
        redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format(
            date=request.GET['course_closed']
        )
861 862 863
    else:
        redirect_message = ''

864 865 866
    valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
    display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses

867
    context = {
868
        'enterprise_message': enterprise_message,
869 870
        'consent_required_courses': consent_required_courses,
        'enterprise_customer_name': enterprise_customer_name,
871
        'enrollment_message': enrollment_message,
872
        'redirect_message': redirect_message,
873
        'account_activation_messages': account_activation_messages,
874
        'course_enrollments': course_enrollments,
875
        'course_optouts': course_optouts,
876 877
        'banner_account_activation_message': banner_account_activation_message,
        'sidebar_account_activation_message': sidebar_account_activation_message,
878 879 880
        'staff_access': staff_access,
        'errored_courses': errored_courses,
        'show_courseware_links_for': show_courseware_links_for,
Will Daly committed
881
        'all_course_modes': course_mode_info,
882
        'cert_statuses': cert_statuses,
883
        'credit_statuses': _credit_statuses(user, course_enrollments),
884 885 886
        'show_email_settings_for': show_email_settings_for,
        'reverifications': reverifications,
        'verification_status': verification_status,
887
        'verification_status_by_course': verify_status_by_course,
888
        'verification_errors': verification_errors,
889
        'block_courses': block_courses,
890 891
        'denied_banner': denied_banner,
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
892
        'user': user,
893
        'logout_url': reverse('logout'),
894
        'platform_name': platform_name,
895
        'enrolled_courses_either_paid': enrolled_courses_either_paid,
896
        'provider_states': [],
897 898
        'order_history_list': order_history_list,
        'courses_requirements_not_met': courses_requirements_not_met,
899
        'nav_hidden': True,
900
        'inverted_programs': inverted_programs,
901
        'show_program_listing': ProgramsApiConfig.is_enabled(),
902
        'show_dashboard_tabs': True,
903
        'disable_courseware_js': True,
904
        'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
905
        'display_sidebar_on_dashboard': display_sidebar_on_dashboard,
906
    }
907

vkaracic committed
908
    ecommerce_service = EcommerceService()
909
    if ecommerce_service.is_enabled(request.user):
vkaracic committed
910 911 912 913 914
        context.update({
            'use_ecommerce_payment_flow': True,
            'ecommerce_payment_page': ecommerce_service.payment_page_url(),
        })

915 916 917
    response = render_to_response('dashboard.html', context)
    set_user_info_cookie(response, request)
    return response
918 919


920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948
@login_required
def course_run_refund_status(request, course_id):
    """
    Get Refundable status for a course.

    Arguments:
        request: The request object.
        course_id (str): The unique identifier for the course.

    Returns:
        Json response.

    """

    try:
        course_key = CourseKey.from_string(course_id)
        course_enrollment = CourseEnrollment.get_enrollment(request.user, course_key)

    except InvalidKeyError:
        logging.exception("The course key used to get refund status caused InvalidKeyError during look up.")

        return JsonResponse({'course_refundable_status': ''}, status=406)

    refundable_status = course_enrollment.refundable()
    logging.info("Course refund status for course {0} is {1}".format(course_id, refundable_status))

    return JsonResponse({'course_refundable_status': refundable_status}, status=200)


949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969
def get_verification_error_reasons_for_display(verification_error_codes):
    verification_errors = []
    verification_error_map = {
        'photos_mismatched': _('Photos are mismatched'),
        'id_image_missing_name': _('Name missing from ID photo'),
        'id_image_missing': _('ID photo not provided'),
        'id_invalid': _('ID is invalid'),
        'user_image_not_clear': _('Learner photo is blurry'),
        'name_mismatch': _('Name on ID does not match name on account'),
        'user_image_missing': _('Learner photo not provided'),
        'id_image_not_clear': _('ID photo is blurry'),
    }

    for error in verification_error_codes:
        error_text = verification_error_map.get(error)
        if error_text:
            verification_errors.append(error_text)

    return verification_errors


970 971 972
def _create_recent_enrollment_message(course_enrollments, course_modes):  # pylint: disable=invalid-name
    """
    Builds a recent course enrollment message.
973

974 975
    Constructs a new message template based on any recent course enrollments
    for the student.
976 977

    Args:
978
        course_enrollments (list[CourseEnrollment]): a list of course enrollments.
Will Daly committed
979
        course_modes (dict): Mapping of course ID's to course mode dictionaries.
980 981 982

    Returns:
        A string representing the HTML message output from the message template.
Will Daly committed
983
        None if there are no recently enrolled courses.
984 985

    """
986
    recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
Will Daly committed
987 988

    if recently_enrolled_courses:
989 990 991 992 993 994 995 996 997 998 999 1000
        enrollments_count = len(recently_enrolled_courses)
        course_name_separator = ', '
        # If length of enrolled course 2, join names with 'and'
        if enrollments_count == 2:
            course_name_separator = _(' and ')

        course_names = course_name_separator.join(
            [enrollment.course_overview.display_name for enrollment in recently_enrolled_courses]
        )

        allow_donations = any(
            _allow_donation(course_modes, enrollment.course_overview.id, enrollment)
1001
            for enrollment in recently_enrolled_courses
1002
        )
Will Daly committed
1003

1004
        platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
1005

1006 1007
        return render_to_string(
            'enrollment/course_enrollment_message.html',
1008 1009 1010 1011 1012 1013 1014
            {
                'course_names': course_names,
                'enrollments_count': enrollments_count,
                'allow_donations': allow_donations,
                'platform_name': platform_name,
                'course_id': recently_enrolled_courses[0].course_overview.id if enrollments_count == 1 else None
            }
1015 1016 1017
        )


1018 1019 1020
def _get_recently_enrolled_courses(course_enrollments):
    """
    Given a list of enrollments, filter out all but recent enrollments.
1021 1022

    Args:
1023
        course_enrollments (list[CourseEnrollment]): A list of course enrollments.
1024 1025

    Returns:
1026
        list[CourseEnrollment]: A list of recent course enrollments.
1027 1028 1029 1030
    """
    seconds = DashboardConfiguration.current().recent_enrollment_time_delta
    time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
    return [
1031
        enrollment for enrollment in course_enrollments
1032 1033 1034 1035 1036 1037
        # 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
    ]


1038
def _allow_donation(course_modes, course_id, enrollment):
1039 1040 1041 1042 1043 1044 1045
    """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.
1046
        enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
1047 1048 1049 1050 1051

    Returns:
        True if the course is allowing donations.

    """
1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066
    if course_id not in course_modes:
        flat_unexpired_modes = {
            unicode(course_id): [mode for mode in modes]
            for course_id, modes in course_modes.iteritems()
        }
        flat_all_modes = {
            unicode(course_id): [mode.slug for mode in modes]
            for course_id, modes in CourseMode.all_modes_for_courses([course_id]).iteritems()
        }
        log.error(
            u'Can not find `%s` in course modes.`%s`. All modes: `%s`',
            course_id,
            flat_unexpired_modes,
            flat_all_modes
        )
1067 1068 1069 1070
    donations_enabled = configuration_helpers.get_value(
        'ENABLE_DONATIONS',
        DonationConfiguration.current().enabled
    )
1071 1072 1073 1074 1075
    return (
        donations_enabled and
        enrollment.mode in course_modes[course_id] and
        course_modes[course_id][enrollment.mode].min_price == 0
    )
1076 1077


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

1081 1082 1083
    email_opt_in = request.POST.get('email_opt_in')
    if email_opt_in is not None:
        email_opt_in_boolean = email_opt_in == 'true'
1084
        preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
1085 1086


1087
def _credit_statuses(user, course_enrollments):
1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102
    """
    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.
1103 1104
        course_enrollments (list[CourseEnrollment]): List of enrollments for the
            user.
1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120

    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:
1121
    >>> _credit_statuses(user, course_enrollments)
1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137
    {
        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

1138 1139 1140 1141
    # Feature flag off
    if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"):
        return {}

1142 1143 1144 1145 1146 1147
    request_status_by_course = {
        request["course_key"]: request["status"]
        for request in credit_api.get_credit_requests_for_user(user.username)
    }

    credit_enrollments = {
1148 1149
        enrollment.course_id: enrollment
        for enrollment in course_enrollments
1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172
        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):
1173
        course_key = CourseKey.from_string(unicode(eligibility["course_key"]))
1174
        providers_names = get_credit_provider_display_names(course_key)
1175 1176 1177 1178 1179
        status = {
            "course_key": unicode(course_key),
            "eligible": True,
            "deadline": eligibility["deadline"],
            "purchased": course_key in credit_enrollments,
1180
            "provider_name": make_providers_strings(providers_names),
1181
            "provider_status_url": None,
1182
            "provider_id": None,
1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205
            "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")
1206
                status["provider_id"] = provider_id
1207 1208 1209 1210 1211 1212

        statuses[course_key] = status

    return statuses


1213
@transaction.non_atomic_requests
1214
@require_POST
1215
@outer_atomic(read_committed=True)
1216
def change_enrollment(request, check_access=True):
1217 1218 1219
    """
    Modify the enrollment status for the logged-in user.

1220 1221
    TODO: This is lms specific and does not belong in common code.

1222 1223 1224 1225 1226 1227 1228 1229 1230
    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
1231 1232
    happens. This function should only be called from an AJAX request, so
    the error messages in the responses should never actually be user-visible.
1233 1234 1235 1236 1237

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

    Keyword Args:
1238 1239 1240 1241 1242
        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.
1243 1244 1245 1246

    Returns:
        Response

1247
    """
1248
    # Get the user
1249
    user = request.user
1250

Julia Hansbrough committed
1251 1252 1253 1254
    # Ensure the user is authenticated
    if not user.is_authenticated():
        return HttpResponseForbidden()

1255
    # Ensure we received a course_id
1256
    action = request.POST.get("enrollment_action")
1257
    if 'course_id' not in request.POST:
David Baumgold committed
1258
        return HttpResponseBadRequest(_("Course id not specified"))
1259

Julia Hansbrough committed
1260
    try:
1261
        course_id = CourseKey.from_string(request.POST.get("course_id"))
Julia Hansbrough committed
1262 1263
    except InvalidKeyError:
        log.warning(
1264 1265 1266 1267
            u"User %s tried to %s with invalid course id: %s",
            user.username,
            action,
            request.POST.get("course_id"),
Julia Hansbrough committed
1268 1269 1270
        )
        return HttpResponseBadRequest(_("Invalid course id"))

1271 1272 1273 1274
    # Allow us to monitor performance of this transaction on a per-course basis since we often roll-out features
    # on a per-course basis.
    monitoring_utils.set_custom_metric('course_id', unicode(course_id))

1275
    if action == "enroll":
Don Mitchell committed
1276 1277 1278
        # 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):
1279 1280 1281 1282 1283
            log.warning(
                u"User %s tried to enroll in non-existent course %s",
                user.username,
                course_id
            )
Don Mitchell committed
1284 1285
            return HttpResponseBadRequest(_("Course id is invalid"))

1286 1287
        # Record the user's email opt-in preference
        if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
1288
            _update_email_opt_in(request, course_id.org)
1289

1290 1291
        available_modes = CourseMode.modes_for_course_dict(course_id)

1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302
        # 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)

1303 1304 1305
        # Check that auto enrollment is allowed for this course
        # (= the course is NOT behind a paywall)
        if CourseMode.can_auto_enroll(course_id):
1306
            # Enroll the user using the default mode (audit)
1307 1308 1309 1310
            # 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
1311
            # to "audit".
1312
            try:
1313 1314
                enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
                if enroll_mode:
1315
                    CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
1316
            except Exception:  # pylint: disable=broad-except
1317
                return HttpResponseBadRequest(_("Could not enroll"))
1318

1319 1320
        # If we have more than one course mode or professional ed is enabled,
        # then send the user to the choose your track page.
1321
        # (In the case of no-id-professional/professional ed, this will redirect to a page that
1322
        # funnels users directly into the verification / payment flow)
1323
        if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
1324 1325 1326 1327 1328 1329
            return HttpResponse(
                reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
            )

        # Otherwise, there is only one mode available (the default)
        return HttpResponse()
1330
    elif action == "unenroll":
1331 1332
        enrollment = CourseEnrollment.get_enrollment(user, course_id)
        if not enrollment:
1333
            return HttpResponseBadRequest(_("You are not enrolled in this course"))
1334

1335 1336
        certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode)
        if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES:
1337 1338
            return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course"))

1339
        CourseEnrollment.unenroll(user, course_id)
1340
        REFUND_ORDER.send(sender=None, course_enrollment=enrollment)
1341
        return HttpResponse()
1342
    else:
David Baumgold committed
1343
        return HttpResponseBadRequest(_("Enrollment action is invalid"))
1344

1345

1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378
def _generate_not_activated_message(user):
    """
    Generates the message displayed on the sign-in screen when a learner attempts to access the
    system with an inactive account.

    Arguments:
        user (User): User object for the learner attempting to sign in.
    """

    support_url = configuration_helpers.get_value(
        'SUPPORT_SITE_LINK',
        settings.SUPPORT_SITE_LINK
    )

    platform_name = configuration_helpers.get_value(
        'PLATFORM_NAME',
        settings.PLATFORM_NAME
    )

    not_activated_msg_template = _('In order to sign in, you need to activate your account.<br /><br />'
                                   'We just sent an activation link to <strong>{email}</strong>.  If '
                                   'you do not receive an email, check your spam folders or '
                                   '<a href="{support_url}">contact {platform} Support</a>.')

    not_activated_message = not_activated_msg_template.format(
        email=user.email,
        support_url=support_url,
        platform=platform_name
    )

    return not_activated_message


1379
# Need different levels of logging
1380
@ensure_csrf_cookie
1381
def login_user(request, error=""):  # pylint: disable=too-many-statements,unused-argument
1382
    """AJAX request to log in the user."""
1383

1384 1385 1386 1387 1388 1389
    backend_name = None
    email = None
    password = None
    redirect_url = None
    response = None
    running_pipeline = None
1390
    third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
1391 1392 1393
    third_party_auth_successful = False
    trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
    user = None
1394
    platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
1395 1396 1397 1398 1399 1400 1401 1402 1403

    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']
1404 1405
        third_party_uid = running_pipeline['kwargs']['uid']
        requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
1406 1407

        try:
1408
            user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
1409 1410
            third_party_auth_successful = True
        except User.DoesNotExist:
1411
            AUDIT_LOG.info(
1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436
                u"Login failed - user with username {username} has no social auth "
                "with backend_name {backend_name}".format(
                    username=username, backend_name=backend_name)
            )
            message = _(
                "You've successfully logged into your {provider_name} account, "
                "but this account isn't linked with an {platform_name} account yet."
            ).format(
                platform_name=platform_name,
                provider_name=requested_provider.name,
            )
            message += "<br/><br/>"
            message += _(
                "Use your {platform_name} username and password to log into {platform_name} below, "
                "and then link your {platform_name} account with {provider_name} from your dashboard."
            ).format(
                platform_name=platform_name,
                provider_name=requested_provider.name,
            )
            message += "<br/><br/>"
            message += _(
                "If you don't have an {platform_name} account yet, "
                "click <strong>Register</strong> at the top of the page."
            ).format(
                platform_name=platform_name
1437
            )
1438

1439 1440
            return HttpResponse(message, content_type="text/plain", status=403)

1441 1442 1443 1444 1445
    else:

        if 'email' not in request.POST or 'password' not in request.POST:
            return JsonResponse({
                "success": False,
1446 1447 1448
                # TODO: User error message
                "value": _('There was an error receiving your login information. Please email us.'),
            })  # TODO: this should be status code 400
1449 1450 1451 1452 1453 1454 1455 1456 1457 1458

        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
1459

1460 1461 1462
    # 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.
1463
    if settings.FEATURES.get('AUTH_USE_SHIB') and user:
1464 1465
        try:
            eamap = ExternalAuthMap.objects.get(user=user)
1466
            if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
David Baumgold committed
1467 1468 1469 1470
                return JsonResponse({
                    "success": False,
                    "redirect": reverse('shib-login'),
                })  # TODO: this should be status code 301  # pylint: disable=fixme
1471 1472
        except ExternalAuthMap.DoesNotExist:
            # This is actually the common case, logging in user without external linked login
1473
            AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
1474

1475
    # see if account has been locked out due to excessive login failures
1476 1477 1478
    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):
1479 1480
            lockout_message = _('This account has been temporarily locked due '
                                'to excessive login failures. Try again later.')
David Baumgold committed
1481 1482
            return JsonResponse({
                "success": False,
1483
                "value": lockout_message,
David Baumgold committed
1484
            })  # TODO: this should be status code 429  # pylint: disable=fixme
1485

1486
    # see if the user must reset his/her password due to any policy settings
1487
    if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup):
1488 1489 1490 1491
        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
1492
                       '"Forgot Password" link on this page to reset your password before logging in again.'),
1493 1494
        })  # TODO: this should be status code 403  # pylint: disable=fixme

Diana Huang committed
1495 1496 1497 1498
    # 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 ""
1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509

    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
1510
    if user is None:
1511 1512 1513 1514
        # 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
1515 1516 1517
        # 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 != "":
1518 1519 1520 1521 1522
            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
1523 1524 1525 1526
        return JsonResponse({
            "success": False,
            "value": _('Email or password is incorrect.'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
1527

1528 1529 1530 1531
    # successful login, clear failed login attempts counters, if applicable
    if LoginFailures.is_feature_enabled():
        LoginFailures.clear_lockout_counter(user)

1532
    # Track the user's sign in
1533
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1534
        tracking_context = tracker.get_tracker().resolve_context()
1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547
        analytics.identify(
            user.id,
            {
                'email': email,
                'username': username
            },
            {
                # Disable MailChimp because we don't want to update the user's email
                # and username in MailChimp on every page load. We only need to capture
                # this data on registration/activation.
                'MailChimp': False
            }
        )
1548 1549 1550 1551 1552 1553

        analytics.track(
            user.id,
            "edx.bi.user.account.authenticated",
            {
                'category': "conversion",
1554
                'label': request.POST.get('course_id'),
Julia Hansbrough committed
1555
                'provider': None
1556 1557
            },
            context={
1558
                'ip': tracking_context.get('ip'),
1559
                'Google Analytics': {
Sarina Canelake committed
1560
                    'clientId': tracking_context.get('client_id')
1561 1562 1563
                }
            }
        )
Piotr Mitros committed
1564
    if user is not None and user.is_active:
1565
        try:
1566 1567
            # We do not log here, because we have a handler registered
            # to perform logging on successful logins.
1568
            login(request, user)
1569
            if request.POST.get('remember') == 'true':
1570
                request.session.set_expiry(604800)
1571 1572 1573
                log.debug("Setting user session to never expire")
            else:
                request.session.set_expiry(0)
Sarina Canelake committed
1574
        except Exception as exc:  # pylint: disable=broad-except
1575
            AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
1576
            log.critical("Login failed - Could not create session. Is memcached running?")
Sarina Canelake committed
1577
            log.exception(exc)
1578
            raise
1579

1580
        redirect_url = None  # The AJAX method calling should know the default destination upon success
1581 1582 1583
        if third_party_auth_successful:
            redirect_url = pipeline.get_complete_url(backend_name)

David Baumgold committed
1584 1585 1586 1587
        response = JsonResponse({
            "success": True,
            "redirect_url": redirect_url,
        })
1588

1589 1590
        # Ensure that the external marketing site can
        # detect that the user is logged in.
Will Daly committed
1591
        return set_logged_in_cookies(request, response, user)
Victor Shnayder committed
1592

1593 1594 1595 1596
    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
1597

1598
    reactivation_email_for_user(user)
1599

David Baumgold committed
1600 1601
    return JsonResponse({
        "success": False,
1602
        "value": _generate_not_activated_message(user),
David Baumgold committed
1603
    })  # TODO: this should be status code 400  # pylint: disable=fixme
Piotr Mitros committed
1604

1605

1606
@csrf_exempt
1607
@require_POST
1608
@social_utils.psa("social:complete")
1609 1610 1611 1612 1613 1614
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.
    """
1615 1616
    warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)

1617
    backend = request.backend
1618 1619 1620
    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
1621
            request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
1622
            user = None
1623
            access_token = request.POST["access_token"]
1624
            try:
1625
                user = backend.do_auth(access_token)
1626
            except (HTTPError, AuthException):
1627 1628 1629
                pass
            # do_auth can return a non-User object if it fails
            if user and isinstance(user, User):
1630
                login(request, user)
1631 1632 1633
                return JsonResponse(status=204)
            else:
                # Ensure user does not re-enter the pipeline
1634
                request.social_strategy.clean_partial_pipeline(access_token)
1635 1636 1637 1638 1639
                return JsonResponse({"error": "invalid_token"}, status=401)
        else:
            return JsonResponse({"error": "invalid_request"}, status=400)
    raise Http404

1640

1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659
@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:
1660
        row = [user.username, user.standing.changed_by]
1661 1662 1663 1664 1665 1666
        rows.append(row)

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

    return render_to_response("manage_user_standing.html", context)

1667

1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680
@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() == '':
1681
        context['message'] = _('Please enter a username')
1682 1683 1684 1685
        return JsonResponse(context, status=400)

    account_action = request.POST.get('account_action')
    if account_action is None:
1686
        context['message'] = _('Please choose an option')
1687 1688 1689 1690 1691 1692
        return JsonResponse(context, status=400)

    username = username.strip()
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
1693
        context['message'] = _("User with username {} does not exist").format(username)
1694 1695
        return JsonResponse(context, status=400)
    else:
1696
        user_account, _success = UserStanding.objects.get_or_create(
1697 1698 1699 1700
            user=user, defaults={'changed_by': request.user},
        )
        if account_action == 'disable':
            user_account.account_status = UserStanding.ACCOUNT_DISABLED
1701
            context['message'] = _("Successfully disabled {}'s account").format(username)
1702
            log.info(u"%s disabled %s's account", request.user, username)
1703 1704
        elif account_action == 'reenable':
            user_account.account_status = UserStanding.ACCOUNT_ENABLED
1705
            context['message'] = _("Successfully reenabled {}'s account").format(username)
1706
            log.info(u"%s reenabled %s's account", request.user, username)
1707
        else:
1708
            context['message'] = _("Unexpected account status")
1709 1710
            return JsonResponse(context, status=400)
        user_account.changed_by = request.user
1711
        user_account.standing_last_changed_at = datetime.datetime.now(UTC)
1712 1713 1714 1715
        user_account.save()

    return JsonResponse(context)

1716

1717
@login_required
1718
@ensure_csrf_cookie
1719
def change_setting(request):
1720
    """JSON call to change a profile setting: Right now, location"""
1721
    # TODO (vshnayder): location is no longer used
Sarina Canelake committed
1722
    u_prof = UserProfile.objects.get(user=request.user)  # request.user.profile_cache
Piotr Mitros committed
1723
    if 'location' in request.POST:
Sarina Canelake committed
1724 1725
        u_prof.location = request.POST['location']
    u_prof.save()
1726

David Baumgold committed
1727 1728
    return JsonResponse({
        "success": True,
Sarina Canelake committed
1729
        "location": u_prof.location,
David Baumgold committed
1730
    })
1731

Calen Pennington committed
1732

1733 1734 1735 1736 1737
class AccountValidationError(Exception):
    def __init__(self, message, field):
        super(AccountValidationError, self).__init__(message)
        self.field = field

1738 1739

@receiver(post_save, sender=User)
1740
def user_signup_handler(sender, **kwargs):  # pylint: disable=unused-argument
1741 1742 1743 1744 1745
    """
    handler that saves the user Signup Source
    when the user is created
    """
    if 'created' in kwargs and kwargs['created']:
1746
        site = configuration_helpers.get_value('SITE_NAME')
1747
        if site:
1748
            user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
1749 1750 1751 1752
            user_signup_source.save()
            log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))


1753
def _do_create_account(form, custom_form=None):
1754 1755 1756 1757 1758 1759 1760 1761
    """
    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.
    """
1762 1763 1764 1765 1766 1767 1768
    # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
    if not configuration_helpers.get_value(
            'ALLOW_PUBLIC_ACCOUNT_CREATION',
            settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
    ):
        raise PermissionDenied()

1769 1770 1771 1772 1773 1774 1775
    errors = {}
    errors.update(form.errors)
    if custom_form:
        errors.update(custom_form.errors)

    if errors:
        raise ValidationError(errors)
1776 1777 1778 1779 1780 1781 1782

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

1785 1786 1787
    # 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:
1788 1789
        with transaction.atomic():
            user.save()
1790 1791 1792 1793
            if custom_form:
                custom_model = custom_form.save(commit=False)
                custom_model.user = user
                custom_model.save()
1794 1795
    except IntegrityError:
        # Figure out the cause of the integrity error
1796 1797 1798 1799 1800
        # TODO duplicate email is already handled by form.errors above as a ValidationError.
        # The checks for duplicate email/username should occur in the same place with an
        # AccountValidationError and a consistent user message returned (i.e. both should
        # return "It looks like {username} belongs to an existing account. Try again with a
        # different username.")
1801
        if len(User.objects.filter(username=user.username)) > 0:
1802
            raise AccountValidationError(
1803
                _("An account with the Public Username '{username}' already exists.").format(username=user.username),
1804
                field="username"
Sarina Canelake committed
1805
            )
1806
        elif len(User.objects.filter(email=user.email)) > 0:
1807
            raise AccountValidationError(
1808
                _("An account with the Email '{email}' already exists.").format(email=user.email),
1809
                field="email"
Sarina Canelake committed
1810
            )
1811 1812
        else:
            raise
1813

1814 1815 1816 1817 1818
    # 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)

1819
    registration.register(user)
1820

1821 1822 1823 1824 1825 1826 1827 1828 1829
    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
1830 1831
    if extended_profile:
        profile.meta = json.dumps(extended_profile)
1832
    try:
1833
        profile.save()
Sarina Canelake committed
1834
    except Exception:  # pylint: disable=broad-except
David Baumgold committed
1835
        log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
1836
        raise
1837

1838
    return (user, profile, registration)
1839

1840

1841 1842 1843 1844 1845 1846 1847
def _create_or_set_user_attribute_created_on_site(user, site):
    # Create or Set UserAttribute indicating the microsite site the user account was created on.
    # User maybe created on 'courses.edx.org', or a white-label site
    if site:
        UserAttribute.set_user_attribute(user, 'created_on_site', site.domain)


1848
def create_account_with_params(request, params):
1849
    """
1850 1851 1852 1853 1854
    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
1855

1856
    Does not return anything.
Matthew Mongeau committed
1857

1858 1859 1860
    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.
1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873

    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").
1874 1875 1876 1877 1878 1879 1880 1881
    * Duplicate email raises a ValidationError (rather than the expected
      AccountValidationError). Duplicate username returns an inconsistent
      user message (i.e. "An account with the Public Username '{username}'
      already exists." rather than "It looks like {username} belongs to an
      existing account. Try again with a different username.") The two checks
      occur at different places in the code; as a result, registering with
      both a duplicate username and email raises only a ValidationError for
      email only.
1882 1883 1884 1885
    """
    # 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())
1886

1887 1888
    # allow to define custom set of required/optional/hidden fields via configuration
    extra_fields = configuration_helpers.get_value(
1889 1890 1891
        'REGISTRATION_EXTRA_FIELDS',
        getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
    )
1892 1893 1894 1895 1896 1897 1898
    # registration via third party (Google, Facebook) using mobile application
    # doesn't use social auth pipeline (no redirect uri(s) etc involved).
    # In this case all related info (required for account linking)
    # is sent in params.
    # `third_party_auth_credentials_in_api` essentially means 'request
    # is made from mobile application'
    third_party_auth_credentials_in_api = 'provider' in params
Matthew Mongeau committed
1899

1900
    is_third_party_auth_enabled = third_party_auth.is_enabled()
1901

1902
    if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
1903
        params["password"] = pipeline.make_random_password()
1904

1905 1906 1907 1908 1909 1910 1911 1912 1913 1914
    # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate
    # error message
    if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)):
        raise ValidationError(
            {'session_expired': [
                _(u"Registration using {provider} has timed out.").format(
                    provider=params.get('social_auth_provider'))
            ]}
        )

ichuang committed
1915 1916
    # 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
1917
    # unless originally we didn't get a valid email or name from the external auth
1918
    # TODO: We do not check whether these values meet all necessary criteria, such as email length
Sarina Canelake committed
1919 1920
    do_external_auth = 'ExternalAuthMap' in request.session
    if do_external_auth:
ichuang committed
1921
        eamap = request.session['ExternalAuthMap']
1922 1923
        try:
            validate_email(eamap.external_email)
1924
            params["email"] = eamap.external_email
1925
        except ValidationError:
1926 1927 1928 1929 1930
            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
1931

1932
    extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', [])
1933 1934 1935 1936
    enforce_password_policy = (
        settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
        not do_external_auth
    )
1937
    # Can't have terms of service for certain SHIB users, like at Stanford
1938
    registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
1939
    tos_required = (
1940 1941 1942
        registration_fields.get('terms_of_service') != 'hidden' or
        registration_fields.get('honor_code') != 'hidden'
    ) and (
1943 1944
        not settings.FEATURES.get("AUTH_USE_SHIB") or
        not settings.FEATURES.get("SHIB_DISABLE_TOS") or
Sarina Canelake committed
1945
        not do_external_auth or
1946
        not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX)
1947
    )
1948

1949
    form = AccountCreationForm(
1950
        data=params,
1951 1952 1953 1954
        extra_fields=extra_fields,
        extended_profile_fields=extended_profile_fields,
        enforce_username_neq_password=True,
        enforce_password_policy=enforce_password_policy,
1955
        tos_required=tos_required,
1956
    )
1957
    custom_form = get_registration_extension_form(data=params)
1958

1959
    # Perform operations within a transaction that are critical to account creation
1960
    with transaction.atomic():
1961
        # first, create the account
1962
        (user, profile, registration) = _do_create_account(form, custom_form)
1963

1964
        # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
1965
        # (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
1966 1967 1968 1969 1970

        # Note: this is orthogonal to the 3rd party authentication pipeline that occurs
        # when the account is created via the browser and redirect URLs.

        if is_third_party_auth_enabled and third_party_auth_credentials_in_api:
1971 1972 1973 1974
            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)
1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987
            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:
1988
                pipeline_user = request.backend.do_auth(social_access_token, user=user)
1989 1990 1991 1992 1993 1994
            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
1995
                request.social_strategy.clean_partial_pipeline(social_access_token)
1996
                raise ValidationError({'access_token': [error_message]})
1997

1998
    # Perform operations that are non-critical parts of account creation
1999 2000
    _create_or_set_user_attribute_created_on_site(user, request.site)

2001 2002
    preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())

2003 2004 2005
    if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
        try:
            enable_notifications(user)
2006
        except Exception:  # pylint: disable=broad-except
2007 2008
            log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))

2009
    dog_stats_api.increment("common.student.account_created")
2010

2011 2012 2013
    # If the user is registering via 3rd party auth, track which provider they use
    third_party_provider = None
    running_pipeline = None
2014
    if is_third_party_auth_enabled and pipeline.running(request):
2015 2016 2017
        running_pipeline = pipeline.get(request)
        third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)

2018
    # Track the user's registration
2019
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
2020
        tracking_context = tracker.get_tracker().resolve_context()
2021
        identity_args = [
2022
            user.id,  # pylint: disable=no-member
2023 2024 2025 2026
            {
                'email': user.email,
                'username': user.username,
                'name': profile.name,
2027 2028 2029
                # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey.
                'age': profile.age or -1,
                'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year,
2030 2031 2032
                'education': profile.level_of_education_display,
                'address': profile.mailing_address,
                'gender': profile.gender_display,
2033
                'country': unicode(profile.country),
2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044
            }
        ]

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

        analytics.identify(*identity_args)
2045 2046 2047

        analytics.track(
            user.id,
Sarina Canelake committed
2048
            "edx.bi.user.account.registered",
2049
            {
Julia Hansbrough committed
2050
                'category': 'conversion',
2051
                'label': params.get('course_id'),
2052
                'provider': third_party_provider.name if third_party_provider else None
2053 2054
            },
            context={
2055
                'ip': tracking_context.get('ip'),
2056
                'Google Analytics': {
Sarina Canelake committed
2057
                    'clientId': tracking_context.get('client_id')
2058 2059 2060 2061
                }
            }
        )

2062
    # Announce registration
2063
    REGISTER_USER.send(sender=None, user=user, registration=registration)
2064

2065 2066
    create_comments_service_user(user)

2067 2068 2069
    # Check if we system is configured to skip activation email for the current user.
    skip_email = skip_activation_email(
        user, do_external_auth, running_pipeline, third_party_provider,
2070
    )
2071 2072

    if skip_email:
2073
        registration.activate()
2074
        _enroll_user_in_pending_courses(user)  # Enroll student in any pending courses
2075 2076
    else:
        compose_and_send_activation_email(user, profile, registration)
2077

2078 2079 2080
    # 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.
2081
    new_user = authenticate(username=user.username, password=params['password'])
Sarina Canelake committed
2082
    login(request, new_user)
2083 2084
    request.session.set_expiry(0)

2085 2086 2087 2088 2089
    try:
        record_registration_attributions(request, new_user)
    # Don't prevent a user from registering due to attribution errors.
    except Exception:   # pylint: disable=broad-except
        log.exception('Error while attributing cookies to user registration.')
2090

2091 2092
    # 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
2093 2094
    if new_user is not None:
        AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
2095

Sarina Canelake committed
2096 2097
    if do_external_auth:
        eamap.user = new_user
2098
        eamap.dtsignup = datetime.datetime.now(UTC)
ichuang committed
2099
        eamap.save()
2100 2101
        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
2102

2103
        if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
2104
            log.info('bypassing activation email')
Sarina Canelake committed
2105 2106 2107
            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
2108

Will Daly committed
2109 2110
    return new_user

2111

2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159
def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider):
    """
    Return `True` if activation email should be skipped.

    Skip email if we are:
        1. Doing load testing.
        2. Random user generation for other forms of testing.
        3. External auth bypassing activation.
        4. Have the platform configured to not require e-mail activation.
        5. Registering a new user using a trusted third party provider (with skip_email_verification=True)

    Note that this feature is only tested as a flag set one way or
    the other for *new* systems. we need to be careful about
    changing settings on a running system to make sure no users are
    left in an inconsistent state (or doing a migration if they are).

    Arguments:
        user (User): Django User object for the current user.
        do_external_auth (bool): True if external authentication is in progress.
        running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication.
        third_party_provider (ProviderConfig): An instance of third party provider configuration.

    Returns:
        (bool): `True` if account activation email should be skipped, `False` if account activation email should be
            sent.
    """
    sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email')

    # Email is valid if the SAML assertion email matches the user account email or
    # no email was provided in the SAML assertion. Some IdP's use a callback
    # to retrieve additional user account information (including email) after the
    # initial account creation.
    valid_email = (
        sso_pipeline_email == user.email or (
            sso_pipeline_email is None and
            third_party_provider and
            getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY
        )
    )

    return (
        settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or
        settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or
        (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or
        (third_party_provider and third_party_provider.skip_email_verification and valid_email)
    )


2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178
def _enroll_user_in_pending_courses(student):
    """
    Enroll student in any pending courses he/she may have.
    """
    ceas = CourseEnrollmentAllowed.objects.filter(email=student.email)
    for cea in ceas:
        if cea.auto_enroll:
            enrollment = CourseEnrollment.enroll(student, cea.course_id)
            manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student.email)
            if manual_enrollment_audit is not None:
                # get the enrolled by user and reason from the ManualEnrollmentAudit table.
                # then create a new ManualEnrollmentAudit table entry for the same email
                # different transition state.
                ManualEnrollmentAudit.create_manual_enrollment_audit(
                    manual_enrollment_audit.enrolled_by, student.email, ALLOWEDTOENROLL_TO_ENROLLED,
                    manual_enrollment_audit.reason, enrollment
                )


2179
def record_affiliate_registration_attribution(request, user):
2180 2181 2182 2183 2184
    """
    Attribute this user's registration to the referring affiliate, if
    applicable.
    """
    affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
2185
    if user and affiliate_id:
2186
        UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
2187 2188


2189 2190 2191 2192 2193 2194 2195 2196 2197
def record_utm_registration_attribution(request, user):
    """
    Attribute this user's registration to the latest UTM referrer, if
    applicable.
    """
    utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name
    utm_cookie = request.COOKIES.get(utm_cookie_name)
    if user and utm_cookie:
        utm = json.loads(utm_cookie)
2198 2199 2200 2201 2202 2203 2204 2205
        for utm_parameter_name in REGISTRATION_UTM_PARAMETERS:
            utm_parameter = utm.get(utm_parameter_name)
            if utm_parameter:
                UserAttribute.set_user_attribute(
                    user,
                    REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name),
                    utm_parameter
                )
2206 2207 2208 2209 2210 2211
        created_at_unixtime = utm.get('created_at')
        if created_at_unixtime:
            # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds.
            # PYTHON: time.time()      => 1475590280.823698
            # JS: new Date().getTime() => 1475590280823
            created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC)
2212 2213 2214 2215 2216
            UserAttribute.set_user_attribute(
                user,
                REGISTRATION_UTM_CREATED_AT,
                created_at_datetime
            )
2217 2218 2219 2220 2221 2222 2223 2224 2225 2226


def record_registration_attributions(request, user):
    """
    Attribute this user's registration based on referrer cookies.
    """
    record_affiliate_registration_attribution(request, user)
    record_utm_registration_attribution(request, user)


2227 2228 2229 2230
@csrf_exempt
def create_account(request, post_override=None):
    """
    JSON call to create new edX account.
2231
    Used by form in signup_modal.html, which is included into header.html
2232
    """
2233 2234 2235 2236 2237 2238 2239
    # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
    if not configuration_helpers.get_value(
            'ALLOW_PUBLIC_ACCOUNT_CREATION',
            settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)
    ):
        return HttpResponseForbidden(_("Account creation not allowed."))

2240 2241
    warnings.warn("Please use RegistrationView instead.", DeprecationWarning)

2242
    try:
Will Daly committed
2243
        user = create_account_with_params(request, post_override or request.POST)
2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256
    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
        )

2257
    redirect_url = None  # The AJAX method calling should know the default destination upon success
2258 2259

    # Resume the third-party-auth pipeline if necessary.
2260
    if third_party_auth.is_enabled() and pipeline.running(request):
2261 2262 2263
        running_pipeline = pipeline.get(request)
        redirect_url = pipeline.get_complete_url(running_pipeline['backend'])

2264 2265
    response = JsonResponse({
        'success': True,
2266
        'redirect_url': redirect_url,
2267
    })
Will Daly committed
2268
    set_logged_in_cookies(request, response, user)
2269 2270
    return response

Matthew Mongeau committed
2271

2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292
def str2bool(s):
    s = str(s)
    return s.lower() in ('yes', 'true', 't', '1')


def _clean_roles(roles):
    """ Clean roles.

    Strips whitespace from roles, and removes empty items.

    Args:
        roles (str[]): List of role names.

    Returns:
        str[]
    """
    roles = [role.strip() for role in roles]
    roles = [role for role in roles if role]
    return roles


2293
def auto_auth(request):
2294
    """
2295
    Create or configure a user account, then log in as that user.
Matthew Mongeau committed
2296

2297 2298
    Enabled only when
    settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
2299

2300 2301 2302 2303 2304
    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`
2305
    * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
2306
    * `no_login`: Define this to create the user but not login
2307 2308 2309
    * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or
        course home page if course_id is defined, otherwise it will redirect to dashboard
    * `redirect_to`: will redirect to to this url
2310
    * `is_active` : make/update account with status provided as 'is_active'
2311 2312 2313
    If username, email, or password are not provided, use
    randomly generated credentials.
    """
Matthew Mongeau committed
2314

2315
    # Generate a unique name to use if none provided
2316
    generated_username = uuid.uuid4().hex[0:30]
2317 2318

    # Use the params from the request, otherwise use these defaults
2319 2320 2321
    username = request.GET.get('username', generated_username)
    password = request.GET.get('password', username)
    email = request.GET.get('email', username + "@example.com")
2322
    full_name = request.GET.get('full_name', username)
2323 2324 2325 2326 2327
    is_staff = str2bool(request.GET.get('staff', False))
    is_superuser = str2bool(request.GET.get('superuser', False))
    course_id = request.GET.get('course_id')
    redirect_to = request.GET.get('redirect_to')
    is_active = str2bool(request.GET.get('is_active', True))
2328

2329
    # Valid modes: audit, credit, honor, no-id-professional, professional, verified
2330 2331
    enrollment_mode = request.GET.get('enrollment_mode', 'honor')

2332 2333 2334
    # Parse roles, stripping whitespace, and filtering out empty strings
    roles = _clean_roles(request.GET.get('roles', '').split(','))
    course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(','))
2335

2336
    redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to
2337
    login_when_done = 'no_login' not in request.GET
2338

2339 2340 2341 2342 2343 2344 2345 2346 2347
    form = AccountCreationForm(
        data={
            'username': username,
            'email': email,
            'password': password,
            'name': full_name,
        },
        tos_required=False
    )
2348

2349 2350
    # Attempt to create the account.
    # If successful, this will return a tuple containing
2351 2352
    # the new user object.
    try:
2353
        user, profile, reg = _do_create_account(form)
2354
    except (AccountValidationError, ValidationError):
2355
        # Attempt to retrieve the existing user.
2356
        user = User.objects.get(username=username)
2357 2358
        user.email = email
        user.set_password(password)
2359
        user.is_active = is_active
2360
        user.save()
2361
        profile = UserProfile.objects.get(user=user)
2362
        reg = Registration.objects.get(user=user)
2363
    except PermissionDenied:
2364
        return HttpResponseForbidden(_('Account creation not allowed.'))
2365

2366 2367 2368
    user.is_staff = is_staff
    user.is_superuser = is_superuser
    user.save()
2369

2370
    if is_active:
2371 2372
        reg.activate()
        reg.save()
2373

2374 2375 2376 2377 2378 2379
    # 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()

2380 2381
    _create_or_set_user_attribute_created_on_site(user, request.site)

2382
    # Enroll the user in a course
2383 2384 2385
    course_key = None
    if course_id:
        course_key = CourseLocator.from_string(course_id)
2386
        CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
2387

2388 2389 2390 2391 2392 2393
        # Apply the roles
        for role in roles:
            assign_role(course_key, user, role)

        for role in course_access_roles:
            CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role)
2394

2395
    # Log in as the user
2396 2397 2398
    if login_when_done:
        user = authenticate(username=username, password=password)
        login(request, user)
2399

2400 2401
    create_comments_service_user(user)

2402
    if redirect_when_done:
2403
        if redirect_to:
2404
            # Redirect to page specified by the client
2405 2406
            redirect_url = redirect_to
        elif course_id:
2407
            # Redirect to the course homepage (in LMS) or outline page (in Studio)
2408
            try:
2409
                redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
2410
            except NoReverseMatch:
2411
                redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
2412
        else:
2413
            # Redirect to the learner dashboard (in LMS) or homepage (in Studio)
2414 2415 2416 2417 2418 2419
            try:
                redirect_url = reverse('dashboard')
            except NoReverseMatch:
                redirect_url = reverse('home')

        return redirect(redirect_url)
2420
    else:
2421
        response = JsonResponse({
2422
            'created_status': 'Logged in' if login_when_done else 'Created',
2423 2424 2425
            'username': username,
            'email': email,
            'password': password,
2426
            'user_id': user.id,  # pylint: disable=no-member
2427 2428
            'anonymous_id': anonymous_id_for_user(user, None),
        })
2429 2430
    response.set_cookie('csrftoken', csrf(request)['csrf_token'])
    return response
2431

2432

2433
@ensure_csrf_cookie
Piotr Mitros committed
2434
def activate_account(request, key):
2435
    """When link in activation e-mail is clicked"""
2436 2437 2438 2439 2440 2441 2442 2443 2444 2445

    # If request is in Studio call the appropriate view
    if theming_helpers.get_project_root_name().lower() == u'cms':
        return activate_account_studio(request, key)

    try:
        registration = Registration.objects.get(activation_key=key)
    except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
        messages.error(
            request,
2446
            HTML(_(
2447 2448 2449 2450 2451 2452 2453
                '{html_start}Your account could not be activated{html_end}'
                'Something went wrong, please <a href="{support_url}">contact support</a> to resolve this issue.'
            )).format(
                support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
                html_start=HTML('<p class="message-title">'),
                html_end=HTML('</p>'),
            ),
2454
            extra_tags='account-activation aa-icon'
2455 2456 2457 2458
        )
    else:
        if not registration.user.is_active:
            registration.activate()
2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470
            # Success message for logged in users.
            message = _('{html_start}Success{html_end} You have activated your account.')

            if not request.user.is_authenticated():
                # Success message for logged out users
                message = _(
                    '{html_start}Success! You have activated your account.{html_end}'
                    'You will now receive email updates and alerts from us related to'
                    ' the courses you are enrolled in. Sign In to continue.'
                )

            # Add message for later use.
2471 2472
            messages.success(
                request,
2473
                HTML(message).format(
2474 2475 2476
                    html_start=HTML('<p class="message-title">'),
                    html_end=HTML('</p>'),
                ),
2477
                extra_tags='account-activation aa-icon',
2478 2479 2480 2481
            )
        else:
            messages.info(
                request,
2482
                HTML(_('{html_start}This account has already been activated.{html_end}')).format(
2483 2484 2485
                    html_start=HTML('<p class="message-title">'),
                    html_end=HTML('</p>'),
                ),
2486
                extra_tags='account-activation aa-icon',
2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507
            )

        # Enroll student in any pending courses he/she may have if auto_enroll flag is set
        _enroll_user_in_pending_courses(registration.user)

    return redirect('dashboard')


@ensure_csrf_cookie
def activate_account_studio(request, key):
    """
    When link in activation e-mail is clicked and the link belongs to studio.
    """
    try:
        registration = Registration.objects.get(activation_key=key)
    except (Registration.DoesNotExist, Registration.MultipleObjectsReturned):
        return render_to_response(
            "registration/activation_invalid.html",
            {'csrf': csrf(request)['csrf_token']}
        )
    else:
2508 2509
        user_logged_in = request.user.is_authenticated()
        already_active = True
2510 2511
        if not registration.user.is_active:
            registration.activate()
2512
            already_active = False
2513

2514
        # Enroll student in any pending courses he/she may have if auto_enroll flag is set
2515
        _enroll_user_in_pending_courses(registration.user)
2516

2517
        return render_to_response(
2518 2519 2520 2521 2522 2523
            "registration/activation_complete.html",
            {
                'user_logged_in': user_logged_in,
                'already_active': already_active
            }
        )
Piotr Mitros committed
2524

2525

2526
@csrf_exempt
2527
@require_POST
Piotr Mitros committed
2528
def password_reset(request):
2529
    """ Attempts to send a password reset e-mail. """
2530 2531
    # Add some rate limiting here by re-using the RateLimitMixin as a helper class
    limiter = BadRequestRateLimiter()
2532
    if limiter.is_rate_limit_exceeded(request):
2533 2534 2535
        AUDIT_LOG.warning("Rate limit exceeded in password_reset")
        return HttpResponseForbidden()

2536
    form = PasswordResetFormNoActive(request.POST)
Piotr Mitros committed
2537
    if form.is_valid():
Calen Pennington committed
2538
        form.save(use_https=request.is_secure(),
2539
                  from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
2540
                  request=request)
2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552
        # 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,
            }
        )
Ahsan committed
2553
        destroy_oauth_tokens(request.user)
2554 2555 2556 2557 2558
    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
2559 2560 2561 2562
    return JsonResponse({
        'success': True,
        'value': render_to_string('registration/password_reset_done.html', {}),
    })
David Baumgold committed
2563

2564

2565 2566 2567 2568 2569 2570 2571 2572
def uidb36_to_uidb64(uidb36):
    """
    Needed to support old password reset URLs that use base36-encoded user IDs
    https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231
    Args:
        uidb36: base36-encoded user ID

    Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID
2573
    """
2574 2575 2576 2577 2578 2579 2580
    try:
        uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
    except ValueError:
        uidb64 = '1'  # dummy invalid ID (incorrect padding for base64)
    return uidb64


2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604
def validate_password(password):
    """
    Validate password overall strength if ENFORCE_PASSWORD_POLICY is enable
    otherwise only validate the length of the password.

    Args:
        password: the user's proposed new password.

    Returns:
        err_msg: an error message if there's a violation of one of the password
            checks. Otherwise, `None`.
    """

    try:
        if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False):
            validate_password_strength(password)
        else:
            validate_password_length(password)

    except ValidationError as err:
        return _('Password: ') + '; '.join(err.messages)


def validate_password_security_policy(user, password):
2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617
    """
    Tie in password policy enforcement as an optional level of
    security protection

    Args:
        user: the user object whose password we're checking.
        password: the user's proposed new password.

    Returns:
        err_msg: an error message if there's a violation of one of the password
            checks. Otherwise, `None`.
    """

2618
    err_msg = None
2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643
    # also, check the password reuse policy
    if not PasswordHistory.is_allowable_password_reuse(user, password):
        if user.is_staff:
            num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
        else:
            num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE']
        # Because of how ngettext is, splitting the following into shorter lines would be ugly.
        # pylint: disable=line-too-long
        err_msg = ungettext(
            "You are re-using a password that you have used recently. You must have {num} distinct password before reusing a previous password.",
            "You are re-using a password that you have used recently. You must have {num} distinct passwords before reusing a previous password.",
            num_distinct
        ).format(num=num_distinct)

    # also, check to see if passwords are getting reset too frequent
    if PasswordHistory.is_password_reset_too_soon(user):
        num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS']
        # Because of how ngettext is, splitting the following into shorter lines would be ugly.
        # pylint: disable=line-too-long
        err_msg = ungettext(
            "You are resetting passwords too frequently. Due to security policies, {num} day must elapse between password resets.",
            "You are resetting passwords too frequently. Due to security policies, {num} days must elapse between password resets.",
            num_days
        ).format(num=num_days)

2644
    return err_msg
2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655


def password_reset_confirm_wrapper(request, uidb36=None, token=None):
    """
    A wrapper around django.contrib.auth.views.password_reset_confirm.
    Needed because we want to set the user as active at this step.
    We also optionally do some additional password policy checks.
    """
    # convert old-style base36-encoded user id to base64
    uidb64 = uidb36_to_uidb64(uidb36)
    platform_name = {
2656
        "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
2657
    }
2658 2659 2660 2661
    try:
        uid_int = base36_to_int(uidb36)
        user = User.objects.get(id=uid_int)
    except (ValueError, User.DoesNotExist):
2662 2663 2664 2665 2666
        # if there's any error getting a user, just let django's
        # password_reset_confirm function handle it.
        return password_reset_confirm(
            request, uidb64=uidb64, token=token, extra_context=platform_name
        )
2667 2668 2669

    if request.method == 'POST':
        password = request.POST['new_password1']
2670 2671 2672 2673 2674 2675 2676 2677 2678 2679
        valid_link = False
        error_message = validate_password_security_policy(user, password)
        if not error_message:
            # if security is not violated, we need to validate password
            error_message = validate_password(password)
            if error_message:
                # password reset link will be valid if there is no security violation
                valid_link = True

        if error_message:
2680
            # We have a password reset attempt which violates some security
2681
            # policy, or any other validation. Use the existing Django template to communicate that
2682 2683
            # back to the user.
            context = {
2684
                'validlink': valid_link,
2685 2686
                'form': None,
                'title': _('Password reset unsuccessful'),
2687
                'err_msg': error_message,
2688 2689 2690 2691 2692
            }
            context.update(platform_name)
            return TemplateResponse(
                request, 'registration/password_reset_confirm.html', context
            )
2693

2694 2695
        # remember what the old password hash is before we call down
        old_password_hash = user.password
2696

2697 2698 2699
        response = password_reset_confirm(
            request, uidb64=uidb64, token=token, extra_context=platform_name
        )
2700

2701 2702 2703
        # If password reset was unsuccessful a template response is returned (status_code 200).
        # Check if form is invalid then show an error to the user.
        # Note if password reset was successful we get response redirect (status_code 302).
2704 2705 2706 2707 2708 2709 2710 2711 2712 2713
        if response.status_code == 200:
            form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False
            if not form_valid:
                log.warning(
                    u'Unable to reset password for user [%s] because form is not valid. '
                    u'A possible cause is that the user had an invalid reset token',
                    user.username,
                )
                response.context_data['err_msg'] = _('Error in resetting your password. Please try again.')
                return response
2714

2715 2716
        # get the updated user
        updated_user = User.objects.get(id=uid_int)
2717

2718 2719 2720 2721 2722 2723 2724 2725 2726
        # did the password hash change, if so record it in the PasswordHistory
        if updated_user.password != old_password_hash:
            entry = PasswordHistory()
            entry.create(updated_user)

    else:
        response = password_reset_confirm(
            request, uidb64=uidb64, token=token, extra_context=platform_name
        )
2727

2728 2729 2730 2731
        response_was_successful = response.context_data.get('validlink')
        if response_was_successful and not user.is_active:
            user.is_active = True
            user.save()
2732

2733
    return response
2734

Calen Pennington committed
2735

2736
def reactivation_email_for_user(user):
2737
    try:
2738
        registration = Registration.objects.get(user=user)
2739
    except Registration.DoesNotExist:
David Baumgold committed
2740 2741 2742 2743
        return JsonResponse({
            "success": False,
            "error": _('No inactive user with this e-mail exists'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
2744

2745
    try:
2746
        context = generate_activation_email_context(user, registration)
2747 2748 2749 2750 2751 2752 2753 2754 2755 2756
    except ObjectDoesNotExist:
        log.error(
            u'Unable to send reactivation email due to unavailable profile for the user "%s"',
            user.username,
            exc_info=True
        )
        return JsonResponse({
            "success": False,
            "error": _('Unable to send reactivation email')
        })
Matthew Mongeau committed
2757

David Baumgold committed
2758
    subject = render_to_string('emails/activation_email_subject.txt', context)
2759
    subject = ''.join(subject.splitlines())
David Baumgold committed
2760
    message = render_to_string('emails/activation_email.txt', context)
2761
    from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
2762
    from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
2763

2764
    try:
2765
        user.email_user(subject, message, from_address)
David Baumgold committed
2766
    except Exception:  # pylint: disable=broad-except
asadiqbal committed
2767
        log.error(
2768 2769 2770
            u'Unable to send reactivation email from "%s" to "%s"',
            from_address,
            user.email,
asadiqbal committed
2771 2772
            exc_info=True
        )
David Baumgold committed
2773 2774 2775 2776
        return JsonResponse({
            "success": False,
            "error": _('Unable to send reactivation email')
        })  # TODO: this should be status code 500  # pylint: disable=fixme
Matthew Mongeau committed
2777

David Baumgold committed
2778
    return JsonResponse({"success": True})
Victor Shnayder committed
2779

2780

2781
def validate_new_email(user, new_email):
2782
    """
2783 2784
    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.
2785 2786 2787 2788 2789 2790 2791 2792
    """
    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.'))
2793

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

2797

2798
def do_email_change_request(user, new_email, activation_key=None):
2799 2800 2801 2802 2803
    """
    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.
    """
2804
    pec_list = PendingEmailChange.objects.filter(user=user)
Matthew Mongeau committed
2805
    if len(pec_list) == 0:
2806 2807
        pec = PendingEmailChange()
        pec.user = user
2808
    else:
2809 2810
        pec = pec_list[0]

2811 2812 2813 2814
    # if activation_key is not passing as an argument, generate a random key
    if not activation_key:
        activation_key = uuid.uuid4().hex

2815 2816
    pec.new_email = new_email
    pec.activation_key = activation_key
2817 2818
    pec.save()

2819 2820 2821 2822 2823
    context = {
        'key': pec.activation_key,
        'old_email': user.email,
        'new_email': pec.new_email
    }
2824

2825
    subject = render_to_string('emails/email_change_subject.txt', context)
2826
    subject = ''.join(subject.splitlines())
2827

2828 2829
    message = render_to_string('emails/email_change.txt', context)

2830
    from_address = configuration_helpers.get_value(
2831 2832 2833
        'email_from_address',
        settings.DEFAULT_FROM_EMAIL
    )
2834
    try:
2835
        mail.send_mail(subject, message, from_address, [pec.new_email])
2836
    except Exception:  # pylint: disable=broad-except
2837
        log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
2838
        raise ValueError(_('Unable to send email activation link. Please try again later.'))
2839

2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852
    # 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,
        }
    )

2853 2854

@ensure_csrf_cookie
Sarina Canelake committed
2855 2856 2857
def confirm_email_change(request, key):  # pylint: disable=unused-argument
    """
    User requested a new e-mail. This is called when the activation
2858
    link is clicked. We confirm with the old e-mail, and update
2859
    """
2860
    with transaction.atomic():
2861 2862 2863
        try:
            pec = PendingEmailChange.objects.get(activation_key=key)
        except PendingEmailChange.DoesNotExist:
2864
            response = render_to_response("invalid_email_key.html", {})
2865
            transaction.set_rollback(True)
2866
            return response
2867 2868 2869 2870 2871 2872

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

2874
        if len(User.objects.filter(email=pec.new_email)) != 0:
2875
            response = render_to_response("email_exists.html", {})
2876
            transaction.set_rollback(True)
2877
            return response
2878 2879 2880 2881

        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
2882 2883
        u_prof = UserProfile.objects.get(user=user)
        meta = u_prof.get_meta()
2884 2885
        if 'old_emails' not in meta:
            meta['old_emails'] = []
2886
        meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
Sarina Canelake committed
2887 2888
        u_prof.set_meta(meta)
        u_prof.save()
2889 2890
        # Send it to the old email...
        try:
asadiqbal committed
2891 2892 2893
            user.email_user(
                subject,
                message,
2894
                configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
asadiqbal committed
2895
            )
Sarina Canelake committed
2896
        except Exception:    # pylint: disable=broad-except
2897
            log.warning('Unable to send confirmation email to old address', exc_info=True)
2898
            response = render_to_response("email_change_failed.html", {'email': user.email})
2899
            transaction.set_rollback(True)
2900
            return response
2901

2902 2903 2904 2905 2906
        user.email = pec.new_email
        user.save()
        pec.delete()
        # And send it to the new email...
        try:
asadiqbal committed
2907 2908 2909
            user.email_user(
                subject,
                message,
2910
                configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
asadiqbal committed
2911
            )
Sarina Canelake committed
2912
        except Exception:  # pylint: disable=broad-except
2913
            log.warning('Unable to send confirmation email to new address', exc_info=True)
2914
            response = render_to_response("email_change_failed.html", {'email': pec.new_email})
2915
            transaction.set_rollback(True)
2916
            return response
2917

2918 2919
        response = render_to_response("email_change_successful.html", address_context)
        return response
2920

2921

2922 2923
@require_POST
@login_required
2924 2925 2926 2927 2928 2929
@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")
2930
    course_key = CourseKey.from_string(course_id)
2931 2932
    receive_emails = request.POST.get("receive_emails")
    if receive_emails:
2933
        optout_object = Optout.objects.filter(user=user, course_id=course_key)
2934 2935
        if optout_object:
            optout_object.delete()
2936 2937 2938 2939
        log.info(
            u"User %s (%s) opted in to receive emails from course %s",
            user.username,
            user.email,
2940 2941 2942 2943 2944 2945 2946
            course_id,
        )
        track.views.server_track(
            request,
            "change-email-settings",
            {"receive_emails": "yes", "course": course_id},
            page='dashboard',
2947
        )
2948
    else:
2949
        Optout.objects.get_or_create(user=user, course_id=course_key)
2950 2951 2952 2953
        log.info(
            u"User %s (%s) opted out of receiving emails from course %s",
            user.username,
            user.email,
2954 2955 2956 2957 2958 2959 2960
            course_id,
        )
        track.views.server_track(
            request,
            "change-email-settings",
            {"receive_emails": "no", "course": course_id},
            page='dashboard',
2961
        )
2962

David Baumgold committed
2963
    return JsonResponse({"success": True})
2964 2965


2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976
class LogoutView(TemplateView):
    """
    Logs out user and redirects.

    The template should load iframes to log the user out of OpenID Connect services.
    See http://openid.net/specs/openid-connect-logout-1_0.html.
    """
    oauth_client_ids = []
    template_name = 'logout.html'

    # Keep track of the page to which the user should ultimately be redirected.
2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991
    default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/'

    @property
    def target(self):
        """
        If a redirect_url is specified in the querystring for this request, and the value is a url
        with the same host, the view will redirect to this page after rendering the template.
        If it is not specified, we will use the default target url.
        """
        target_url = self.request.GET.get('redirect_url')

        if target_url and is_safe_url(target_url, self.request.META.get('HTTP_HOST')):
            return target_url
        else:
            return self.default_target
2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002

    def dispatch(self, request, *args, **kwargs):  # pylint: disable=missing-docstring
        # We do not log here, because we have a handler registered to perform logging on successful logouts.
        request.is_from_logout = True

        # Get the list of authorized clients before we clear the session.
        self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, [])

        logout(request)

        # If we don't need to deal with OIDC logouts, just redirect the user.
3003
        if self.oauth_client_ids:
3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048
            response = super(LogoutView, self).dispatch(request, *args, **kwargs)
        else:
            response = redirect(self.target)

        # Clear the cookie used by the edx.org marketing site
        delete_logged_in_cookies(response)

        return response

    def _build_logout_url(self, url):
        """
        Builds a logout URL with the `no_redirect` query string parameter.

        Args:
            url (str): IDA logout URL

        Returns:
            str
        """
        scheme, netloc, path, query_string, fragment = urlsplit(url)
        query_params = parse_qs(query_string)
        query_params['no_redirect'] = 1
        new_query_string = urlencode(query_params, doseq=True)
        return urlunsplit((scheme, netloc, path, new_query_string, fragment))

    def get_context_data(self, **kwargs):
        context = super(LogoutView, self).get_context_data(**kwargs)

        # Create a list of URIs that must be called to log the user out of all of the IDAs.
        uris = Client.objects.filter(client_id__in=self.oauth_client_ids,
                                     logout_uri__isnull=False).values_list('logout_uri', flat=True)

        referrer = self.request.META.get('HTTP_REFERER', '').strip('/')
        logout_uris = []

        for uri in uris:
            if not referrer or (referrer and not uri.startswith(referrer)):
                logout_uris.append(self._build_logout_url(uri))

        context.update({
            'target': self.target,
            'logout_uris': logout_uris,
        })

        return context