views.py 121 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 54 55 56
import dogstats_wrapper as dog_stats_api
import openedx.core.djangoapps.external_auth.views
import third_party_auth
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
57
from certificates.models import (  # pylint: disable=import-error
58 59 60
    CertificateStatuses,
    GeneratedCertificate,
    certificate_status_for_student
61
)
62
from course_modes.models import CourseMode
63
from courseware.access import has_access
64
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date  # pylint: disable=import-error
65
from django_comment_common.models import assign_role
66
from edxmako.shortcuts import render_to_response, render_to_string
67
from eventtracking import tracker
68
from lms.djangoapps.commerce.utils import EcommerceService  # pylint: disable=import-error
69
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
70
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification  # pylint: disable=import-error
71 72
# Note that this lives in LMS, so this dependency should be refactored.
from notification_prefs.views import enable_notifications
73
from openedx.core.djangoapps import monitoring_utils
74
from openedx.core.djangoapps.catalog.utils import get_programs_with_type
75
from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course
76
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
77 78 79 80
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
81 82
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
83
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
84
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
asadiqbal committed
85
from openedx.core.djangoapps.theming import helpers as theming_helpers
86
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import course_home_url_name
from openedx.features.enterprise_support.api import get_dashboard_consent_notification
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,
103
    CourseAccessRole,
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
    CourseEnrollment,
    CourseEnrollmentAllowed,
    CourseEnrollmentAttribute,
    DashboardConfiguration,
    LinkedInAddToProfileConfiguration,
    LoginFailures,
    ManualEnrollmentAudit,
    PasswordHistory,
    PendingEmailChange,
    Registration,
    RegistrationCookieConfiguration,
    UserAttribute,
    UserProfile,
    UserSignupSource,
    UserStanding,
    anonymous_id_for_user,
    create_comments_service_user,
121
    unique_id_for_user
122
)
123
from student.signals import REFUND_ORDER
124 125 126 127 128 129
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
130
from util.password_policy_validators import validate_password_length, validate_password_strength
131
from xmodule.modulestore.django import modulestore
132

133
log = logging.getLogger("edx.student")
134
AUDIT_LOG = logging.getLogger("audit")
135
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display')  # pylint: disable=invalid-name
136
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
137 138
# Used as the name of the user attribute for tracking affiliate registrations
REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
139 140 141 142 143 144 145 146
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'
147
# used to announce a registration
148
REGISTER_USER = Signal(providing_args=["user", "registration"])
149

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

Sarina Canelake committed
153

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

162

163 164 165 166
# 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
167
def index(request, extra_context=None, user=AnonymousUser()):
168
    """
ichuang committed
169 170 171 172
    Render the edX main page.

    extra_context is used to allow immediate display of certain modal windows, eg signup,
    as used by external_auth.
173
    """
Sarina Canelake committed
174 175
    if extra_context is None:
        extra_context = {}
176

177
    programs_list = []
Renzo Lucioni committed
178 179
    courses = get_courses(user)

180 181 182 183
    if configuration_helpers.get_value(
            "ENABLE_COURSE_SORTING_BY_START_DATE",
            settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"],
    ):
184 185 186
        courses = sort_by_start_date(courses)
    else:
        courses = sort_by_announcement(courses)
187

188
    context = {'courses': courses}
Chris Dodge committed
189

190
    context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html')
191 192

    # This appears to be an unused context parameter, at least for the master templates...
193
    context['show_partners'] = configuration_helpers.get_value('show_partners', True)
194 195 196

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

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

205 206
    # allow for theme override of the courses list
    context['courses_list'] = theming_helpers.get_template_path('courses_list.html')
207 208

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

211 212
    # Add marketable programs to the context.
    context['programs_list'] = get_programs_with_type(request.site, include_hidden=False)
213

ichuang committed
214
    return render_to_response('index.html', context)
215

216

217 218 219 220 221
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.
    """
222
    return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
223 224


225
def cert_info(user, course_overview, course_mode):
226 227
    """
    Get the certificate info needed to render the dashboard section for the given
228 229 230 231 232 233 234 235
    student and course.

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

    Returns:
236
        dict: A dictionary with keys:
237 238
            'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or
                'certificate_earned_but_not_available'
239 240 241 242
            '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'
243
            'can_unenroll': if status allows for unenrollment
244
    """
245 246 247 248 249 250
    return _cert_info(
        user,
        course_overview,
        certificate_status_for_student(user, course_overview.id),
        course_mode
    )
251

Calen Pennington committed
252

253
def reverification_info(statuses):
Julia Hansbrough committed
254
    """
255
    Returns reverification-related information for *all* of user's enrollments whose
256
    reverification status is in statuses.
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271

    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]:
272
            reverifications[status].sort(key=lambda x: x.date)
273 274 275
    return reverifications


276
def get_course_enrollments(user, org_whitelist, org_blacklist):
277
    """
278 279 280 281
    Given a user, return a filtered set of his or her course enrollments.

    Arguments:
        user (User): the user in question.
282 283
        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.
284 285 286 287

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

291 292 293 294 295 296 297 298 299 300
        # 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

301 302
        # Filter out anything that is not in the whitelist.
        if org_whitelist and course_overview.location.org not in org_whitelist:
303 304
            continue

305 306
        # Conversely, filter out any enrollments in the blacklist.
        elif org_blacklist and course_overview.location.org in org_blacklist:
307
            continue
Julia Hansbrough committed
308

309 310 311 312 313
        # Else, include the enrollment.
        else:
            yield enrollment


314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338
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)


339
def _cert_info(user, course_overview, cert_status, course_mode):  # pylint: disable=unused-argument
340 341
    """
    Implements the logic for cert_info -- split out for testing.
342 343 344 345 346

    Arguments:
        user (User): A user.
        course_overview (CourseOverview): A course.
        course_mode (str): The enrollment mode (honor, verified, audit, etc.)
347
    """
348 349 350
    # simplify the status for the template using this lookup table
    template_state = {
        CertificateStatuses.generating: 'generating',
351
        CertificateStatuses.downloadable: 'downloadable',
352 353
        CertificateStatuses.notpassing: 'notpassing',
        CertificateStatuses.restricted: 'restricted',
Bill DeRusha committed
354
        CertificateStatuses.auditing: 'auditing',
355 356
        CertificateStatuses.audit_passing: 'auditing',
        CertificateStatuses.audit_notpassing: 'auditing',
357
        CertificateStatuses.unverified: 'unverified',
358 359
    }

360
    certificate_earned_but_not_available_status = 'certificate_earned_but_not_available'
361
    default_status = 'processing'
362

363 364 365 366 367
    default_info = {
        'status': default_status,
        'show_survey_button': False,
        'can_unenroll': True,
    }
368

369
    if cert_status is None:
370
        return default_info
371

372 373
    status = template_state.get(cert_status['status'], default_status)
    is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing')
374

375 376 377 378 379 380
    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
381

382 383 384 385 386
    if (
        course_overview.certificates_display_behavior == 'early_no_info' and
        is_hidden_status
    ):
        return default_info
387

Sarina Canelake committed
388 389
    status_dict = {
        'status': status,
390
        'mode': cert_status.get('mode', None),
391 392
        'linked_in_url': None,
        'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
Sarina Canelake committed
393
    }
394

395
    if not status == default_status and course_overview.end_of_course_survey_url is not None:
Sarina Canelake committed
396
        status_dict.update({
397
            'show_survey_button': True,
398
            'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)})
399
    else:
Sarina Canelake committed
400
        status_dict['show_survey_button'] = False
401

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

423 424 425 426
            # 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()
427 428

            # posting certificates to LinkedIn is not currently
429 430
            # supported in White Labels
            if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site():
431
                status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
432 433
                    course_overview.id,
                    course_overview.display_name,
434 435 436
                    cert_status.get('mode'),
                    cert_status['download_url']
                )
437

438
    if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}:
439 440
        cert_grade_percent = -1
        persisted_grade_percent = -1
441
        persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False)
442
        if persisted_grade is not None:
443 444 445 446 447 448
            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:
449 450 451
            # 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.
452
            return default_info
453

454 455
        status_dict['grade'] = unicode(max(cert_grade_percent, persisted_grade_percent))

Sarina Canelake committed
456
    return status_dict
457

Calen Pennington committed
458

459
@ensure_csrf_cookie
460
def signin_user(request):
461 462 463 464
    """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
