views.py 23.7 KB
Newer Older
1 2
""" Views for a student's account information. """

3
import json
4
import logging
5
import urlparse
6
from datetime import datetime
7

8
from django.conf import settings
9
from django.contrib import messages
Ahsan committed
10
from django.contrib.auth import get_user_model
11
from django.contrib.auth.decorators import login_required
12 13
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
14
from django.shortcuts import redirect
15
from django.utils.translation import ugettext as _
16
from django.views.decorators.csrf import ensure_csrf_cookie
17
from django.views.decorators.http import require_http_methods
18
from django_countries import countries
Usman Khalid committed
19

20
import third_party_auth
21
from commerce.models import CommerceConfiguration
22
from edxmako.shortcuts import render_to_response
23
from lms.djangoapps.commerce.utils import EcommerceService
24
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
25 26 27
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.lang_pref.api import all_languages, released_languages
28
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
29
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
30
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
31
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
32 33 34 35 36
from openedx.core.djangoapps.user_api.api import (
    RegistrationFormFactory,
    get_login_session_form,
    get_password_reset_form
)
37 38
from openedx.core.djangoapps.user_api.errors import UserNotFound
from openedx.core.lib.edx_api_utils import get_edx_api_data
39 40 41
from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
from openedx.features.enterprise_support.api import enterprise_customer_for_request
from student.helpers import destroy_oauth_tokens, get_next_url_for_login_page
Usman Khalid committed
42
from student.models import UserProfile
43 44
from student.views import register_user as old_register_view
from student.views import signin_user as old_login_view
45
from third_party_auth import pipeline
46
from third_party_auth.decorators import xframe_allow_whitelisted
Usman Khalid committed
47
from util.bad_request_rate_limiter import BadRequestRateLimiter
48
from util.date_utils import strftime_localized
49

50
AUDIT_LOG = logging.getLogger("audit")
51
log = logging.getLogger(__name__)
Ahsan committed
52
User = get_user_model()  # pylint:disable=invalid-name
53 54


55
@require_http_methods(['GET'])
56
@ensure_csrf_cookie
57
@xframe_allow_whitelisted
58 59 60 61 62 63 64
def login_and_registration_form(request, initial_mode="login"):
    """Render the combined login/registration form, defaulting to login

    This relies on the JS to asynchronously load the actual form from
    the user_api.

    Keyword Args:
65
        initial_mode (string): Either "login" or "register".
66 67

    """
68 69
    # Determine the URL to redirect to following login/registration/third_party_auth
    redirect_to = get_next_url_for_login_page(request)
70 71
    # If we're already logged in, redirect to the dashboard
    if request.user.is_authenticated():
72
        return redirect(redirect_to)
73

74 75 76
    # Retrieve the form descriptions from the user API
    form_descriptions = _get_form_descriptions(request)

77 78 79 80 81 82 83
    # Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check.
    # If present, we display a login page focused on third-party auth with that provider.
    third_party_auth_hint = None
    if '?' in redirect_to:
        try:
            next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query)
            provider_id = next_args['tpa_hint'][0]
84 85 86 87 88
            tpa_hint_provider = third_party_auth.provider.Registry.get(provider_id=provider_id)
            if tpa_hint_provider:
                if tpa_hint_provider.skip_hinted_login_dialog:
                    # Forward the user directly to the provider's login URL when the provider is configured
                    # to skip the dialog.
89 90 91 92
                    if initial_mode == "register":
                        auth_entry = pipeline.AUTH_ENTRY_REGISTER
                    else:
                        auth_entry = pipeline.AUTH_ENTRY_LOGIN
93
                    return redirect(
94
                        pipeline.get_login_url(provider_id, auth_entry, redirect_url=redirect_to)
95
                    )
96 97
                third_party_auth_hint = provider_id
                initial_mode = "hinted_login"
98 99
        except (KeyError, ValueError, IndexError) as ex:
            log.error("Unknown tpa_hint provider: %s", ex)
100

101
    # If this is a themed site, revert to the old login/registration pages.
102
    # We need to do this for now to support existing themes.
103
    # Themed sites can use the new logistration page by setting
104
    # 'ENABLE_COMBINED_LOGIN_REGISTRATION' in their