465 466
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
467
    if request.user.is_authenticated():
468 469 470 471 472 473
        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:
474
            third_party_auth_error = _(unicode(msg))  # pylint: disable=translation-of-non-string
475
            break
476

477
    context = {
478
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
479 480 481 482
        # 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',
483
        'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
484
        'platform_name': configuration_helpers.get_value(
485 486 487
            'platform_name',
            settings.PLATFORM_NAME
        ),
488
        'third_party_auth_error': third_party_auth_error
489
    }
490

John Jarvis committed
491
    return render_to_response('login.html', context)
John Jarvis committed
492

493

494
@ensure_csrf_cookie
495
def register_user(request, extra_context=None):
496
    """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
497 498
    # Determine the URL to redirect to following login:
    redirect_to = get_next_url_for_login_page(request)
499
    if request.user.is_authenticated():
500
        return redirect(redirect_to)
501 502 503 504

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

506
    context = {
507
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in the header
508 509 510
        'email': '',
        'name': '',
        'running_pipeline': None,
511
        'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
512
        'platform_name': configuration_helpers.get_value(
513 514 515
            'platform_name',
            settings.PLATFORM_NAME
        ),
516 517
        'selected_provider': '',
        'username': '',
518
    }
519

520 521
    if extra_context is not None:
        context.update(extra_context)
522

523 524 525
    if context.get("extauth_domain", '').startswith(
            openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX
    ):
526
        return render_to_response('register-shib.html', context)
527 528 529

    # If third-party auth is enabled, prepopulate the form with data from the
    # selected provider.
530
    if third_party_auth.is_enabled() and pipeline.running(request):
531
        running_pipeline = pipeline.get(request)
532
        current_provider = provider.Registry.get_from_pipeline(running_pipeline)
533 534 535 536 537
        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)
538

John Jarvis committed
539 540 541
    return render_to_response('register.html', context)


Will Daly committed
542
def complete_course_mode_info(course_id, enrollment, modes=None):
543 544 545 546 547 548 549 550
    """
    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
551 552 553
    if modes is None:
        modes = CourseMode.modes_for_course_dict(course_id)

554
    mode_info = {'show_upsell': False, 'days_for_upsell': None}
555 556 557
    # 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:
558
        mode_info['show_upsell'] = True
vkaracic committed
559
        mode_info['verified_sku'] = modes['verified'].sku
560
        mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku
561
        # if there is an expiration date, find out how long from now it is
562
        if modes['verified'].expiration_datetime:
563
            today = datetime.datetime.now(UTC).date()
564
            mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days
565 566 567 568

    return mode_info


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

    return blocked

Sarina Canelake committed
597

598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
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),
    }


616 617 618 619 620 621 622 623 624 625 626 627 628
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)
629
    context = generate_activation_email_context(user, user_registration)
630 631 632 633
    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)
634 635
    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)
636 637 638 639 640 641 642
    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)


643
@login_required
Matthew Mongeau committed
644 645
@ensure_csrf_cookie
def dashboard(request):
646 647 648 649 650 651 652 653 654 655 656 657
    """
    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.

    """
658
    user = request.user
659 660
    if not UserProfile.objects.filter(user=user).exists():
        return redirect(reverse('account_settings'))
661

662
    platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
663 664 665 666 667 668 669 670
    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)
    )
671
    activation_email_support_link = configuration_helpers.get_value(
672 673
        'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK
    ) or settings.SUPPORT_SITE_LINK
674

675 676 677
    # 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))
678

679 680
    # Record how many courses there are so that we can get a better
    # understanding of usage patterns on prod.
681
    monitoring_utils.accumulate('num_courses', len(course_enrollments))
682

683
    # sort the enrollment pairs by the enrollment date
684
    course_enrollments.sort(key=lambda x: x.created, reverse=True)
685

Will Daly committed
686
    # Retrieve the course modes for each course
687
    enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
688
    __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
Will Daly committed
689
    course_modes_by_course = {
690 691 692 693 694
        course_id: {
            mode.slug: mode
            for mode in modes
        }
        for course_id, modes in unexpired_course_modes.iteritems()
Will Daly committed
695 696 697 698 699
    }

    # 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(
700
        course_enrollments, course_modes_by_course
Will Daly committed
701
    )
702

703
    course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True)
704

705 706 707 708 709 710 711 712 713 714 715 716
    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',
717 718 719 720 721
            {
                'email': user.email,
                'platform_name': platform_name,
                'activation_email_support_link': activation_email_support_link
            }
722 723 724
        )
    elif not user.is_active:
        banner_account_activation_message = render_to_string(
725
            'registration/activate_account_notice.html',
726
            {'email': user.email}
727
        )
728

729 730
    enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments)

731 732 733 734 735
    # Account activation message
    account_activation_messages = [
        message for message in messages.get_messages(request) if 'account-activation' in message.tags
    ]

736
    # Global staff can see what courses encountered an error on their dashboard
737
    staff_access = False
Victor Shnayder committed
738
    errored_courses = {}
739
    if has_access(user, 'staff', 'global'):
740
        # Show any courses that encountered an error on load
741 742 743
        staff_access = True
        errored_courses = modulestore().get_errored_courses()

744
    show_courseware_links_for = frozenset(
745 746
        enrollment.course_id for enrollment in course_enrollments
        if has_access(request.user, 'load', enrollment.course_overview)
747
    )
748

749
    # Find programs associated with course runs being displayed. This information
750 751
    # is passed in the template context to allow rendering of program-related
    # information on the dashboard.
752
    meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
753 754
    inverted_programs = meter.invert_programs()

Will Daly committed
755 756 757 758
    # 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 = {
759 760 761
        enrollment.course_id: complete_course_mode_info(
            enrollment.course_id, enrollment,
            modes=course_modes_by_course[enrollment.course_id]
Will Daly committed
762
        )
763
        for enrollment in course_enrollments
Will Daly committed
764 765
    }

766 767 768 769 770 771 772 773 774 775 776 777 778 779
    # 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.
780
    verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
Will Daly committed
781
    cert_statuses = {
782 783
        enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
        for enrollment in course_enrollments
Will Daly committed
784
    }
Victor Shnayder committed
785

786
    # only show email settings for Mongo course and when bulk email is turned on
787
    show_email_settings_for = frozenset(
788
        enrollment.course_id for enrollment in course_enrollments if (
789
            BulkEmailFlag.feature_enabled(enrollment.course_id)
790 791
        )
    )
792

793
    # Verification Attempts
794
    # Used to generate the "you must reverify for course x" banner
795 796
    verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user)
    verification_errors = get_verification_error_reasons_for_display(verification_error_codes)
797

Julia Hansbrough committed
798
    # Gets data for midcourse reverifications, if any are necessary or have failed
799
    statuses = ["approved", "denied", "pending", "must_reverify"]
800
    reverifications = reverification_info(statuses)
801

802 803 804 805 806 807 808 809 810 811 812
    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
        )
    )
813

814 815 816 817
    enrolled_courses_either_paid = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.is_paid_course()
    )
818

819 820 821 822
    # 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"])

823
    # Populate the Order History for the side-bar.