105
    # configuration settings.
106
    if is_request_in_themed_site() and not configuration_helpers.get_value('ENABLE_COMBINED_LOGIN_REGISTRATION', False):
107 108 109 110 111 112 113 114 115 116
        if initial_mode == "login":
            return old_login_view(request)
        elif initial_mode == "register":
            return old_register_view(request)

    # Allow external auth to intercept and handle the request
    ext_auth_response = _external_auth_intercept(request, initial_mode)
    if ext_auth_response is not None:
        return ext_auth_response

117 118 119 120 121 122 123
    # Account activation message
    account_activation_messages = [
        {
            'message': message.message, 'tags': message.tags
        } for message in messages.get_messages(request) if 'account-activation' in message.tags
    ]

124
    # Otherwise, render the combined login/registration page
125
    context = {
126 127 128
        'data': {
            'login_redirect_url': redirect_to,
            'initial_mode': initial_mode,
129
            'third_party_auth': _third_party_auth_context(request, redirect_to, third_party_auth_hint),
130
            'third_party_auth_hint': third_party_auth_hint or '',
131
            'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
132
            'support_link': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
133 134 135
            'password_reset_support_link': configuration_helpers.get_value(
                'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK
            ) or settings.SUPPORT_SITE_LINK,
136
            'account_activation_messages': account_activation_messages,
137 138 139 140 141 142 143 144

            # Include form descriptions retrieved from the user API.
            # We could have the JS client make these requests directly,
            # but we include them in the initial page load to avoid
            # the additional round-trip to the server.
            'login_form_desc': json.loads(form_descriptions['login']),
            'registration_form_desc': json.loads(form_descriptions['registration']),
            'password_reset_form_desc': json.loads(form_descriptions['password_reset']),
145 146
            'account_creation_allowed': configuration_helpers.get_value(
                'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True))
147 148
        },
        'login_redirect_url': redirect_to,  # This gets added to the query string of the "Sign In" button in header
149
        'responsive': True,
150
        'allow_iframing': True,
151
        'disable_courseware_js': True,
asadiqbal committed
152
        'combined_login_and_register': True,
153
        'disable_footer': not configuration_helpers.get_value(
154 155 156
            'ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER',
            settings.FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER']
        ),
157 158
    }

159 160
    context = update_context_for_enterprise(request, context)

161 162 163 164 165 166 167 168 169
    response = render_to_response('student_account/login_and_register.html', context)

    # Remove enterprise cookie so that subsequent requests show default login page.
    response.delete_cookie(
        configuration_helpers.get_value("ENTERPRISE_CUSTOMER_COOKIE_NAME", settings.ENTERPRISE_CUSTOMER_COOKIE_NAME),
        domain=configuration_helpers.get_value("BASE_COOKIE_DOMAIN", settings.BASE_COOKIE_DOMAIN),
    )

    return response
170 171


172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
@require_http_methods(['POST'])
def password_change_request_handler(request):
    """Handle password change requests originating from the account page.

    Uses the Account API to email the user a link to the password reset page.

    Note:
        The next step in the password reset process (confirmation) is currently handled
        by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's
        password reset confirmation view.

    Args:
        request (HttpRequest)

    Returns:
        HttpResponse: 200 if the email was sent successfully
188
        HttpResponse: 400 if there is no 'email' POST parameter
189 190 191 192 193 194 195 196
        HttpResponse: 403 if the client has been rate limited
        HttpResponse: 405 if using an unsupported HTTP method

    Example usage:

        POST /account/password

    """
197

198 199 200 201 202 203 204 205 206 207 208
    limiter = BadRequestRateLimiter()
    if limiter.is_rate_limit_exceeded(request):
        AUDIT_LOG.warning("Password reset rate limit exceeded")
        return HttpResponseForbidden()

    user = request.user
    # Prefer logged-in user's email
    email = user.email if user.is_authenticated() else request.POST.get('email')

    if email:
        try:
209
            request_password_change(email, request.is_secure())
Ahsan committed
210 211
            user = user if user.is_authenticated() else User.objects.get(email=email)
            destroy_oauth_tokens(user)
212
        except UserNotFound:
213 214 215 216 217 218
            AUDIT_LOG.info("Invalid password reset attempt")
            # Increment the rate limit counter
            limiter.tick_bad_request_counter(request)

        return HttpResponse(status=200)
    else:
219
        return HttpResponseBadRequest(_("No email address provided."))
220 221


222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
def update_context_for_enterprise(request, context):
    """
    Take the processed context produced by the view, determine if it's relevant
    to a particular Enterprise Customer, and update it to include that customer's
    enterprise metadata.
    """

    context = context.copy()

    sidebar_context = enterprise_sidebar_context(request)

    if sidebar_context:
        context['data']['registration_form_desc']['fields'] = enterprise_fields_only(
            context['data']['registration_form_desc']
        )
        context.update(sidebar_context)
        context['enable_enterprise_sidebar'] = True
239
        context['data']['hide_auth_warnings'] = True
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269
    else:
        context['enable_enterprise_sidebar'] = False

    return context


def enterprise_fields_only(fields):
    """
    Take the received field definition, and exclude those fields that we don't want
    to require if the user is going to be a member of an Enterprise Customer.
    """
    enterprise_exclusions = configuration_helpers.get_value(
        'ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS',
        settings.ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS
    )
    return [field for field in fields['fields'] if field['name'] not in enterprise_exclusions]


def enterprise_sidebar_context(request):
    """
    Given the current request, render the HTML of a sidebar for the current
    logistration view that depicts Enterprise-related information.
    """
    enterprise_customer = enterprise_customer_for_request(request)

    if not enterprise_customer:
        return {}

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

270
    logo_url = enterprise_customer.get('branding_configuration', {}).get('logo', '')
271

272 273 274 275
    branded_welcome_template = configuration_helpers.get_value(
        'ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE',
        settings.ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE
    )
276 277 278 279

    branded_welcome_string = branded_welcome_template.format(
        start_bold=u'<b>',
        end_bold=u'</b>',
280
        enterprise_name=enterprise_customer['name'],
281 282 283 284 285 286 287 288 289 290
        platform_name=platform_name
    )

    platform_welcome_template = configuration_helpers.get_value(
        'ENTERPRISE_PLATFORM_WELCOME_TEMPLATE',
        settings.ENTERPRISE_PLATFORM_WELCOME_TEMPLATE
    )
    platform_welcome_string = platform_welcome_template.format(platform_name=platform_name)

    context = {
291 292
        'enterprise_name': enterprise_customer['name'],
        'enterprise_logo_url': logo_url,
293 294 295 296 297 298 299
        'enterprise_branded_welcome_string': branded_welcome_string,
        'platform_welcome_string': platform_welcome_string,
    }

    return context


300
def _third_party_auth_context(request, redirect_to, tpa_hint=None):
301 302 303 304 305
    """Context for third party auth providers and the currently running pipeline.

    Arguments:
        request (HttpRequest): The request, used to determine if a pipeline
            is currently running.
306 307
        redirect_to: The URL to send the user to following successful
            authentication.
308 309
        tpa_hint (string): An override flag that will return a matching provider
            as long as its configuration has been enabled
310 311 312 313 314 315 316

    Returns:
        dict

    """
    context = {
        "currentProvider": None,
317
        "providers": [],
318
        "secondaryProviders": [],
319 320
        "finishAuthUrl": None,
        "errorMessage": None,
321
        "registerFormSubmitButtonText": _("Create Account"),
322 323 324
    }

    if third_party_auth.is_enabled():
325 326
        enterprise_customer = enterprise_customer_for_request(request)
        if not enterprise_customer:
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
            for enabled in third_party_auth.provider.Registry.displayed_for_login(tpa_hint=tpa_hint):
                info = {
                    "id": enabled.provider_id,
                    "name": enabled.name,
                    "iconClass": enabled.icon_class or None,
                    "iconImage": enabled.icon_image.url if enabled.icon_image else None,
                    "loginUrl": pipeline.get_login_url(
                        enabled.provider_id,
                        pipeline.AUTH_ENTRY_LOGIN,
                        redirect_url=redirect_to,
                    ),
                    "registerUrl": pipeline.get_login_url(
                        enabled.provider_id,
                        pipeline.AUTH_ENTRY_REGISTER,
                        redirect_url=redirect_to,
                    ),
                }
                context["providers" if not enabled.secondary else "secondaryProviders"].append(info)
345

346
        running_pipeline = pipeline.get(request)
347
        if running_pipeline is not None:
348
            current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline)