824
    order_history_list = order_history(user, course_org_filter=site_org_whitelist, org_filter_out_set=site_org_blacklist)
825

826
    # get list of courses having pre-requisites yet to be completed
827 828 829 830
    courses_having_prerequisites = frozenset(
        enrollment.course_id for enrollment in course_enrollments
        if enrollment.course_overview.pre_requisite_courses
    )
831 832
    courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)

833
    if 'notlive' in request.GET:
834 835
        redirect_message = _("The course you are looking for does not start until {date}.").format(
            date=request.GET['notlive']
836
        )
837 838 839 840
    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']
        )
841 842 843
    else:
        redirect_message = ''

844 845 846
    valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
    display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses

847
    context = {
848
        'enterprise_message': enterprise_message,
849
        'enrollment_message': enrollment_message,
850
        'redirect_message': redirect_message,
851
        'account_activation_messages': account_activation_messages,
852
        'course_enrollments': course_enrollments,
853
        'course_optouts': course_optouts,
854 855
        'banner_account_activation_message': banner_account_activation_message,
        'sidebar_account_activation_message': sidebar_account_activation_message,
856 857 858
        'staff_access': staff_access,
        'errored_courses': errored_courses,
        'show_courseware_links_for': show_courseware_links_for,
Will Daly committed
859
        'all_course_modes': course_mode_info,
860
        'cert_statuses': cert_statuses,
861
        'credit_statuses': _credit_statuses(user, course_enrollments),
862 863 864
        'show_email_settings_for': show_email_settings_for,
        'reverifications': reverifications,
        'verification_status': verification_status,
865
        'verification_status_by_course': verify_status_by_course,
866
        'verification_errors': verification_errors,
867
        'block_courses': block_courses,
868 869
        'denied_banner': denied_banner,
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
870
        'user': user,
871
        'logout_url': reverse('logout'),
872
        'platform_name': platform_name,
873
        'enrolled_courses_either_paid': enrolled_courses_either_paid,
874
        'provider_states': [],
875 876
        'order_history_list': order_history_list,
        'courses_requirements_not_met': courses_requirements_not_met,
877
        'nav_hidden': True,
878
        'inverted_programs': inverted_programs,
879
        'show_program_listing': ProgramsApiConfig.is_enabled(),
880
        'show_dashboard_tabs': True,
881
        'disable_courseware_js': True,
882
        'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
883
        'display_sidebar_on_dashboard': display_sidebar_on_dashboard,
884
    }
885

vkaracic committed
886
    ecommerce_service = EcommerceService()
887
    if ecommerce_service.is_enabled(request.user):
vkaracic committed
888 889 890 891 892
        context.update({
            'use_ecommerce_payment_flow': True,
            'ecommerce_payment_page': ecommerce_service.payment_page_url(),
        })

893 894 895
    response = render_to_response('dashboard.html', context)
    set_user_info_cookie(response, request)
    return response
896 897


898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926
@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)


927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947
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


948 949 950
def _create_recent_enrollment_message(course_enrollments, course_modes):  # pylint: disable=invalid-name
    """
    Builds a recent course enrollment message.
951

952 953
    Constructs a new message template based on any recent course enrollments
    for the student.
954 955

    Args:
956
        course_enrollments (list[CourseEnrollment]): a list of course enrollments.
Will Daly committed
957
        course_modes (dict): Mapping of course ID's to course mode dictionaries.
958 959 960

    Returns:
        A string representing the HTML message output from the message template.
Will Daly committed
961
        None if there are no recently enrolled courses.
962 963

    """
964
    recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments)
Will Daly committed
965 966

    if recently_enrolled_courses:
967 968 969 970 971 972 973 974 975 976 977 978
        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)
979
            for enrollment in recently_enrolled_courses
980
        )
Will Daly committed
981

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

984 985
        return render_to_string(
            'enrollment/course_enrollment_message.html',
986 987 988 989 990 991 992
            {
                '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
            }
993 994 995
        )


996 997 998
def _get_recently_enrolled_courses(course_enrollments):
    """
    Given a list of enrollments, filter out all but recent enrollments.
999 1000

    Args:
1001
        course_enrollments (list[CourseEnrollment]): A list of course enrollments.
1002 1003

    Returns:
1004
        list[CourseEnrollment]: A list of recent course enrollments.
1005 1006 1007 1008
    """
    seconds = DashboardConfiguration.current().recent_enrollment_time_delta
    time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds))
    return [
1009
        enrollment for enrollment in course_enrollments
1010 1011 1012 1013 1014 1015
        # 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
    ]


1016
def _allow_donation(course_modes, course_id, enrollment):
1017 1018 1019 1020 1021 1022 1023
    """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.
1024
        enrollment(CourseEnrollment): The enrollment object in which the user is enrolled
1025 1026 1027 1028 1029

    Returns:
        True if the course is allowing donations.

    """
1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044
    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
        )
1045 1046 1047 1048
    donations_enabled = configuration_helpers.get_value(
        'ENABLE_DONATIONS',
        DonationConfiguration.current().enabled
    )
1049 1050 1051 1052 1053
    return (
        donations_enabled and
        enrollment.mode in course_modes[course_id] and
        course_modes[course_id][enrollment.mode].min_price == 0
    )
1054 1055


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

1059 1060 1061
    email_opt_in = request.POST.get('email_opt_in')
    if email_opt_in is not None:
        email_opt_in_boolean = email_opt_in == 'true'
1062
        preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
1063 1064


1065
def _credit_statuses(user, course_enrollments):
1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080
    """
    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.
1081 1082
        course_enrollments (list[CourseEnrollment]): List of enrollments for the
            user.
1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098

    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:
1099
    >>> _credit_statuses(user, course_enrollments)
1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115
    {
        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

1116 1117 1118 1119
    # Feature flag off
    if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"):
        return {}

1120 1121 1122 1123 1124 1125
    request_status_by_course = {
        request["course_key"]: request["status"]
        for request in credit_api.get_credit_requests_for_user(user.username)
    }

    credit_enrollments = {
1126 1127
        enrollment.course_id: enrollment
        for enrollment in course_enrollments
1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150
        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):
1151
        course_key = CourseKey.from_string(unicode(eligibility["course_key"]))
1152
        providers_names = get_credit_provider_display_names(course_key)
1153 1154 1155 1156 1157
        status = {
            "course_key": unicode(course_key),
            "eligible": True,
            "deadline": eligibility["deadline"],
            "purchased": course_key in credit_enrollments,
1158
            "provider_name": make_providers_strings(providers_names),
1159
            "provider_status_url": None,
1160
            "provider_id": None,
1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183
            "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")
1184
                status["provider_id"] = provider_id
1185 1186 1187 1188 1189 1190

        statuses[course_key] = status

    return statuses


1191
@transaction.non_atomic_requests
1192
@require_POST
1193
@outer_atomic(read_committed=True)
1194
def change_enrollment(request, check_access=True):
1195 1196 1197
    """
    Modify the enrollment status for the logged-in user.

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

1200 1201 1202 1203 1204 1205 1206 1207 1208
    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
1209 1210
    happens. This function should only be called from an AJAX request, so
    the error messages in the responses should never actually be user-visible.
1211 1212 1213 1214 1215

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

    Keyword Args:
1216 1217 1218 1219 1220
        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.
1221 1222 1223 1224

    Returns:
        Response

1225
    """
1226
    # Get the user
1227
    user = request.user
1228

Julia Hansbrough committed
1229 1230 1231 1232
    # Ensure the user is authenticated
    if not user.is_authenticated():
        return HttpResponseForbidden()

1233
    # Ensure we received a course_id
1234
    action = request.POST.get("enrollment_action")
1235
    if 'course_id' not in request.POST:
David Baumgold committed
1236
        return HttpResponseBadRequest(_("Course id not specified"))
1237

Julia Hansbrough committed
1238
    try:
1239
        course_id = CourseKey.from_string(request.POST.get("course_id"))
Julia Hansbrough committed
1240 1241
    except InvalidKeyError:
        log.warning(
1242 1243 1244 1245
            u"User %s tried to %s with invalid course id: %s",
            user.username,
            action,
            request.POST.get("course_id"),
Julia Hansbrough committed
1246 1247 1248
        )
        return HttpResponseBadRequest(_("Invalid course id"))

1249
    if action == "enroll":
Don Mitchell committed
1250 1251 1252
        # 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):
1253 1254 1255 1256 1257
            log.warning(
                u"User %s tried to enroll in non-existent course %s",
                user.username,
                course_id
            )
Don Mitchell committed
1258 1259
            return HttpResponseBadRequest(_("Course id is invalid"))

1260 1261
        # Record the user's email opt-in preference
        if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
1262
            _update_email_opt_in(request, course_id.org)
1263

1264 1265
        available_modes = CourseMode.modes_for_course_dict(course_id)

1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276
        # 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)

1277 1278 1279
        # Check that auto enrollment is allowed for this course
        # (= the course is NOT behind a paywall)
        if CourseMode.can_auto_enroll(course_id):
1280
            # Enroll the user using the default mode (audit)
1281 1282 1283 1284
            # 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
1285
            # to "audit".
1286
            try:
1287 1288
                enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
                if enroll_mode:
1289
                    CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
1290
            except Exception:  # pylint: disable=broad-except
1291
                return HttpResponseBadRequest(_("Could not enroll"))
1292

1293 1294
        # If we have more than one course mode or professional ed is enabled,
        # then send the user to the choose your track page.
1295
        # (In the case of no-id-professional/professional ed, this will redirect to a page that
1296
        # funnels users directly into the verification / payment flow)
1297
        if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes):
1298 1299 1300 1301 1302 1303
            return HttpResponse(
                reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
            )

        # Otherwise, there is only one mode available (the default)
        return HttpResponse()
1304
    elif action == "unenroll":
1305 1306
        enrollment = CourseEnrollment.get_enrollment(user, course_id)
        if not enrollment:
1307
            return HttpResponseBadRequest(_("You are not enrolled in this course"))
1308

1309 1310
        certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode)
        if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES:
1311 1312
            return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course"))

1313
        CourseEnrollment.unenroll(user, course_id)
1314
        REFUND_ORDER.send(sender=None, course_enrollment=enrollment)
1315
        return HttpResponse()
1316
    else:
David Baumgold committed
1317
        return HttpResponseBadRequest(_("Enrollment action is invalid"))
1318

1319

1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352
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


1353
# Need different levels of logging
1354
@ensure_csrf_cookie
1355
def login_user(request, error=""):  # pylint: disable=too-many-statements,unused-argument
1356
    """AJAX request to log in the user."""
1357

1358 1359 1360 1361 1362 1363
    backend_name = None
    email = None
    password = None
    redirect_url = None
    response = None
    running_pipeline = None
1364
    third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
1365 1366 1367
    third_party_auth_successful = False
    trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
    user = None
1368
    platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
1369 1370 1371 1372 1373 1374 1375 1376 1377

    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']
1378 1379
        third_party_uid = running_pipeline['kwargs']['uid']
        requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
1380 1381

        try:
1382
            user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
1383 1384
            third_party_auth_successful = True
        except User.DoesNotExist:
1385
            AUDIT_LOG.info(
1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410
                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
1411
            )
1412

1413 1414
            return HttpResponse(message, content_type="text/plain", status=403)

1415 1416 1417 1418 1419
    else:

        if 'email' not in request.POST or 'password' not in request.POST:
            return JsonResponse({
                "success": False,
1420 1421 1422
                # TODO: User error message
                "value": _('There was an error receiving your login information. Please email us.'),
            })  # TODO: this should be status code 400
1423 1424 1425 1426 1427 1428 1429 1430 1431 1432

        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
1433

1434 1435 1436
    # 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.
1437
    if settings.FEATURES.get('AUTH_USE_SHIB') and user:
1438 1439
        try:
            eamap = ExternalAuthMap.objects.get(user=user)
1440
            if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
David Baumgold committed
1441 1442 1443 1444
                return JsonResponse({
                    "success": False,
                    "redirect": reverse('shib-login'),
                })  # TODO: this should be status code 301  # pylint: disable=fixme
1445 1446
        except ExternalAuthMap.DoesNotExist:
            # This is actually the common case, logging in user without external linked login
1447
            AUDIT_LOG.info(u"User %s w/o external auth attempting login", user)
1448

1449
    # see if account has been locked out due to excessive login failures
1450 1451 1452
    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):
1453 1454
            lockout_message = _('This account has been temporarily locked due '
                                'to excessive login failures. Try again later.')
David Baumgold committed
1455 1456
            return JsonResponse({
                "success": False,
1457
                "value": lockout_message,
David Baumgold committed
1458
            })  # TODO: this should be status code 429  # pylint: disable=fixme
1459

1460
    # see if the user must reset his/her password due to any policy settings
1461
    if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup):
1462 1463 1464 1465
        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
1466
                       '"Forgot Password" link on this page to reset your password before logging in again.'),
1467 1468
        })  # TODO: this should be status code 403  # pylint: disable=fixme

Diana Huang committed
1469 1470 1471 1472
    # 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 ""
1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483

    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
1484
    if user is None:
1485 1486 1487 1488
        # 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
1489 1490 1491
        # 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 != "":
1492 1493 1494 1495 1496
            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
1497 1498 1499 1500
        return JsonResponse({
            "success": False,
            "value": _('Email or password is incorrect.'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
1501

1502 1503 1504 1505
    # successful login, clear failed login attempts counters, if applicable
    if LoginFailures.is_feature_enabled():
        LoginFailures.clear_lockout_counter(user)

1506
    # Track the user's sign in
1507
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1508
        tracking_context = tracker.get_tracker().resolve_context()
1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521
        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
            }
        )
1522 1523 1524 1525 1526 1527

        analytics.track(
            user.id,
            "edx.bi.user.account.authenticated",
            {
                'category': "conversion",
1528
                'label': request.POST.get('course_id'),
Julia Hansbrough committed
1529
                'provider': None
1530 1531
            },
            context={
1532
                'ip': tracking_context.get('ip'),
1533
                'Google Analytics': {
Sarina Canelake committed
1534
                    'clientId': tracking_context.get('client_id')
1535 1536 1537
                }
            }
        )
Piotr Mitros committed
1538
    if user is not None and user.is_active:
1539
        try:
1540 1541
            # We do not log here, because we have a handler registered
            # to perform logging on successful logins.
1542
            login(request, user)
1543
            if request.POST.get('remember') == 'true':
1544
                request.session.set_expiry(604800)
1545 1546 1547
                log.debug("Setting user session to never expire")
            else:
                request.session.set_expiry(0)
Sarina Canelake committed
1548
        except Exception as exc:  # pylint: disable=broad-except
1549
            AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?")
1550
            log.critical("Login failed - Could not create session. Is memcached running?")
Sarina Canelake committed
1551
            log.exception(exc)
1552
            raise
1553

1554
        redirect_url = None  # The AJAX method calling should know the default destination upon success
1555 1556 1557
        if third_party_auth_successful:
            redirect_url = pipeline.get_complete_url(backend_name)

David Baumgold committed
1558 1559 1560 1561
        response = JsonResponse({
            "success": True,
            "redirect_url": redirect_url,
        })
1562

1563 1564
        # Ensure that the external marketing site can
        # detect that the user is logged in.
Will Daly committed
1565
        return set_logged_in_cookies(request, response, user)
Victor Shnayder committed
1566

1567 1568 1569 1570
    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
1571

1572
    reactivation_email_for_user(user)
1573

David Baumgold committed
1574 1575
    return JsonResponse({
        "success": False,
1576
        "value": _generate_not_activated_message(user),
David Baumgold committed
1577
    })  # TODO: this should be status code 400  # pylint: disable=fixme
Piotr Mitros committed
1578

1579

1580
@csrf_exempt
1581
@require_POST
1582
@social_utils.psa("social:complete")
1583 1584 1585 1586 1587 1588
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.
    """