349

350 351 352 353 354
            if current_provider is not None:
                context["currentProvider"] = current_provider.name
                context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name)

                if current_provider.skip_registration_form:
355 356 357 358 359 360 361 362 363 364 365 366
                    # For enterprise (and later for everyone), we need to get explicit consent to the
                    # Terms of service instead of auto submitting the registration form outright.
                    if not enterprise_customer:
                        # As a reliable way of "skipping" the registration form, we just submit it automatically
                        context["autoSubmitRegForm"] = True
                    else:
                        context["autoRegisterWelcomeMessage"] = (
                            'Thank you for joining {}. '
                            'Just a couple steps before you start learning!'
                        ).format(
                            configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
                        )
367
                        context["registerFormSubmitButtonText"] = _("Continue")
368

369 370 371
        # Check for any error messages we may want to display:
        for msg in messages.get_messages(request):
            if msg.extra_tags.split()[0] == "social-auth":
372
                # msg may or may not be translated. Try translating [again] in case we are able to:
373
                context['errorMessage'] = _(unicode(msg))  # pylint: disable=translation-of-non-string
374
                break
375 376

    return context
377 378 379 380 381 382 383 384 385 386 387 388 389


def _get_form_descriptions(request):
    """Retrieve form descriptions from the user API.

    Arguments:
        request (HttpRequest): The original request, used to retrieve session info.

    Returns:
        dict: Keys are 'login', 'registration', and 'password_reset';
            values are the JSON-serialized form descriptions.

    """
390

391
    return {
392 393 394
        'password_reset': get_password_reset_form().to_json(),
        'login': get_login_session_form().to_json(),
        'registration': RegistrationFormFactory().get_registration_form(request).to_json()
395 396 397
    }


398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
def _external_auth_intercept(request, mode):
    """Allow external auth to intercept a login/registration request.

    Arguments:
        request (Request): The original request.
        mode (str): Either "login" or "register"

    Returns:
        Response or None

    """
    if mode == "login":
        return external_auth_login(request)
    elif mode == "register":
        return external_auth_register(request)
Usman Khalid committed
413 414


415 416 417
def get_user_orders(user):
    """Given a user, get the detail of all the orders from the Ecommerce service.

418
    Args:
419 420 421 422 423 424 425 426 427 428 429 430 431 432
        user (User): The user to authenticate as when requesting ecommerce.

    Returns:
        list of dict, representing orders returned by the Ecommerce service.
    """
    no_data = []
    user_orders = []
    commerce_configuration = CommerceConfiguration.current()
    user_query = {'username': user.username}

    use_cache = commerce_configuration.is_cache_enabled
    cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None
    api = ecommerce_api_client(user)
    commerce_user_orders = get_edx_api_data(
433
        commerce_configuration, 'orders', api=api, querystring=user_query, cache_key=cache_key
434 435 436 437
    )

    for order in commerce_user_orders:
        if order['status'].lower() == 'complete':
438 439 440 441 442 443 444 445 446
            date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ")
            order_data = {
                'number': order['number'],
                'price': order['total_excl_tax'],
                'order_date': strftime_localized(date_placed, 'SHORT_DATE'),
                'receipt_url': EcommerceService().get_receipt_page_url(order['number']),
                'lines': order['lines'],
            }
            user_orders.append(order_data)
447 448 449 450

    return user_orders


Usman Khalid committed
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468
@login_required
@require_http_methods(['GET'])
def account_settings(request):
    """Render the current user's account settings page.

    Args:
        request (HttpRequest)

    Returns:
        HttpResponse: 200 if the page was sent successfully
        HttpResponse: 302 if not logged in (redirect to login page)
        HttpResponse: 405 if using an unsupported HTTP method

    Example usage:

        GET /account/settings

    """
469
    return render_to_response('student_account/account_settings.html', account_settings_context(request))
Usman Khalid committed
470 471