1589 1590
    warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)

1591
    backend = request.backend
1592 1593 1594
    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
1595
            request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API
1596
            user = None
1597
            access_token = request.POST["access_token"]
1598
            try:
1599
                user = backend.do_auth(access_token)
1600
            except (HTTPError, AuthException):
1601 1602 1603
                pass
            # do_auth can return a non-User object if it fails
            if user and isinstance(user, User):
1604
                login(request, user)
1605 1606 1607
                return JsonResponse(status=204)
            else:
                # Ensure user does not re-enter the pipeline
1608
                request.social_strategy.clean_partial_pipeline(access_token)
1609 1610 1611 1612 1613
                return JsonResponse({"error": "invalid_token"}, status=401)
        else:
            return JsonResponse({"error": "invalid_request"}, status=400)
    raise Http404

1614

1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633
@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:
1634
        row = [user.username, user.standing.changed_by]
1635 1636 1637 1638 1639 1640
        rows.append(row)

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

    return render_to_response("manage_user_standing.html", context)

1641

1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654
@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() == '':
1655
        context['message'] = _('Please enter a username')
1656 1657 1658 1659
        return JsonResponse(context, status=400)

    account_action = request.POST.get('account_action')
    if account_action is None:
1660
        context['message'] = _('Please choose an option')
1661 1662 1663 1664 1665 1666
        return JsonResponse(context, status=400)

    username = username.strip()
    try:
        user = User.objects.get(username=username)
    except User.DoesNotExist:
1667
        context['message'] = _("User with username {} does not exist").format(username)
1668 1669
        return JsonResponse(context, status=400)
    else:
1670
        user_account, _success = UserStanding.objects.get_or_create(
1671 1672 1673 1674
            user=user, defaults={'changed_by': request.user},
        )
        if account_action == 'disable':
            user_account.account_status = UserStanding.ACCOUNT_DISABLED
1675
            context['message'] = _("Successfully disabled {}'s account").format(username)
1676
            log.info(u"%s disabled %s's account", request.user, username)
1677 1678
        elif account_action == 'reenable':
            user_account.account_status = UserStanding.ACCOUNT_ENABLED
1679
            context['message'] = _("Successfully reenabled {}'s account").format(username)
1680
            log.info(u"%s reenabled %s's account", request.user, username)
1681
        else:
1682
            context['message'] = _("Unexpected account status")
1683 1684
            return JsonResponse(context, status=400)
        user_account.changed_by = request.user
1685
        user_account.standing_last_changed_at = datetime.datetime.now(UTC)
1686 1687 1688 1689
        user_account.save()

    return JsonResponse(context)

1690

1691
@login_required
1692
@ensure_csrf_cookie
1693
def change_setting(request):
1694
    """JSON call to change a profile setting: Right now, location"""
1695
    # TODO (vshnayder): location is no longer used
Sarina Canelake committed
1696
    u_prof = UserProfile.objects.get(user=request.user)  # request.user.profile_cache
Piotr Mitros committed
1697
    if 'location' in request.POST:
Sarina Canelake committed
1698 1699
        u_prof.location = request.POST['location']
    u_prof.save()
1700

David Baumgold committed
1701 1702
    return JsonResponse({
        "success": True,
Sarina Canelake committed
1703
        "location": u_prof.location,
David Baumgold committed
1704
    })
1705

Calen Pennington committed
1706

1707 1708 1709 1710 1711
class AccountValidationError(Exception):
    def __init__(self, message, field):
        super(AccountValidationError, self).__init__(message)
        self.field = field

1712 1713

@receiver(post_save, sender=User)
1714
def user_signup_handler(sender, **kwargs):  # pylint: disable=unused-argument
1715 1716 1717 1718 1719
    """
    handler that saves the user Signup Source
    when the user is created
    """
    if 'created' in kwargs and kwargs['created']:
1720
        site = configuration_helpers.get_value('SITE_NAME')
1721
        if site:
1722
            user_signup_source = UserSignupSource(user=kwargs['instance'], site=site)
1723 1724 1725 1726
            user_signup_source.save()
            log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))


1727
def _do_create_account(form, custom_form=None):
1728 1729 1730 1731 1732 1733 1734 1735
    """
    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.
    """
1736 1737 1738 1739 1740 1741 1742
    # 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()

1743 1744 1745 1746 1747 1748 1749
    errors = {}
    errors.update(form.errors)
    if custom_form:
        errors.update(custom_form.errors)

    if errors:
        raise ValidationError(errors)
1750 1751 1752 1753 1754 1755 1756

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

1759 1760 1761
    # 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:
1762 1763
        with transaction.atomic():
            user.save()
1764 1765 1766 1767
            if custom_form:
                custom_model = custom_form.save(commit=False)
                custom_model.user = user
                custom_model.save()
1768 1769
    except IntegrityError:
        # Figure out the cause of the integrity error
1770 1771 1772 1773 1774
        # 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.")
1775
        if len(User.objects.filter(username=user.username)) > 0:
1776
            raise AccountValidationError(
1777
                _("An account with the Public Username '{username}' already exists.").format(username=user.username),
1778
                field="username"
Sarina Canelake committed
1779
            )
1780
        elif len(User.objects.filter(email=user.email)) > 0:
1781
            raise AccountValidationError(
1782
                _("An account with the Email '{email}' already exists.").format(email=user.email),
1783
                field="email"
Sarina Canelake committed
1784
            )
1785 1786
        else:
            raise
1787

1788 1789 1790 1791 1792
    # 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)

1793
    registration.register(user)
1794

1795 1796 1797 1798 1799 1800 1801 1802 1803
    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
1804 1805
    if extended_profile:
        profile.meta = json.dumps(extended_profile)
1806
    try:
1807
        profile.save()
Sarina Canelake committed
1808
    except Exception:  # pylint: disable=broad-except
David Baumgold committed
1809
        log.exception("UserProfile creation failed for user {id}.".format(id=user.id))
1810
        raise
1811

1812
    return (user, profile, registration)
1813

1814

1815 1816 1817 1818 1819 1820 1821
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)


1822
def create_account_with_params(request, params):
1823
    """
1824 1825 1826 1827 1828
    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
1829

1830
    Does not return anything.
Matthew Mongeau committed
1831

1832 1833 1834
    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.
1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847

    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").
1848 1849 1850 1851 1852 1853 1854 1855
    * 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.
1856 1857 1858 1859
    """
    # 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())
1860

1861 1862
    # allow to define custom set of required/optional/hidden fields via configuration
    extra_fields = configuration_helpers.get_value(
1863 1864 1865
        'REGISTRATION_EXTRA_FIELDS',
        getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
    )
1866 1867 1868 1869 1870 1871 1872
    # 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
1873

1874
    is_third_party_auth_enabled = third_party_auth.is_enabled()
1875

1876
    if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
1877
        params["password"] = pipeline.make_random_password()
1878

1879 1880 1881 1882 1883 1884 1885 1886 1887 1888
    # 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
1889 1890
    # 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
1891
    # unless originally we didn't get a valid email or name from the external auth
1892
    # TODO: We do not check whether these values meet all necessary criteria, such as email length