472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501
@login_required
@require_http_methods(['GET'])
def finish_auth(request):  # pylint: disable=unused-argument
    """ Following logistration (1st or 3rd party), handle any special query string params.

    See FinishAuthView.js for details on the query string params.

    e.g. auto-enroll the user in a course, set email opt-in preference.

    This view just displays a "Please wait" message while AJAX calls are made to enroll the
    user in the course etc. This view is only used if a parameter like "course_id" is present
    during login/registration/third_party_auth. Otherwise, there is no need for it.

    Ideally this view will finish and redirect to the next step before the user even sees it.

    Args:
        request (HttpRequest)

    Returns:
        HttpResponse: 200 if the page was sent successfully
        HttpResponse: 302 if not logged in (redirect to login page)
        HttpResponse: 405 if using an unsupported HTTP method

    Example usage:

        GET /account/finish_auth/?course_id=course-v1:blah&enrollment_action=enroll

    """
    return render_to_response('student_account/finish_auth.html', {
        'disable_courseware_js': True,
502
        'disable_footer': True,
503 504 505
    })


506
def account_settings_context(request):
Usman Khalid committed
507 508 509
    """ Context for the account settings page.

    Args:
510
        request: The request object.
Usman Khalid committed
511 512 513 514 515

    Returns:
        dict

    """
516 517
    user = request.user

Usman Khalid committed
518
    year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]
519 520 521 522 523 524 525
    try:
        user_orders = get_user_orders(user)
    except:  # pylint: disable=bare-except
        log.exception('Error fetching order history from Otto.')
        # Return empty order list as account settings page expect a list and
        # it will be broken if exception raised
        user_orders = []
Usman Khalid committed
526 527

    context = {
528 529
        'auth': {},
        'duplicate_provider': None,
unawaz committed
530
        'nav_hidden': True,
Usman Khalid committed
531 532
        'fields': {
            'country': {
533
                'options': list(countries),
Usman Khalid committed
534
            }, 'gender': {
535
                'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES],  # pylint: disable=translation-of-non-string
Usman Khalid committed
536 537 538
            }, 'language': {
                'options': released_languages(),
            }, 'level_of_education': {
539
                'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES],  # pylint: disable=translation-of-non-string
Usman Khalid committed
540 541 542 543 544
            }, 'password': {
                'url': reverse('password_reset'),
            }, 'year_of_birth': {
                'options': year_of_birth_options,
            }, 'preferred_language': {
545
                'options': all_languages(),
546
            }, 'time_zone': {
547
                'options': TIME_ZONE_CHOICES,
Usman Khalid committed
548
            }
549
        },
550
        'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
551 552 553
        'password_reset_support_link': configuration_helpers.get_value(
            'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK
        ) or settings.SUPPORT_SITE_LINK,
554 555
        'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
        'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
556
        'disable_courseware_js': True,
557
        'show_program_listing': ProgramsApiConfig.is_enabled(),
558
        'order_history': user_orders
Usman Khalid committed
559 560
    }

561 562 563 564 565 566 567 568
    if third_party_auth.is_enabled():
        # If the account on the third party provider is already connected with another edX account,
        # we display a message to the user.
        context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))

        auth_states = pipeline.get_provider_user_states(user)

        context['auth']['providers'] = [{
569 570
            'id': state.provider.provider_id,
            'name': state.provider.name,  # The name of the provider e.g. Facebook
571 572
            'connected': state.has_account,  # Whether the user's edX account is connected with the provider.
            # If the user is not connected, they should be directed to this page to authenticate
573
            # with the particular provider, as long as the provider supports initiating a login.
574
            'connect_url': pipeline.get_login_url(
575
                state.provider.provider_id,
576 577 578 579
                pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
                # The url the user should be directed to after the auth process has completed.
                redirect_url=reverse('account_settings'),
            ),
580
            'accepts_logins': state.provider.accepts_logins,
581 582
            # If the user is connected, sending a POST request to this url removes the connection
            # information for this provider from their edX account.
583
            'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id),
584 585 586
            # We only want to include providers if they are either currently available to be logged
            # in with, or if the user is already authenticated with them.
        } for state in auth_states if state.provider.display_for_login or state.has_account]
587

Usman Khalid committed
588
    return context