Sarina Canelake committed
1893 1894
    do_external_auth = 'ExternalAuthMap' in request.session
    if do_external_auth:
ichuang committed
1895
        eamap = request.session['ExternalAuthMap']
1896 1897
        try:
            validate_email(eamap.external_email)
1898
            params["email"] = eamap.external_email
1899
        except ValidationError:
1900 1901 1902 1903 1904
            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
1905

1906
    extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', [])
1907 1908 1909 1910
    enforce_password_policy = (
        settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and
        not do_external_auth
    )
1911
    # Can't have terms of service for certain SHIB users, like at Stanford
1912
    registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
1913
    tos_required = (
1914 1915 1916
        registration_fields.get('terms_of_service') != 'hidden' or
        registration_fields.get('honor_code') != 'hidden'
    ) and (
1917 1918
        not settings.FEATURES.get("AUTH_USE_SHIB") or
        not settings.FEATURES.get("SHIB_DISABLE_TOS") or
Sarina Canelake committed
1919
        not do_external_auth or
1920
        not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX)
1921
    )
1922

1923
    form = AccountCreationForm(
1924
        data=params,
1925 1926 1927 1928
        extra_fields=extra_fields,
        extended_profile_fields=extended_profile_fields,
        enforce_username_neq_password=True,
        enforce_password_policy=enforce_password_policy,
1929
        tos_required=tos_required,
1930
    )
1931
    custom_form = get_registration_extension_form(data=params)
1932

1933
    # Perform operations within a transaction that are critical to account creation
1934
    with transaction.atomic():
1935
        # first, create the account
1936
        (user, profile, registration) = _do_create_account(form, custom_form)
1937

1938
        # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth
1939
        # (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
1940 1941 1942 1943 1944

        # 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:
1945 1946 1947 1948
            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)
1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961
            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:
1962
                pipeline_user = request.backend.do_auth(social_access_token, user=user)
1963 1964 1965 1966 1967 1968
            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
1969
                request.social_strategy.clean_partial_pipeline(social_access_token)
1970
                raise ValidationError({'access_token': [error_message]})
1971

1972
    # Perform operations that are non-critical parts of account creation
1973 1974
    _create_or_set_user_attribute_created_on_site(user, request.site)

1975 1976
    preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language())

1977 1978 1979
    if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'):
        try:
            enable_notifications(user)
1980
        except Exception:  # pylint: disable=broad-except
1981 1982
            log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id))

1983
    dog_stats_api.increment("common.student.account_created")
1984

1985 1986 1987
    # If the user is registering via 3rd party auth, track which provider they use
    third_party_provider = None
    running_pipeline = None
1988
    if is_third_party_auth_enabled and pipeline.running(request):
1989 1990 1991
        running_pipeline = pipeline.get(request)
        third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)

1992
    # Track the user's registration
1993
    if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1994
        tracking_context = tracker.get_tracker().resolve_context()
1995
        identity_args = [
1996
            user.id,  # pylint: disable=no-member
1997 1998 1999 2000
            {
                'email': user.email,
                'username': user.username,
                'name': profile.name,
2001 2002 2003
                # 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,
2004 2005 2006
                'education': profile.level_of_education_display,
                'address': profile.mailing_address,
                'gender': profile.gender_display,
2007
                'country': unicode(profile.country),
2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018
            }
        ]

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

        analytics.identify(*identity_args)
2019 2020 2021

        analytics.track(
            user.id,
Sarina Canelake committed
2022
            "edx.bi.user.account.registered",
2023
            {
Julia Hansbrough committed
2024
                'category': 'conversion',
2025
                'label': params.get('course_id'),
2026
                'provider': third_party_provider.name if third_party_provider else None
2027 2028
            },
            context={
2029
                'ip': tracking_context.get('ip'),
2030
                'Google Analytics': {
Sarina Canelake committed
2031
                    'clientId': tracking_context.get('client_id')
2032 2033 2034 2035
                }
            }
        )

2036
    # Announce registration
2037
    REGISTER_USER.send(sender=None, user=user, registration=registration)
2038

2039 2040
    create_comments_service_user(user)

2041 2042 2043 2044 2045 2046
    # Don't send email if we are:
    #
    # 1. Doing load testing.
    # 2. Random user generation for other forms of testing.
    # 3. External auth bypassing activation.
    # 4. Have the platform configured to not require e-mail activation.
2047
    # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
2048 2049 2050 2051 2052
    #
    # 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).
2053
    send_email = (
2054
        not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
2055
        not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
2056 2057 2058 2059 2060
        not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and
        not (
            third_party_provider and third_party_provider.skip_email_verification and
            user.email == running_pipeline['kwargs'].get('details', {}).get('email')
        )
2061 2062
    )
    if send_email:
2063
        compose_and_send_activation_email(user, profile, registration)
2064 2065
    else:
        registration.activate()
2066
        _enroll_user_in_pending_courses(user)  # Enroll student in any pending courses
2067

2068 2069 2070
    # 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.
2071
    new_user = authenticate(username=user.username, password=params['password'])
Sarina Canelake committed
2072
    login(request, new_user)
2073 2074
    request.session.set_expiry(0)

2075 2076 2077 2078 2079
    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.')
2080

2081 2082
    # 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
2083 2084
    if new_user is not None:
        AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username))
2085

Sarina Canelake committed
2086 2087
    if do_external_auth:
        eamap.user = new_user
2088
        eamap.dtsignup = datetime.datetime.now(UTC)
ichuang committed
2089
        eamap.save()
2090 2091
        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
2092

2093
        if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
2094
            log.info('bypassing activation email')
Sarina Canelake committed
2095 2096 2097
            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
2098

Will Daly committed
2099 2100
    return new_user

2101

2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120
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
                )


2121
def record_affiliate_registration_attribution(request, user):
2122 2123 2124 2125 2126
    """
    Attribute this user's registration to the referring affiliate, if
    applicable.
    """
    affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
2127
    if user and affiliate_id:
2128
        UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
2129 2130


2131 2132 2133 2134 2135 2136 2137 2138 2139
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)
2140 2141 2142 2143 2144 2145 2146 2147
        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
                )
2148 2149 2150 2151 2152 2153
        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)
2154 2155 2156 2157 2158
            UserAttribute.set_user_attribute(
                user,
                REGISTRATION_UTM_CREATED_AT,
                created_at_datetime
            )
2159 2160 2161 2162 2163 2164 2165 2166 2167 2168


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)


2169 2170 2171 2172 2173 2174
@csrf_exempt
def create_account(request, post_override=None):
    """
    JSON call to create new edX account.
    Used by form in signup_modal.html, which is included into navigation.html
    """
2175 2176 2177 2178 2179 2180 2181
    # 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."))

2182 2183
    warnings.warn("Please use RegistrationView instead.", DeprecationWarning)

2184
    try:
Will Daly committed
2185
        user = create_account_with_params(request, post_override or request.POST)
2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198
    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
        )

2199
    redirect_url = None  # The AJAX method calling should know the default destination upon success
2200 2201

    # Resume the third-party-auth pipeline if necessary.
2202
    if third_party_auth.is_enabled() and pipeline.running(request):
2203 2204 2205
        running_pipeline = pipeline.get(request)
        redirect_url = pipeline.get_complete_url(running_pipeline['backend'])

2206 2207
    response = JsonResponse({
        'success': True,
2208
        'redirect_url': redirect_url,
2209
    })
Will Daly committed
2210
    set_logged_in_cookies(request, response, user)
2211 2212
    return response

Matthew Mongeau committed
2213

2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234
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


2235
def auto_auth(request):
2236
    """
2237
    Create or configure a user account, then log in as that user.
Matthew Mongeau committed
2238

2239 2240
    Enabled only when
    settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true.
2241

2242 2243 2244 2245 2246
    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`
2247
    * `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
2248
    * `no_login`: Define this to create the user but not login
2249 2250 2251
    * `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
2252
    * `is_active` : make/update account with status provided as 'is_active'
2253 2254 2255
    If username, email, or password are not provided, use
    randomly generated credentials.
    """
Matthew Mongeau committed
2256

2257
    # Generate a unique name to use if none provided
2258
    generated_username = uuid.uuid4().hex[0:30]
2259 2260

    # Use the params from the request, otherwise use these defaults
2261 2262 2263
    username = request.GET.get('username', generated_username)
    password = request.GET.get('password', username)
    email = request.GET.get('email', username + "@example.com")
2264
    full_name = request.GET.get('full_name', username)
2265 2266 2267 2268 2269
    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))
2270

2271
    # Valid modes: audit, credit, honor, no-id-professional, professional, verified
2272 2273
    enrollment_mode = request.GET.get('enrollment_mode', 'honor')

2274 2275 2276
    # 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(','))
2277

2278
    redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to
2279
    login_when_done = 'no_login' not in request.GET
2280

2281 2282 2283 2284 2285 2286 2287 2288 2289
    form = AccountCreationForm(
        data={
            'username': username,
            'email': email,
            'password': password,
            'name': full_name,
        },
        tos_required=False
    )
2290

2291 2292
    # Attempt to create the account.
    # If successful, this will return a tuple containing
2293 2294
    # the new user object.
    try:
2295
        user, profile, reg = _do_create_account(form)
2296
    except (AccountValidationError, ValidationError):
2297
        # Attempt to retrieve the existing user.
2298
        user = User.objects.get(username=username)
2299 2300
        user.email = email
        user.set_password(password)
2301
        user.is_active = is_active
2302
        user.save()
2303
        profile = UserProfile.objects.get(user=user)
2304
        reg = Registration.objects.get(user=user)
2305
    except PermissionDenied:
2306
        return HttpResponseForbidden(_('Account creation not allowed.'))
2307

2308 2309 2310
    user.is_staff = is_staff
    user.is_superuser = is_superuser
    user.save()
2311

2312
    if is_active:
2313 2314
        reg.activate()
        reg.save()
2315

2316 2317 2318 2319 2320 2321
    # 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()

2322 2323
    _create_or_set_user_attribute_created_on_site(user, request.site)

2324
    # Enroll the user in a course
2325 2326 2327
    course_key = None
    if course_id:
        course_key = CourseLocator.from_string(course_id)
2328
        CourseEnrollment.enroll(user, course_key, mode=enrollment_mode)
2329

2330 2331 2332 2333 2334 2335
        # 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)
2336

2337
    # Log in as the user
2338 2339 2340
    if login_when_done:
        user = authenticate(username=username, password=password)
        login(request, user)
2341

2342 2343
    create_comments_service_user(user)

2344
    if redirect_when_done:
2345
        if redirect_to:
2346
            # Redirect to page specified by the client
2347 2348
            redirect_url = redirect_to
        elif course_id:
2349
            # Redirect to the course homepage (in LMS) or outline page (in Studio)
2350
            try:
2351
                redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
2352
            except NoReverseMatch:
2353
                redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
2354
        else:
2355
            # Redirect to the learner dashboard (in LMS) or homepage (in Studio)
2356 2357 2358 2359 2360 2361
            try:
                redirect_url = reverse('dashboard')
            except NoReverseMatch:
                redirect_url = reverse('home')

        return redirect(redirect_url)
2362
    else:
2363
        response = JsonResponse({
2364
            'created_status': 'Logged in' if login_when_done else 'Created',
2365 2366 2367
            'username': username,
            'email': email,
            'password': password,
2368
            'user_id': user.id,  # pylint: disable=no-member
2369 2370
            'anonymous_id': anonymous_id_for_user(user, None),
        })
2371 2372
    response.set_cookie('csrftoken', csrf(request)['csrf_token'])
    return response
2373

2374

2375
@ensure_csrf_cookie
Piotr Mitros committed
2376
def activate_account(request, key):
2377
    """When link in activation e-mail is clicked"""
2378 2379 2380 2381 2382 2383 2384 2385 2386 2387

    # 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,
2388
            HTML(_(
2389 2390 2391 2392 2393 2394 2395
                '{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>'),
            ),
2396
            extra_tags='account-activation aa-icon'
2397 2398 2399 2400
        )
    else:
        if not registration.user.is_active:
            registration.activate()
2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412
            # 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.
2413 2414
            messages.success(
                request,
2415
                HTML(message).format(
2416 2417 2418
                    html_start=HTML('<p class="message-title">'),
                    html_end=HTML('</p>'),
                ),
2419
                extra_tags='account-activation aa-icon',
2420 2421 2422 2423
            )
        else:
            messages.info(
                request,
2424
                HTML(_('{html_start}This account has already been activated.{html_end}')).format(
2425 2426 2427
                    html_start=HTML('<p class="message-title">'),
                    html_end=HTML('</p>'),
                ),
2428
                extra_tags='account-activation aa-icon',
2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449
            )

        # 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:
2450 2451
        user_logged_in = request.user.is_authenticated()
        already_active = True
2452 2453
        if not registration.user.is_active:
            registration.activate()
2454
            already_active = False
2455

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

2459
        return render_to_response(
2460 2461 2462 2463 2464 2465
            "registration/activation_complete.html",
            {
                'user_logged_in': user_logged_in,
                'already_active': already_active
            }
        )
Piotr Mitros committed
2466

2467

2468
@csrf_exempt
2469
@require_POST
Piotr Mitros committed
2470
def password_reset(request):
2471
    """ Attempts to send a password reset e-mail. """
2472 2473
    # Add some rate limiting here by re-using the RateLimitMixin as a helper class
    limiter = BadRequestRateLimiter()
2474
    if limiter.is_rate_limit_exceeded(request):
2475 2476 2477
        AUDIT_LOG.warning("Rate limit exceeded in password_reset")
        return HttpResponseForbidden()

2478
    form = PasswordResetFormNoActive(request.POST)
Piotr Mitros committed
2479
    if form.is_valid():
Calen Pennington committed
2480
        form.save(use_https=request.is_secure(),
2481
                  from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
2482
                  request=request)
2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494
        # 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
2495
        destroy_oauth_tokens(request.user)
2496 2497 2498 2499 2500
    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
2501 2502 2503 2504
    return JsonResponse({
        'success': True,
        'value': render_to_string('registration/password_reset_done.html', {}),
    })
David Baumgold committed
2505

2506

2507 2508 2509 2510 2511 2512 2513 2514
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
2515
    """
2516 2517 2518 2519 2520 2521 2522
    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


2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546
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):
2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559
    """
    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`.
    """

2560
    err_msg = None
2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585
    # 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)

2586
    return err_msg
2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597


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 = {
2598
        "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
2599
    }
2600 2601 2602 2603
    try:
        uid_int = base36_to_int(uidb36)
        user = User.objects.get(id=uid_int)
    except (ValueError, User.DoesNotExist):
2604 2605 2606 2607 2608
        # 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
        )
2609 2610 2611

    if request.method == 'POST':
        password = request.POST['new_password1']
2612 2613 2614 2615 2616 2617 2618 2619 2620 2621
        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:
2622
            # We have a password reset attempt which violates some security
2623
            # policy, or any other validation. Use the existing Django template to communicate that
2624 2625
            # back to the user.
            context = {
2626
                'validlink': valid_link,
2627 2628
                'form': None,
                'title': _('Password reset unsuccessful'),
2629
                'err_msg': error_message,
2630 2631 2632 2633 2634
            }
            context.update(platform_name)
            return TemplateResponse(
                request, 'registration/password_reset_confirm.html', context
            )
2635

2636 2637
        # remember what the old password hash is before we call down
        old_password_hash = user.password
2638

2639 2640 2641
        response = password_reset_confirm(
            request, uidb64=uidb64, token=token, extra_context=platform_name
        )
2642

2643 2644 2645
        # 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).
2646 2647 2648 2649 2650 2651 2652 2653 2654 2655
        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
2656

2657 2658
        # get the updated user
        updated_user = User.objects.get(id=uid_int)
2659

2660 2661 2662 2663 2664 2665 2666 2667 2668
        # 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
        )
2669

2670 2671 2672 2673
        response_was_successful = response.context_data.get('validlink')
        if response_was_successful and not user.is_active:
            user.is_active = True
            user.save()
2674

2675
    return response
2676

Calen Pennington committed
2677

2678
def reactivation_email_for_user(user):
2679
    try:
2680
        registration = Registration.objects.get(user=user)
2681
    except Registration.DoesNotExist:
David Baumgold committed
2682 2683 2684 2685
        return JsonResponse({
            "success": False,
            "error": _('No inactive user with this e-mail exists'),
        })  # TODO: this should be status code 400  # pylint: disable=fixme
2686

2687
    try:
2688
        context = generate_activation_email_context(user, registration)
2689 2690 2691 2692 2693 2694 2695 2696 2697 2698
    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
2699

David Baumgold committed
2700
    subject = render_to_string('emails/activation_email_subject.txt', context)
2701
    subject = ''.join(subject.splitlines())
David Baumgold committed
2702
    message = render_to_string('emails/activation_email.txt', context)
2703
    from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
2704
    from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address)
2705

2706
    try:
2707
        user.email_user(subject, message, from_address)
David Baumgold committed
2708
    except Exception:  # pylint: disable=broad-except
asadiqbal committed
2709
        log.error(
2710 2711 2712
            u'Unable to send reactivation email from "%s" to "%s"',
            from_address,
            user.email,
asadiqbal committed
2713 2714
            exc_info=True
        )
David Baumgold committed
2715 2716 2717 2718
        return JsonResponse({
            "success": False,
            "error": _('Unable to send reactivation email')
        })  # TODO: this should be status code 500  # pylint: disable=fixme
Matthew Mongeau committed
2719

David Baumgold committed
2720
    return JsonResponse({"success": True})
Victor Shnayder committed
2721

2722

2723
def validate_new_email(user, new_email):
2724
    """
2725 2726
    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.
2727 2728 2729 2730 2731 2732 2733 2734
    """
    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.'))
2735

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

2739

2740
def do_email_change_request(user, new_email, activation_key=None):
2741 2742 2743 2744 2745
    """
    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.
    """
2746
    pec_list = PendingEmailChange.objects.filter(user=user)
Matthew Mongeau committed
2747
    if len(pec_list) == 0:
2748 2749
        pec = PendingEmailChange()
        pec.user = user
2750
    else:
2751 2752
        pec = pec_list[0]

2753 2754 2755 2756
    # if activation_key is not passing as an argument, generate a random key
    if not activation_key:
        activation_key = uuid.uuid4().hex

2757 2758
    pec.new_email = new_email
    pec.activation_key = activation_key
2759 2760
    pec.save()

2761 2762 2763 2764 2765
    context = {
        'key': pec.activation_key,
        'old_email': user.email,
        'new_email': pec.new_email
    }
2766

2767
    subject = render_to_string('emails/email_change_subject.txt', context)
2768
    subject = ''.join(subject.splitlines())
2769

2770 2771
    message = render_to_string('emails/email_change.txt', context)

2772
    from_address = configuration_helpers.get_value(
2773 2774 2775
        'email_from_address',
        settings.DEFAULT_FROM_EMAIL
    )
2776
    try:
2777
        mail.send_mail(subject, message, from_address, [pec.new_email])
2778
    except Exception:  # pylint: disable=broad-except
2779
        log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True)
2780
        raise ValueError(_('Unable to send email activation link. Please try again later.'))
2781

2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794
    # 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,
        }
    )

2795 2796

@ensure_csrf_cookie
Sarina Canelake committed
2797 2798 2799
def confirm_email_change(request, key):  # pylint: disable=unused-argument
    """
    User requested a new e-mail. This is called when the activation
2800
    link is clicked. We confirm with the old e-mail, and update
2801
    """
2802
    with transaction.atomic():
2803 2804 2805
        try:
            pec = PendingEmailChange.objects.get(activation_key=key)
        except PendingEmailChange.DoesNotExist:
2806
            response = render_to_response("invalid_email_key.html", {})
2807
            transaction.set_rollback(True)
2808
            return response
2809 2810 2811 2812 2813 2814

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

2816
        if len(User.objects.filter(email=pec.new_email)) != 0:
2817
            response = render_to_response("email_exists.html", {})
2818
            transaction.set_rollback(True)
2819
            return response
2820 2821 2822 2823

        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
2824 2825
        u_prof = UserProfile.objects.get(user=user)
        meta = u_prof.get_meta()
2826 2827
        if 'old_emails' not in meta:
            meta['old_emails'] = []
2828
        meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
Sarina Canelake committed
2829 2830
        u_prof.set_meta(meta)
        u_prof.save()
2831 2832
        # Send it to the old email...
        try:
asadiqbal committed
2833 2834 2835
            user.email_user(
                subject,
                message,
2836
                configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
asadiqbal committed
2837
            )
Sarina Canelake committed
2838
        except Exception:    # pylint: disable=broad-except
2839
            log.warning('Unable to send confirmation email to old address', exc_info=True)
2840
            response = render_to_response("email_change_failed.html", {'email': user.email})
2841
            transaction.set_rollback(True)
2842
            return response
2843

2844 2845 2846 2847 2848
        user.email = pec.new_email
        user.save()
        pec.delete()
        # And send it to the new email...
        try:
asadiqbal committed
2849 2850 2851
            user.email_user(
                subject,
                message,
2852
                configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
asadiqbal committed
2853
            )
Sarina Canelake committed
2854
        except Exception:  # pylint: disable=broad-except
2855
            log.warning('Unable to send confirmation email to new address', exc_info=True)
2856
            response = render_to_response("email_change_failed.html", {'email': pec.new_email})
2857
            transaction.set_rollback(True)
2858
            return response
2859

2860 2861
        response = render_to_response("email_change_successful.html", address_context)
        return response
2862

2863

2864 2865
@require_POST
@login_required
2866 2867 2868 2869 2870 2871
@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")
2872
    course_key = CourseKey.from_string(course_id)
2873 2874
    receive_emails = request.POST.get("receive_emails")
    if receive_emails:
2875
        optout_object = Optout.objects.filter(user=user, course_id=course_key)
2876 2877
        if optout_object:
            optout_object.delete()
2878 2879 2880 2881
        log.info(
            u"User %s (%s) opted in to receive emails from course %s",
            user.username,
            user.email,
2882 2883 2884 2885 2886 2887 2888
            course_id,
        )
        track.views.server_track(
            request,
            "change-email-settings",
            {"receive_emails": "yes", "course": course_id},
            page='dashboard',
2889
        )
2890
    else:
2891
        Optout.objects.get_or_create(user=user, course_id=course_key)
2892 2893 2894 2895
        log.info(
            u"User %s (%s) opted out of receiving emails from course %s",
            user.username,
            user.email,
2896 2897 2898 2899 2900 2901 2902
            course_id,
        )
        track.views.server_track(
            request,
            "change-email-settings",
            {"receive_emails": "no", "course": course_id},
            page='dashboard',
2903
        )
2904

David Baumgold committed
2905
    return JsonResponse({"success": True})
2906 2907


2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918
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.
2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933
    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
2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944

    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.
2945
        if self.oauth_client_ids:
2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990
            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