diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 61f99b1dab..da2bf1713b 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -57,7 +57,11 @@ class Command(BaseCommand): def handle(self, *args, **options): try: # a course key may have unicode chars in it - course_key = text_type(options['course_key'], 'utf8') + try: + course_key = text_type(options['course_key'], 'utf8') + # May already be decoded to unicode if coming in through tests, this is ok. + except TypeError: + course_key = text_type(options['course_key']) course_key = CourseKey.from_string(course_key) except InvalidKeyError: raise CommandError('Invalid course_key: {}'.format(options['course_key'])) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index fd3761f520..5c24e8f585 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -254,7 +254,7 @@ def export_olx(self, user_id, course_key_string, language): self.status.set_state(u'Exporting') tarball = create_export_tarball(courselike_module, courselike_key, {}, self.status) artifact = UserTaskArtifact(status=self.status, name=u'Output') - artifact.file.save(name=tarball.name, content=File(tarball)) # pylint: disable=no-member + artifact.file.save(name=os.path.basename(tarball.name), content=File(tarball)) # pylint: disable=no-member artifact.save() # catch all exceptions so we can record useful error messages except Exception as exception: # pylint: disable=broad-except diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index d84282f102..21351cb0a1 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import print_function + import copy import shutil from datetime import timedelta @@ -10,6 +12,7 @@ from unittest import SkipTest from uuid import uuid4 import ddt +import django import lxml.html import mock from django.conf import settings @@ -156,7 +159,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): # Test course export does not fail root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) export_course_to_xml(self.store, content_store, course.id, root_dir, u'test_export') filesystem = OSFS(text_type(root_dir / 'test_export/static')) @@ -171,11 +174,11 @@ class ImportRequiredTestCases(ContentStoreTestCase): shutil.rmtree(root_dir) def test_about_overrides(self): - ''' + """ This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html while there is a base definition in /about/effort.html - ''' + """ course_items = import_course_from_xml( self.store, self.user.id, TEST_DATA_DIR, ['toy'], create_if_not_present=True ) @@ -189,9 +192,9 @@ class ImportRequiredTestCases(ContentStoreTestCase): @requires_pillow_jpeg def test_asset_import(self): - ''' + """ This test validates that an image asset is imported and a thumbnail was generated for a .gif - ''' + """ content_store = contentstore() import_course_from_xml( @@ -255,7 +258,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): # now export the course to a tempdir and test that it contains files 'updates.html' and 'updates.items.json' # with same content as in course 'info' directory root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) export_course_to_xml(self.store, content_store, course.id, root_dir, u'test_export') # check that exported course has files 'updates.html' and 'updates.items.json' @@ -313,7 +316,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): course_id = self.import_and_populate_course() root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) # export out to a tempdir export_course_to_xml(self.store, content_store, course_id, root_dir, u'test_export') @@ -415,7 +418,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) # export out to a tempdir export_course_to_xml(self.store, content_store, course_id, root_dir, u'test_export') @@ -441,7 +444,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) # export out to a tempdir export_course_to_xml(self.store, content_store, course_id, root_dir, u'test_export') @@ -541,7 +544,7 @@ class ImportRequiredTestCases(ContentStoreTestCase): root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) export_course_to_xml(self.store, None, course_id, root_dir, u'test_export_no_content_store') # Delete the course from module store and reimport it @@ -684,8 +687,8 @@ class MiscCourseTests(ContentStoreTestCase): self.assertNotIn(malicious_code, resp.content) def test_advanced_components_in_edit_unit(self): - # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page - # response HTML + # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the + # page response HTML self.check_components_on_page( ADVANCED_COMPONENT_TYPES, ['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', 'Image Annotation', @@ -713,7 +716,7 @@ class MiscCourseTests(ContentStoreTestCase): # Now export the course to a tempdir and test that it contains assets. The export should pass root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) export_course_to_xml(self.store, content_store, self.course.id, root_dir, u'test_export') filesystem = OSFS(root_dir / 'test_export/static') @@ -773,7 +776,7 @@ class MiscCourseTests(ContentStoreTestCase): # Now export the course to a tempdir and test that it contains assets. root_dir = path(mkdtemp_clean()) - print 'Exporting to tempdir = {0}'.format(root_dir) + print('Exporting to tempdir = {0}'.format(root_dir)) export_course_to_xml(self.store, content_store, self.course.id, root_dir, u'test_export') # Verify that asset have been overwritten during export. @@ -807,11 +810,11 @@ class MiscCourseTests(ContentStoreTestCase): return cnt def test_get_items(self): - ''' + """ This verifies a bug we had where the None setting in get_items() meant 'wildcard' Unfortunately, None = published for the revision field, so get_items() would return both draft and non-draft copies. - ''' + """ self.store.convert_to_draft(self.problem.location, self.user.id) # Query get_items() and find the html item. This should just return back a single item (not 2). @@ -832,11 +835,11 @@ class MiscCourseTests(ContentStoreTestCase): self.assertTrue(getattr(items_from_draft_store[0], 'is_draft', False)) def test_draft_metadata(self): - ''' + """ This verifies a bug we had where inherited metadata was getting written to the module as 'own-metadata' when publishing. Also verifies the metadata inheritance is properly computed - ''' + """ # refetch course so it has all the children correct course = self.store.update_item(self.course, self.user.id) course.graceperiod = timedelta(days=1, hours=5, minutes=59, seconds=59) @@ -935,7 +938,7 @@ class MiscCourseTests(ContentStoreTestCase): """ Tests the ajax callback to render an XModule """ - with override_settings(COURSES_WITH_UNSAFE_CODE=[unicode(self.course.id)]): + with override_settings(COURSES_WITH_UNSAFE_CODE=[text_type(self.course.id)]): # also try a custom response which will trigger the 'is this course in whitelist' logic resp = self.client.get_json( get_url('xblock_view_handler', self.vert_loc, kwargs={'view_name': 'container_preview'}) @@ -944,7 +947,7 @@ class MiscCourseTests(ContentStoreTestCase): vertical = self.store.get_item(self.vert_loc) for child in vertical.children: - self.assertContains(resp, unicode(child)) + self.assertContains(resp, text_type(child)) def test_delete(self): # make sure the parent points to the child object which is to be deleted @@ -963,9 +966,9 @@ class MiscCourseTests(ContentStoreTestCase): self.assertNotIn(self.seq_loc, chapter.children) def test_asset_delete_and_restore(self): - ''' + """ This test will exercise the soft delete/restore functionality of the assets - ''' + """ asset_key = self._delete_asset_in_course() # now try to find it in store, but they should not be there any longer @@ -977,7 +980,7 @@ class MiscCourseTests(ContentStoreTestCase): self.assertIsNotNone(content) # let's restore the asset - restore_asset_from_trashcan(unicode(asset_key)) + restore_asset_from_trashcan(text_type(asset_key)) # now try to find it in courseware store, and they should be back after restore content = contentstore('trashcan').find(asset_key, throw_on_not_found=False) @@ -1001,7 +1004,7 @@ class MiscCourseTests(ContentStoreTestCase): url = reverse_course_url( 'assets_handler', self.course.id, - kwargs={'asset_key_string': unicode(asset_key)} + kwargs={'asset_key_string': text_type(asset_key)} ) resp = self.client.delete(url) self.assertEqual(resp.status_code, 204) @@ -1009,9 +1012,9 @@ class MiscCourseTests(ContentStoreTestCase): return asset_key def test_empty_trashcan(self): - ''' + """ This test will exercise the emptying of the asset trashcan - ''' + """ self._delete_asset_in_course() # make sure there's something in the trashcan @@ -1115,7 +1118,7 @@ class MiscCourseTests(ContentStoreTestCase): # check that /static/ has been converted to the full path # note, we know the link it should be because that's what in the 'toy' course in the test data asset_key = self.course.id.make_asset_key('asset', 'handouts_sample_handout.txt') - self.assertContains(resp, unicode(asset_key)) + self.assertContains(resp, text_type(asset_key)) def test_prefetch_children(self): # make sure we haven't done too many round trips to DB: @@ -1492,8 +1495,8 @@ class ContentStoreTest(ContentStoreTestCase): self.assertContains( resp, '
'.format( - locator=unicode(course.location), - course_key=unicode(course.id), + locator=text_type(course.location), + course_key=text_type(course.id), ), status_code=200, html=True @@ -1504,7 +1507,7 @@ class ContentStoreTest(ContentStoreTestCase): course = CourseFactory.create() section_data = { - 'parent_locator': unicode(course.location), + 'parent_locator': text_type(course.location), 'category': 'chapter', 'display_name': 'Section One', } @@ -1513,7 +1516,7 @@ class ContentStoreTest(ContentStoreTestCase): self.assertEqual(resp.status_code, 200) data = parse_json(resp) - retarget = unicode(course.id.make_usage_key('chapter', 'REPLACE')).replace('REPLACE', r'([0-9]|[a-f]){3,}') + retarget = text_type(course.id.make_usage_key('chapter', 'REPLACE')).replace('REPLACE', r'([0-9]|[a-f]){3,}') self.assertRegexpMatches(data['locator'], retarget) def test_capa_module(self): @@ -1521,7 +1524,7 @@ class ContentStoreTest(ContentStoreTestCase): course = CourseFactory.create() problem_data = { - 'parent_locator': unicode(course.location), + 'parent_locator': text_type(course.location), 'category': 'problem' } @@ -1808,7 +1811,7 @@ class MetadataSaveTestCase(ContentStoreTestCase): course = CourseFactory.create() - video_sample_xml = ''' + video_sample_xml = """ - ''' + """ self.video_descriptor = ItemFactory.create( parent_location=course.location, category='video', data={'data': video_sample_xml} @@ -1878,7 +1881,7 @@ class RerunCourseTest(ContentStoreTestCase): """Create and send an ajax post for the rerun request""" # create data to post - rerun_course_data = {'source_course_key': unicode(source_course_key)} + rerun_course_data = {'source_course_key': text_type(source_course_key)} if not destination_course_data: destination_course_data = self.destination_course_data rerun_course_data.update(destination_course_data) @@ -1898,7 +1901,7 @@ class RerunCourseTest(ContentStoreTestCase): def get_unsucceeded_course_action_elements(self, html, course_key): """Returns the elements in the unsucceeded course action section that have the given course_key""" - return html.cssselect('.courses-processing li[data-course-key="{}"]'.format(unicode(course_key))) + return html.cssselect('.courses-processing li[data-course-key="{}"]'.format(text_type(course_key))) def assertInCourseListing(self, course_key): """ @@ -1943,7 +1946,7 @@ class RerunCourseTest(ContentStoreTestCase): source_course = CourseFactory.create() destination_course_key = self.post_rerun_request(source_course.id) self.verify_rerun_course(source_course.id, destination_course_key, self.destination_course_data['display_name']) - videos = list(get_videos_for_course(unicode(destination_course_key))) + videos = list(get_videos_for_course(text_type(destination_course_key))) self.assertEqual(0, len(videos)) self.assertInCourseListing(destination_course_key) @@ -1952,7 +1955,7 @@ class RerunCourseTest(ContentStoreTestCase): create_video( dict( edx_video_id="tree-hugger", - courses=[unicode(source_course.id)], + courses=[text_type(source_course.id)], status='test', duration=2, encoded_videos=[] @@ -1962,8 +1965,8 @@ class RerunCourseTest(ContentStoreTestCase): self.verify_rerun_course(source_course.id, destination_course_key, self.destination_course_data['display_name']) # Verify that the VAL copies videos to the rerun - source_videos = list(get_videos_for_course(unicode(source_course.id))) - target_videos = list(get_videos_for_course(unicode(destination_course_key))) + source_videos = list(get_videos_for_course(text_type(source_course.id))) + target_videos = list(get_videos_for_course(text_type(destination_course_key))) self.assertEqual(1, len(source_videos)) self.assertEqual(source_videos, target_videos) @@ -2188,7 +2191,7 @@ class SigninPageTestCase(TestCase): # ... # # ... - # + # response = self.client.get("/signin") csrf_token = response.cookies.get("csrftoken") form = lxml.html.fromstring(response.content).get_element_by_id("login_form") @@ -2197,7 +2200,14 @@ class SigninPageTestCase(TestCase): self.assertIsNotNone(csrf_token) self.assertIsNotNone(csrf_token.value) self.assertIsNotNone(csrf_input_field) - self.assertEqual(csrf_token.value, csrf_input_field.attrib["value"]) + + # TODO: Remove Django 1.11 upgrade shim + # SHIM: _compare_salted_tokens was introduced in 1.10. Move the import and use only that branch post-upgrade. + if django.VERSION < (1, 10): + self.assertEqual(csrf_token.value, csrf_input_field.attrib["value"]) + else: + from django.middleware.csrf import _compare_salted_tokens + self.assertTrue(_compare_salted_tokens(csrf_token.value, csrf_input_field.attrib["value"])) def _create_course(test, course_key, course_data): diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index dcfb0afaaf..042153004d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -8,6 +8,7 @@ import mock from django.conf import settings from mock import patch from opaque_keys.edx.locator import CourseKey, LibraryLocator +from six import binary_type, text_type from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, parse_json from contentstore.utils import reverse_course_url, reverse_library_url @@ -23,7 +24,7 @@ LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries def make_url_for_lib(key): """ Get the RESTful/studio URL for testing the given library """ if isinstance(key, LibraryLocator): - key = unicode(key) + key = text_type(key) return LIBRARY_REST_URL + key @@ -239,11 +240,11 @@ class UnitTestLibraries(CourseTestCase): self.assertEqual(response.status_code, 200) info = parse_json(response) self.assertEqual(info['display_name'], lib.display_name) - self.assertEqual(info['library_id'], unicode(lib_key)) + self.assertEqual(info['library_id'], text_type(lib_key)) self.assertEqual(info['previous_version'], None) self.assertNotEqual(info['version'], None) self.assertNotEqual(info['version'], '') - self.assertEqual(info['version'], unicode(version)) + self.assertEqual(info['version'], text_type(version)) def test_get_lib_edit_html(self): """ @@ -319,12 +320,12 @@ class UnitTestLibraries(CourseTestCase): """ library = LibraryFactory.create() extra_user, _ = self.create_non_staff_user() - manage_users_url = reverse_library_url('manage_library_users', unicode(library.location.library_key)) + manage_users_url = reverse_library_url('manage_library_users', text_type(library.location.library_key)) response = self.client.get(manage_users_url) self.assertEqual(response.status_code, 200) # extra_user has not been assigned to the library so should not show up in the list: - self.assertNotIn(extra_user.username, response.content) + self.assertNotIn(binary_type(extra_user.username), response.content) # Now add extra_user to the library: user_details_url = reverse_course_url( @@ -337,4 +338,4 @@ class UnitTestLibraries(CourseTestCase): # Now extra_user should apear in the list: response = self.client.get(manage_users_url) self.assertEqual(response.status_code, 200) - self.assertIn(extra_user.username, response.content) + self.assertIn(binary_type(extra_user.username), response.content) diff --git a/cms/djangoapps/course_creators/tests/test_admin.py b/cms/djangoapps/course_creators/tests/test_admin.py index 949fa45099..438b2e6bf8 100644 --- a/cms/djangoapps/course_creators/tests/test_admin.py +++ b/cms/djangoapps/course_creators/tests/test_admin.py @@ -3,6 +3,7 @@ Tests course_creators.admin.py. """ import mock +import django from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import User from django.core import mail @@ -105,7 +106,13 @@ class CourseCreatorAdminTest(TestCase): # message sent. Admin message will follow. base_num_emails = 1 if expect_sent_to_user else 0 if expect_sent_to_admin: - context = {'user_name': "test_user", 'user_email': u'test_user+courses@edx.org'} + # TODO: Remove Django 1.11 upgrade shim + # SHIM: Usernames come back as unicode in 1.10+, remove this shim post-upgrade + if django.VERSION < (1, 10): + context = {'user_name': 'test_user', 'user_email': u'test_user+courses@edx.org'} + else: + context = {'user_name': u'test_user', 'user_email': u'test_user+courses@edx.org'} + self.assertEquals(base_num_emails + 1, len(mail.outbox), 'Expected admin message to be sent') sent_mail = mail.outbox[base_num_emails] self.assertEquals( diff --git a/cms/envs/test.py b/cms/envs/test.py index ca401048c3..2ddf3a1e09 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -39,13 +39,9 @@ from lms.envs.test import ( REGISTRATION_EXTRA_FIELDS, ) -# Add some host names used in assorted tests +# Allow all hosts during tests, we use a lot of different ones all over the codebase. ALLOWED_HOSTS = [ - 'localhost', - 'logistration.testserver', - '.testserver.fake', - 'test-site.testserver', - 'testserver.fakeother', + '*' ] # mongo connection settings diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index bec5d01179..298fb79001 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -1,24 +1,55 @@ -"""Helpers for the student app. """ +""" +Helpers for the student app. +""" +import json import logging import mimetypes import urllib import urlparse from datetime import datetime +import django from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.urlresolvers import NoReverseMatch, reverse +from django.core.validators import ValidationError, validate_email +from django.contrib.auth import authenticate, load_backend, login, logout +from django.contrib.auth.models import User +from django.db import IntegrityError, transaction from django.utils import http +from django.utils.translation import ugettext as _ from oauth2_provider.models import AccessToken as dot_access_token from oauth2_provider.models import RefreshToken as dot_refresh_token from provider.oauth2.models import AccessToken as dop_access_token from provider.oauth2.models import RefreshToken as dop_refresh_token from pytz import UTC - +from six import iteritems, text_type import third_party_auth from course_modes.models import CourseMode +from lms.djangoapps.certificates.api import ( # pylint: disable=import-error + get_certificate_url, + has_html_certificates_enabled +) +from lms.djangoapps.certificates.models import ( # pylint: disable=import-error + CertificateStatuses, + certificate_status_for_student +) +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline +from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming.helpers import get_themes +from student.models import ( + CourseEnrollment, + LinkedInAddToProfileConfiguration, + PasswordHistory, + Registration, + UserAttribute, + UserProfile, + unique_id_for_user +) + # Enumeration of per-course verification statuses # we display on the student dashboard. @@ -186,7 +217,7 @@ def check_verify_status_by_course(user, course_enrollments): } if recent_verification_datetime: - for key, value in status_by_course.iteritems(): # pylint: disable=unused-variable + for key, value in iteritems(status_by_course): # pylint: disable=unused-variable status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y") return status_by_course @@ -348,3 +379,304 @@ def destroy_oauth_tokens(user): dop_refresh_token.objects.filter(user=user.id).delete() dot_access_token.objects.filter(user=user.id).delete() dot_refresh_token.objects.filter(user=user.id).delete() + + +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), + } + + +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) + + +# TODO: Remove Django 1.11 upgrade shim +# SHIM: Compensate for behavior change of default authentication backend in 1.10 +if django.VERSION < (1, 10): + NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.ModelBackend' +else: + # We want to allow inactive users to log in only when their account is first created + NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.AllowAllUsersModelBackend' + +# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint +# pylint: disable=logging-format-interpolation + + +def authenticate_new_user(request, username, password): + """ + 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. + """ + backend = load_backend(NEW_USER_AUTH_BACKEND) + user = backend.authenticate(request=request, username=username, password=password) + user.backend = NEW_USER_AUTH_BACKEND + return user + + +class AccountValidationError(Exception): + """ + Used in account creation views to raise exceptions with details about specific invalid fields + """ + def __init__(self, message, field): + super(AccountValidationError, self).__init__(message) + self.field = field + + +def cert_info(user, course_overview): + """ + Get the certificate info needed to render the dashboard section for the given + student and course. + + Arguments: + user (User): A user. + course_overview (CourseOverview): A course. + + Returns: + dict: A dictionary with keys: + 'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or + 'certificate_earned_but_not_available' + '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' + 'can_unenroll': if status allows for unenrollment + """ + return _cert_info( + user, + course_overview, + certificate_status_for_student(user, course_overview.id) + ) + + +def _cert_info(user, course_overview, cert_status): + """ + Implements the logic for cert_info -- split out for testing. + + Arguments: + user (User): A user. + course_overview (CourseOverview): A course. + """ + # simplify the status for the template using this lookup table + template_state = { + CertificateStatuses.generating: 'generating', + CertificateStatuses.downloadable: 'downloadable', + CertificateStatuses.notpassing: 'notpassing', + CertificateStatuses.restricted: 'restricted', + CertificateStatuses.auditing: 'auditing', + CertificateStatuses.audit_passing: 'auditing', + CertificateStatuses.audit_notpassing: 'auditing', + CertificateStatuses.unverified: 'unverified', + } + + certificate_earned_but_not_available_status = 'certificate_earned_but_not_available' + default_status = 'processing' + + default_info = { + 'status': default_status, + 'show_survey_button': False, + 'can_unenroll': True, + } + + if cert_status is None: + return default_info + + status = template_state.get(cert_status['status'], default_status) + is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') + + 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 + + if ( + course_overview.certificates_display_behavior == 'early_no_info' and + is_hidden_status + ): + return default_info + + status_dict = { + 'status': status, + 'mode': cert_status.get('mode', None), + 'linked_in_url': None, + 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, + } + + if not status == default_status and course_overview.end_of_course_survey_url is not None: + status_dict.update({ + 'show_survey_button': True, + 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) + else: + status_dict['show_survey_button'] = False + + if status == 'downloadable': + # showing the certificate web view button if certificate is downloadable state and feature flags are enabled. + if has_html_certificates_enabled(course_overview): + if course_overview.has_any_active_web_certificate: + status_dict.update({ + 'show_cert_web_view': True, + 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) + }) + else: + # don't show download certificate button if we don't have an active certificate for course + status_dict['status'] = 'unavailable' + elif 'download_url' not in cert_status: + log.warning( + u"User %s has a downloadable cert for %s, but no download url", + user.username, + course_overview.id + ) + return default_info + else: + status_dict['download_url'] = cert_status['download_url'] + + # 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() + + # posting certificates to LinkedIn is not currently + # supported in White Labels + if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): + status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( + course_overview.id, + course_overview.display_name, + cert_status.get('mode'), + cert_status['download_url'] + ) + + if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: + cert_grade_percent = -1 + persisted_grade_percent = -1 + persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False) + if persisted_grade is not None: + 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: + # 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. + return default_info + + status_dict['grade'] = text_type(max(cert_grade_percent, persisted_grade_percent)) + + return status_dict + + +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. + """ + return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) + + +def do_create_account(form, custom_form=None): + """ + 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. + """ + # 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() + + errors = {} + errors.update(form.errors) + if custom_form: + errors.update(custom_form.errors) + + if errors: + raise ValidationError(errors) + + user = User( + username=form.cleaned_data["username"], + email=form.cleaned_data["email"], + is_active=False + ) + user.set_password(form.cleaned_data["password"]) + registration = Registration() + + # 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: + with transaction.atomic(): + user.save() + if custom_form: + custom_model = custom_form.save(commit=False) + custom_model.user = user + custom_model.save() + except IntegrityError: + # Figure out the cause of the integrity error + # 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.") + if len(User.objects.filter(username=user.username)) > 0: + raise AccountValidationError( + _("An account with the Public Username '{username}' already exists.").format(username=user.username), + field="username" + ) + elif len(User.objects.filter(email=user.email)) > 0: + raise AccountValidationError( + _("An account with the Email '{email}' already exists.").format(email=user.email), + field="email" + ) + else: + raise + + # 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) + + registration.register(user) + + 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 + if extended_profile: + profile.meta = json.dumps(extended_profile) + try: + profile.save() + except Exception: # pylint: disable=broad-except + log.exception("UserProfile creation failed for user {id}.".format(id=user.id)) + raise + + return user, profile, registration diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py index 089d0845a2..64485634dc 100644 --- a/common/djangoapps/student/tests/test_certificates.py +++ b/common/djangoapps/student/tests/test_certificates.py @@ -167,7 +167,7 @@ class CertificateDisplayTest(CertificateDisplayTestBase): @patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False}) def test_no_certificate_status_no_problem(self): - with patch('student.views.cert_info', return_value={}): + with patch('student.views.dashboard.cert_info', return_value={}): self._create_certificate('honor') self._check_can_not_download_certificate() diff --git a/common/djangoapps/student/tests/test_credit.py b/common/djangoapps/student/tests/test_credit.py index 73c3373bad..68a45ffa69 100644 --- a/common/djangoapps/student/tests/test_credit.py +++ b/common/djangoapps/student/tests/test_credit.py @@ -233,7 +233,7 @@ class CreditCourseDashboardTest(ModuleStoreTestCase): self._make_eligible() # The user should have the option to purchase credit - with patch('student.views.get_credit_provider_display_names') as mock_method: + with patch('student.views.dashboard.get_credit_provider_display_names') as mock_method: mock_method.return_value = providers_list response = self._load_dashboard() diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 7f1752777b..36e0c2ce1c 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -22,37 +22,45 @@ from student.views import ( SETTING_CHANGE_INITIATED, confirm_email_change, do_email_change_request, - generate_activation_email_context, - reactivation_email_for_user, validate_new_email ) +from student.views import generate_activation_email_context, send_reactivation_email_for_user from third_party_auth.views import inactive_user_view from util.request import safe_get_host from util.testing import EventTestMixin class TestException(Exception): - """Exception used for testing that nothing will catch explicitly""" + """ + Exception used for testing that nothing will catch explicitly + """ pass def mock_render_to_string(template_name, context): - """Return a string that encodes template_name and context""" + """ + Return a string that encodes template_name and context + """ return str((template_name, sorted(context.iteritems()))) def mock_render_to_response(template_name, context): - """Return an HttpResponse with content that encodes template_name and context""" + """ + Return an HttpResponse with content that encodes template_name and context + """ # This simulates any db access in the templates. UserProfile.objects.exists() return HttpResponse(mock_render_to_string(template_name, context)) class EmailTestMixin(object): - """Adds useful assertions for testing `email_user`""" + """ + Adds useful assertions for testing `email_user` + """ def assertEmailUser(self, email_user, subject_template, subject_context, body_template, body_context): - """Assert that `email_user` was used to send and email with the supplied subject and body + """ + Assert that `email_user` was used to send and email with the supplied subject and body `email_user`: The mock `django.contrib.auth.models.User.email_user` function to verify @@ -68,14 +76,18 @@ class EmailTestMixin(object): ) def append_allowed_hosts(self, hostname): - """ Append hostname to settings.ALLOWED_HOSTS """ + """ + Append hostname to settings.ALLOWED_HOSTS + """ settings.ALLOWED_HOSTS.append(hostname) self.addCleanup(settings.ALLOWED_HOSTS.pop) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class ActivationEmailTests(TestCase): - """Test sending of the activation email. """ + """ + Test sending of the activation email. + """ ACTIVATION_SUBJECT = u"Action Required: Activate your {} account".format(settings.PLATFORM_NAME) @@ -100,7 +112,9 @@ class ActivationEmailTests(TestCase): self._assert_activation_email(self.ACTIVATION_SUBJECT, self.OPENEDX_FRAGMENTS) def _create_account(self): - """Create an account, triggering the activation email. """ + """ + Create an account, triggering the activation email. + """ url = reverse('create_account') params = { 'username': 'test_user', @@ -120,7 +134,9 @@ class ActivationEmailTests(TestCase): ) def _assert_activation_email(self, subject, body_fragments): - """Verify that the activation email was sent. """ + """ + Verify that the activation email was sent. + """ self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] self.assertEqual(msg.subject, subject) @@ -146,10 +162,12 @@ class ActivationEmailTests(TestCase): ) -@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) +@patch('student.views.login.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @patch('django.contrib.auth.models.User.email_user') class ReactivationEmailTests(EmailTestMixin, TestCase): - """Test sending a reactivation email to a user""" + """ + Test sending a reactivation email to a user + """ def setUp(self): super(ReactivationEmailTests, self).setUp() @@ -162,10 +180,12 @@ class ReactivationEmailTests(EmailTestMixin, TestCase): Send the reactivation email to the specified user, and return the response as json data. """ - return json.loads(reactivation_email_for_user(user).content) + return json.loads(send_reactivation_email_for_user(user).content) def assertReactivateEmailSent(self, email_user): - """Assert that the correct reactivation email has been sent""" + """ + Assert that the correct reactivation email has been sent + """ context = generate_activation_email_context(self.user, self.registration) self.assertEmailUser( @@ -222,10 +242,12 @@ class ReactivationEmailTests(EmailTestMixin, TestCase): class EmailChangeRequestTests(EventTestMixin, TestCase): - """Test changing a user's email address""" + """ + Test changing a user's email address + """ def setUp(self): - super(EmailChangeRequestTests, self).setUp('student.views.tracker') + super(EmailChangeRequestTests, self).setUp('student.views.management.tracker') self.user = UserFactory.create() self.new_email = 'new.email@edx.org' self.req_factory = RequestFactory() @@ -237,28 +259,36 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): self.user.email_user = Mock() def do_email_validation(self, email): - """Executes validate_new_email, returning any resulting error message. """ + """ + Executes validate_new_email, returning any resulting error message. + """ try: validate_new_email(self.request.user, email) except ValueError as err: return err.message def do_email_change(self, user, email, activation_key=None): - """Executes do_email_change_request, returning any resulting error message. """ + """ + Executes do_email_change_request, returning any resulting error message. + """ try: do_email_change_request(user, email, activation_key) except ValueError as err: return err.message def assertFailedRequest(self, response_data, expected_error): - """Assert that `response_data` indicates a failed request that returns `expected_error`""" + """ + Assert that `response_data` indicates a failed request that returns `expected_error` + """ self.assertFalse(response_data['success']) self.assertEquals(expected_error, response_data['error']) self.assertFalse(self.user.email_user.called) - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_duplicate_activation_key(self): - """Assert that if two users change Email address simultaneously, no error is thrown""" + """ + Assert that if two users change Email address simultaneously, no error is thrown + """ # New emails for the users user1_new_email = "valid_user1_email@example.com" @@ -280,7 +310,9 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): self.assertEqual(self.do_email_validation(email), 'Valid e-mail address required.') def test_change_email_to_existing_value(self): - """ Test the error message if user attempts to change email to the existing value. """ + """ + Test the error message if user attempts to change email to the existing value. + """ self.assertEqual(self.do_email_validation(self.user.email), 'Old email is the same as the new email.') def test_duplicate_email(self): @@ -292,9 +324,11 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): self.assertEqual(self.do_email_validation(self.new_email), 'An account with this e-mail already exists.') @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_email_failure(self, send_mail): - """ Test the return value if sending the email for the user to click fails. """ + """ + Test the return value if sending the email for the user to click fails. + """ send_mail.side_effect = [Exception, None] self.assertEqual( self.do_email_change(self.user, "valid@email.com"), @@ -303,9 +337,11 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): self.assert_no_events_were_emitted() @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_email_success(self, send_mail): - """ Test email was sent if no errors encountered. """ + """ + Test email was sent if no errors encountered. + """ old_email = self.user.email new_email = "valid@example.com" registration_key = "test registration key" @@ -327,10 +363,12 @@ class EmailChangeRequestTests(EventTestMixin, TestCase): @patch('django.contrib.auth.models.User.email_user') -@patch('student.views.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) -@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) +@patch('student.views.management.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) +@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase): - """Test that confirmation of email change requests function even in the face of exceptions thrown while sending email""" + """ + Test that confirmation of email change requests function even in the face of exceptions thrown while sending email + """ def setUp(self): super(EmailChangeConfirmationTests, self).setUp() self.user = UserFactory.create() @@ -343,18 +381,23 @@ class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase): self.key = self.pending_change_request.activation_key def assertRolledBack(self): - """Assert that no changes to user, profile, or pending email have been made to the db""" + """ + Assert that no changes to user, profile, or pending email have been made to the db + """ self.assertEquals(self.user.email, User.objects.get(username=self.user.username).email) self.assertEquals(self.profile.meta, UserProfile.objects.get(user=self.user).meta) self.assertEquals(1, PendingEmailChange.objects.count()) def assertFailedBeforeEmailing(self, email_user): - """Assert that the function failed before emailing a user""" + """ + Assert that the function failed before emailing a user + """ self.assertRolledBack() self.assertFalse(email_user.called) def check_confirm_email_change(self, expected_template, expected_context): - """Call `confirm_email_change` and assert that the content was generated as expected + """ + Call `confirm_email_change` and assert that the content was generated as expected `expected_template`: The name of the template that should have been used to generate the content @@ -368,7 +411,9 @@ class EmailChangeConfirmationTests(EmailTestMixin, TransactionTestCase): ) def assertChangeEmailSent(self, email_user): - """Assert that the correct email was sent to confirm an email change""" + """ + Assert that the correct email was sent to confirm an email change + """ context = { 'old_email': self.user.email, 'new_email': self.pending_change_request.new_email, diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 6d9040fb87..4fe33807fb 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -84,7 +84,11 @@ class LoginTest(CacheIsolationTestCase): def test_login_fail_no_user_exists(self): nonexistent_email = u'not_a_user@edx.org' - response, mock_audit_log = self._login_response(nonexistent_email, 'test_password') + response, mock_audit_log = self._login_response( + nonexistent_email, + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value='Email or password is incorrect') self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email]) @@ -92,7 +96,11 @@ class LoginTest(CacheIsolationTestCase): @patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) def test_login_fail_incorrect_email_with_advanced_security(self): nonexistent_email = u'not_a_user@edx.org' - response, mock_audit_log = self._login_response(nonexistent_email, 'test_password') + response, mock_audit_log = self._login_response( + nonexistent_email, + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value='Email or password is incorrect') self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email]) @@ -100,21 +108,33 @@ class LoginTest(CacheIsolationTestCase): @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_fail_no_user_exists_no_pii(self): nonexistent_email = u'not_a_user@edx.org' - response, mock_audit_log = self._login_response(nonexistent_email, 'test_password') + response, mock_audit_log = self._login_response( + nonexistent_email, + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value='Email or password is incorrect') self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email']) self._assert_not_in_audit_log(mock_audit_log, 'warning', [nonexistent_email]) def test_login_fail_wrong_password(self): - response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password') + response, mock_audit_log = self._login_response( + 'test@edx.org', + 'wrong_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value='Email or password is incorrect') self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_fail_wrong_password_no_pii(self): - response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password') + response, mock_audit_log = self._login_response( + 'test@edx.org', + 'wrong_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value='Email or password is incorrect') self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'invalid']) @@ -126,7 +146,11 @@ class LoginTest(CacheIsolationTestCase): self.user.save() # Should now be unable to login - response, mock_audit_log = self._login_response('test@edx.org', 'test_password') + response, mock_audit_log = self._login_response( + 'test@edx.org', + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value="In order to sign in, you need to activate your account.") self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user']) @@ -138,7 +162,11 @@ class LoginTest(CacheIsolationTestCase): self.user.save() # Should now be unable to login - response, mock_audit_log = self._login_response('test@edx.org', 'test_password') + response, mock_audit_log = self._login_response( + 'test@edx.org', + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False, value="In order to sign in, you need to activate your account.") self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user']) @@ -146,13 +174,21 @@ class LoginTest(CacheIsolationTestCase): def test_login_unicode_email(self): unicode_email = u'test@edx.org' + unichr(40960) - response, mock_audit_log = self._login_response(unicode_email, 'test_password') + response, mock_audit_log = self._login_response( + unicode_email, + 'test_password', + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False) self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email]) def test_login_unicode_password(self): unicode_password = u'test_password' + unichr(1972) - response, mock_audit_log = self._login_response('test@edx.org', unicode_password) + response, mock_audit_log = self._login_response( + 'test@edx.org', + unicode_password, + 'student.views.login.AUDIT_LOG' + ) self._assert_response(response, success=False) self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) @@ -378,7 +414,9 @@ class LoginTest(CacheIsolationTestCase): self._assert_response(response, success=True) def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG', extra_post_params=None): - ''' Post the login info ''' + """ + Post the login info + """ post_params = {'email': email, 'password': password} if extra_post_params is not None: post_params.update(extra_post_params) @@ -387,7 +425,7 @@ class LoginTest(CacheIsolationTestCase): return result, mock_audit_log def _assert_response(self, response, success=None, value=None): - ''' + """ Assert that the response had status 200 and returned a valid JSON-parseable dict. @@ -396,7 +434,7 @@ class LoginTest(CacheIsolationTestCase): If value is provided, assert that the response contained that value for 'value' in the JSON dict. - ''' + """ self.assertEqual(response.status_code, 200) try: @@ -499,16 +537,16 @@ class ExternalAuthShibTest(ModuleStoreTestCase): Tests the redirects when visiting course-specific URL with @login_required. Should vary by course depending on its enrollment_domain """ - TARGET_URL = reverse('courseware', args=[text_type(self.course.id)]) # pylint: disable=invalid-name - noshib_response = self.client.get(TARGET_URL, follow=True, HTTP_ACCEPT="text/html") + target_url = reverse('courseware', args=[text_type(self.course.id)]) + noshib_response = self.client.get(target_url, follow=True, HTTP_ACCEPT="text/html") self.assertEqual(noshib_response.redirect_chain[-1], - (expected_redirect_url('/login?next={url}'.format(url=TARGET_URL)), 302)) + (expected_redirect_url('/login?next={url}'.format(url=target_url)), 302)) self.assertContains(noshib_response, (u"Sign in or Register | {platform_name}" .format(platform_name=settings.PLATFORM_NAME))) self.assertEqual(noshib_response.status_code, 200) - TARGET_URL_SHIB = reverse('courseware', args=[text_type(self.shib_course.id)]) # pylint: disable=invalid-name - shib_response = self.client.get(**{'path': TARGET_URL_SHIB, + target_url_shib = reverse('courseware', args=[text_type(self.shib_course.id)]) + shib_response = self.client.get(**{'path': target_url_shib, 'follow': True, 'REMOTE_USER': self.extauth.external_id, 'Shib-Identity-Provider': 'https://idp.stanford.edu/', @@ -517,9 +555,9 @@ class ExternalAuthShibTest(ModuleStoreTestCase): # The 'courseware' page actually causes a redirect itself, so it's not the end of the chain and we # won't test its contents self.assertEqual(shib_response.redirect_chain[-3], - (expected_redirect_url('/shib-login/?next={url}'.format(url=TARGET_URL_SHIB)), 302)) + (expected_redirect_url('/shib-login/?next={url}'.format(url=target_url_shib)), 302)) self.assertEqual(shib_response.redirect_chain[-2], - (expected_redirect_url(TARGET_URL_SHIB), 302)) + (expected_redirect_url(target_url_shib), 302)) self.assertEqual(shib_response.status_code, 200) diff --git a/common/djangoapps/student/tests/test_recent_enrollments.py b/common/djangoapps/student/tests/test_recent_enrollments.py index 542f484be1..8050992cbd 100644 --- a/common/djangoapps/student/tests/test_recent_enrollments.py +++ b/common/djangoapps/student/tests/test_recent_enrollments.py @@ -18,7 +18,8 @@ from openedx.core.djangoapps.site_configuration.tests.test_util import with_site from shoppingcart.models import DonationConfiguration from student.models import CourseEnrollment, DashboardConfiguration from student.tests.factories import UserFactory -from student.views import _get_recently_enrolled_courses, get_course_enrollments +from student.views import get_course_enrollments +from student.views.dashboard import _get_recently_enrolled_courses from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py index 61b2488db3..7175949d1c 100644 --- a/common/djangoapps/student/tests/test_reset_password.py +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -44,7 +44,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): ENABLED_CACHES = ['default'] def setUp(self): - super(ResetPasswordTests, self).setUp('student.views.tracker') + super(ResetPasswordTests, self).setUp('student.views.management.tracker') self.user = UserFactory.create() self.user.is_active = False self.user.save() @@ -56,7 +56,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX self.user_bad_passwd.save() - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_user_bad_password_reset(self): """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX""" @@ -71,7 +71,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): }) self.assert_no_events_were_emitted() - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_nonexist_email_password_reset(self): """Now test the exception cases with of reset_password called with invalid email.""" @@ -88,7 +88,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): }) self.assert_no_events_were_emitted() - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_password_reset_ratelimited(self): """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ cache.clear() @@ -110,7 +110,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_reset_password_email(self, send_email): """Tests contents of reset password email, and that user is not active""" @@ -310,7 +310,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase): self.assertEqual(response.context_data['err_msg'], password_dict['error_message']) - @patch('student.views.password_reset_confirm') + @patch('student.views.management.password_reset_confirm') @patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value) def test_reset_password_good_token_configuration_override(self, reset_confirm): """Tests password reset confirmation page for site configuration override.""" diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 8a9deab0ba..cf63aa82d1 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -63,7 +63,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): self.cert_status = 'processing' self.client.login(username=self.user.username, password=PASSWORD) - def mock_cert(self, _user, _course_overview, _course_mode): + def mock_cert(self, _user, _course_overview): """ Return a preset certificate status. """ return { 'status': self.cert_status, @@ -86,7 +86,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): """ Assert that the unenroll action is shown or not based on the cert status.""" self.cert_status = cert_status - with patch('student.views.cert_info', side_effect=self.mock_cert): + with patch('student.views.dashboard.cert_info', side_effect=self.mock_cert): response = self.client.get(reverse('dashboard')) self.assertEqual(pq(response.content)(self.UNENROLL_ELEMENT_ID).length, unenroll_action_count) @@ -104,7 +104,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): """ Assert that the unenroll method is called or not based on the cert status""" self.cert_status = cert_status - with patch('student.views.cert_info', side_effect=self.mock_cert): + with patch('student.views.management.cert_info', side_effect=self.mock_cert): with patch('lms.djangoapps.commerce.signals.handle_refund_order') as mock_refund_handler: REFUND_ORDER.connect(mock_refund_handler) response = self.client.post( @@ -354,8 +354,8 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin): self.assertNotIn('
', response.content) @patch('openedx.core.djangoapps.programs.utils.get_programs') - @patch('student.views.get_visible_sessions_for_entitlement') - @patch('student.views.get_pseudo_session_for_entitlement') + @patch('student.views.dashboard.get_visible_sessions_for_entitlement') + @patch('student.views.dashboard.get_pseudo_session_for_entitlement') @patch.object(CourseOverview, 'get_from_id') def test_unfulfilled_entitlement(self, mock_course_overview, mock_pseudo_session, mock_course_runs, mock_get_programs): @@ -411,7 +411,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin): self.assertIn('You must select a session to access the course.', response.content) self.assertNotIn('To access the course, select a session.', response.content) - @patch('student.views.get_visible_sessions_for_entitlement') + @patch('student.views.dashboard.get_visible_sessions_for_entitlement') @patch.object(CourseOverview, 'get_from_id') def test_unfulfilled_expired_entitlement(self, mock_course_overview, mock_course_runs): """ @@ -504,7 +504,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin): # self.assertNotIn(noAvailableSessions, response.content) @patch('openedx.core.djangoapps.programs.utils.get_programs') - @patch('student.views.get_visible_sessions_for_entitlement') + @patch('student.views.dashboard.get_visible_sessions_for_entitlement') @patch.object(CourseOverview, 'get_from_id') @patch('opaque_keys.edx.keys.CourseKey.from_string') def test_fulfilled_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs): @@ -541,7 +541,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin): self.assertIn('Related Programs:', response.content) @patch('openedx.core.djangoapps.programs.utils.get_programs') - @patch('student.views.get_visible_sessions_for_entitlement') + @patch('student.views.dashboard.get_visible_sessions_for_entitlement') @patch.object(CourseOverview, 'get_from_id') @patch('opaque_keys.edx.keys.CourseKey.from_string') def test_fulfilled_expired_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs, mock_get_programs): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index a1753204ec..31df861188 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -35,6 +35,7 @@ from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, Pr from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms +from student.helpers import _cert_info, process_survey_link from student.models import ( CourseEnrollment, LinkedInAddToProfileConfiguration, @@ -44,7 +45,7 @@ from student.models import ( user_by_anonymous_id ) from student.tests.factories import CourseEnrollmentFactory, UserFactory -from student.views import _cert_info, complete_course_mode_info, process_survey_link +from student.views import complete_course_mode_info from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME from util.testing import EventTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreEnum, ModuleStoreTestCase, SharedModuleStoreTestCase @@ -78,10 +79,9 @@ class CourseEndingTest(TestCase): certificates_display_behavior='end', id=CourseLocator(org="x", course="y", run="z"), ) - course_mode = 'honor' self.assertEqual( - _cert_info(user, course, None, course_mode), + _cert_info(user, course, None), { 'status': 'processing', 'show_survey_button': False, @@ -91,7 +91,7 @@ class CourseEndingTest(TestCase): cert_status = {'status': 'unavailable'} self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'processing', 'show_survey_button': False, @@ -105,7 +105,7 @@ class CourseEndingTest(TestCase): with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = Mock(percent=1.0) self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'generating', 'show_survey_button': True, @@ -119,7 +119,7 @@ class CourseEndingTest(TestCase): cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor'} self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'generating', 'show_survey_button': True, @@ -140,7 +140,7 @@ class CourseEndingTest(TestCase): } self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'downloadable', 'download_url': download_url, @@ -159,7 +159,7 @@ class CourseEndingTest(TestCase): 'mode': 'honor' } self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'notpassing', 'show_survey_button': True, @@ -178,7 +178,7 @@ class CourseEndingTest(TestCase): 'download_url': download_url, 'mode': 'honor' } self.assertEqual( - _cert_info(user, course2, cert_status, course_mode), + _cert_info(user, course2, cert_status), { 'status': 'notpassing', 'show_survey_button': False, @@ -193,7 +193,7 @@ class CourseEndingTest(TestCase): course2.certificates_display_behavior = 'early_no_info' cert_status = {'status': 'unavailable'} self.assertEqual( - _cert_info(user, course2, cert_status, course_mode), + _cert_info(user, course2, cert_status), { 'status': 'processing', 'show_survey_button': False, @@ -207,7 +207,7 @@ class CourseEndingTest(TestCase): 'mode': 'honor' } self.assertEqual( - _cert_info(user, course2, cert_status, course_mode), + _cert_info(user, course2, cert_status), { 'status': 'processing', 'show_survey_button': False, @@ -239,7 +239,6 @@ class CourseEndingTest(TestCase): certificates_display_behavior='end', id=CourseLocator(org="x", course="y", run="z"), ) - course_mode = 'honor' if cert_grade is not None: cert_status = {'status': 'generating', 'grade': unicode(cert_grade), 'mode': 'honor'} @@ -249,7 +248,7 @@ class CourseEndingTest(TestCase): with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = Mock(percent=persisted_grade) self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'generating', 'show_survey_button': True, @@ -274,13 +273,12 @@ class CourseEndingTest(TestCase): certificates_display_behavior='end', id=CourseLocator(org="x", course="y", run="z"), ) - course_mode = 'honor' cert_status = {'status': 'generating', 'mode': 'honor'} with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade: patch_persisted_grade.return_value = None self.assertEqual( - _cert_info(user, course, cert_status, course_mode), + _cert_info(user, course, cert_status), { 'status': 'processing', 'show_survey_button': False, diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index d7daecf96f..21be51b267 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -6,39 +6,39 @@ from django.conf import settings from django.conf.urls import url from django.contrib.auth.views import password_reset_complete -import student.views +from . import views urlpatterns = [ - url(r'^logout$', student.views.LogoutView.as_view(), name='logout'), + url(r'^logout$', views.LogoutView.as_view(), name='logout'), # TODO: standardize login # login endpoint used by cms. - url(r'^login_post$', student.views.login_user, name='login_post'), + url(r'^login_post$', views.login_user, name='login_post'), # login endpoints used by lms. - url(r'^login_ajax$', student.views.login_user, name="login"), - url(r'^login_ajax/(?P[^/]*)$', student.views.login_user), + url(r'^login_ajax$', views.login_user, name="login"), + url(r'^login_ajax/(?P[^/]*)$', views.login_user), - url(r'^email_confirm/(?P[^/]*)$', student.views.confirm_email_change, name='confirm_email_change'), + url(r'^email_confirm/(?P[^/]*)$', views.confirm_email_change, name='confirm_email_change'), - url(r'^create_account$', student.views.create_account, name='create_account'), - url(r'^activate/(?P[^/]*)$', student.views.activate_account, name="activate"), + url(r'^create_account$', views.create_account, name='create_account'), + url(r'^activate/(?P[^/]*)$', views.activate_account, name="activate"), - url(r'^accounts/disable_account_ajax$', student.views.disable_account_ajax, name="disable_account_ajax"), - url(r'^accounts/manage_user_standing', student.views.manage_user_standing, name='manage_user_standing'), + url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"), + url(r'^accounts/manage_user_standing', views.manage_user_standing, name='manage_user_standing'), - url(r'^change_setting$', student.views.change_setting, name='change_setting'), - url(r'^change_email_settings$', student.views.change_email_settings, name='change_email_settings'), + url(r'^change_setting$', views.change_setting, name='change_setting'), + url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'), - # password reset in student.views (see below for password reset django views) - url(r'^password_reset/$', student.views.password_reset, name='password_reset'), + # password reset in views (see below for password reset django views) + url(r'^password_reset/$', views.password_reset, name='password_reset'), url( r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', - student.views.password_reset_confirm_wrapper, + views.password_reset_confirm_wrapper, name='password_reset_confirm', ), url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN), - student.views.course_run_refund_status, + views.course_run_refund_status, name="course_run_refund_status"), ] @@ -46,10 +46,10 @@ urlpatterns = [ # enable automatic login if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'): urlpatterns += [ - url(r'^auto_auth$', student.views.auto_auth), + url(r'^auto_auth$', views.auto_auth), ] -# password reset django views (see above for password reset student.views) +# password reset django views (see above for password reset views) urlpatterns += [ # TODO: Replace with Mako-ized views url( diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py deleted file mode 100644 index 1b5fcdb610..0000000000 --- a/common/djangoapps/student/views.py +++ /dev/null @@ -1,3136 +0,0 @@ -""" -Student Views -""" - -import datetime -import json -import logging -import uuid -import warnings -from collections import defaultdict, namedtuple -from urlparse import parse_qs, urlsplit, urlunsplit - -import django -import analytics -import edx_oauth2_provider -from django.conf import settings -from django.contrib import messages -from django.contrib.auth import authenticate, load_backend, login, logout -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import AnonymousUser, User -from django.contrib.auth.views import password_reset_confirm -from django.core import mail -from django.template.context_processors import csrf -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy -from django.core.validators import ValidationError, validate_email -from django.db import IntegrityError, transaction -from django.db.models.signals import post_save -from django.dispatch import Signal, receiver -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden -from django.shortcuts import redirect -from django.template.response import TemplateResponse -from django.utils.encoding import force_bytes, force_text -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 -from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie -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 -from provider.oauth2.models import Client -from pytz import UTC -from ratelimitbackend.exceptions import RateLimitException -from requests import HTTPError -from six import text_type -from social_core.backends import oauth as social_oauth -from social_core.exceptions import AuthAlreadyAssociated, AuthException -from social_django import utils as social_utils - -import dogstats_wrapper as dog_stats_api -import openedx.core.djangoapps.external_auth.views -import third_party_auth -from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY -import track.views -from bulk_email.models import BulkEmailFlag, Optout # pylint: disable=import-error -from lms.djangoapps.certificates.api import get_certificate_url, has_html_certificates_enabled # pylint: disable=import-error -from lms.djangoapps.certificates.models import ( # pylint: disable=import-error - CertificateStatuses, - GeneratedCertificate, - certificate_status_for_student -) -from course_modes.models import CourseMode -from courseware.access import has_access -from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error -from django_comment_common.models import assign_role -from edxmako.shortcuts import render_to_response, render_to_string -from entitlements.models import CourseEntitlement -from eventtracking import tracker -from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error -from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error -# Note that this lives in LMS, so this dependency should be refactored. -from notification_prefs.views import enable_notifications -from openedx.core.djangoapps import monitoring_utils -from openedx.core.djangoapps.catalog.utils import ( - get_programs, get_programs_with_type, get_visible_sessions_for_entitlement, get_pseudo_session_for_entitlement -) -from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course -from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings -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 -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.programs.utils import ( - ProgramDataExtender, - ProgramProgressMeter -) -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming import helpers as theming_helpers -from openedx.core.djangoapps.user_api import accounts as accounts_settings -from openedx.core.djangoapps.user_api.preferences import api as preferences_api -from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, WaffleFlag -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, - CourseAccessRole, - CourseEnrollment, - CourseEnrollmentAllowed, - CourseEnrollmentAttribute, - DashboardConfiguration, - LinkedInAddToProfileConfiguration, - LoginFailures, - ManualEnrollmentAudit, - PasswordHistory, - PendingEmailChange, - Registration, - RegistrationCookieConfiguration, - UserAttribute, - UserProfile, - UserSignupSource, - UserStanding, - anonymous_id_for_user, - create_comments_service_user, - unique_id_for_user -) -from student.signals import REFUND_ORDER -from student.tasks import send_activation_email -from student.text_me_the_app import TextMeTheAppFragmentView -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 -from util.password_policy_validators import validate_password_length, validate_password_strength -from xmodule.modulestore.django import modulestore - -log = logging.getLogger("edx.student") -AUDIT_LOG = logging.getLogger("audit") -ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name -SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' -# Used as the name of the user attribute for tracking affiliate registrations -REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' -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' -# used to announce a registration -REGISTER_USER = Signal(providing_args=["user", "registration"]) - -# TODO: Remove Django 1.11 upgrade shim -# SHIM: Compensate for behavior change of default authentication backend in 1.10 -if django.VERSION < (1, 10): - NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.ModelBackend' -else: - # We want to allow inactive users to log in only when their account is first created - NEW_USER_AUTH_BACKEND = 'django.contrib.auth.backends.AllowAllUsersModelBackend' - -# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint -# pylint: disable=logging-format-interpolation - - -def authenticate_new_user(request, username, password): - """ - 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. - """ - backend = load_backend(NEW_USER_AUTH_BACKEND) - user = backend.authenticate(request=request, username=username, password=password) - user.backend = NEW_USER_AUTH_BACKEND - return user - - -def csrf_token(context): - """A csrf token that can be included in a form.""" - token = context.get('csrf_token', '') - if token == 'NOTPROVIDED': - return '' - return (u'
' % (token)) - - -# 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) -def index(request, extra_context=None, user=AnonymousUser()): - """ - Render the edX main page. - - extra_context is used to allow immediate display of certain modal windows, eg signup, - as used by external_auth. - """ - if extra_context is None: - extra_context = {} - - programs_list = [] - courses = get_courses(user) - - if configuration_helpers.get_value( - "ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"], - ): - courses = sort_by_start_date(courses) - else: - courses = sort_by_announcement(courses) - - context = {'courses': courses} - - context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html') - - # This appears to be an unused context parameter, at least for the master templates... - context['show_partners'] = configuration_helpers.get_value('show_partners', True) - - # TO DISPLAY A YOUTUBE WELCOME VIDEO - # 1) Change False to True - context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False) - - # Maximum number of courses to display on the homepage. - context['homepage_course_max'] = configuration_helpers.get_value( - 'HOMEPAGE_COURSE_MAX', settings.HOMEPAGE_COURSE_MAX - ) - - # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration - # Note: This value should be moved into a configuration setting and plumbed-through to the - # context via the site configuration workflow, versus living here - youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id") - context['homepage_promo_video_youtube_id'] = youtube_video_id - - # allow for theme override of the courses list - context['courses_list'] = theming_helpers.get_template_path('courses_list.html') - - # Insert additional context for use in the template - context.update(extra_context) - - # Add marketable programs to the context. - context['programs_list'] = get_programs_with_type(request.site, include_hidden=False) - - return render_to_response('index.html', context) - - -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. - """ - return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) - - -def cert_info(user, course_overview, course_mode): - """ - Get the certificate info needed to render the dashboard section for the given - student and course. - - Arguments: - user (User): A user. - course_overview (CourseOverview): A course. - course_mode (str): The enrollment mode (honor, verified, audit, etc.) - - Returns: - dict: A dictionary with keys: - 'status': one of 'generating', 'downloadable', 'notpassing', 'processing', 'restricted', 'unavailable', or - 'certificate_earned_but_not_available' - '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' - 'can_unenroll': if status allows for unenrollment - """ - return _cert_info( - user, - course_overview, - certificate_status_for_student(user, course_overview.id), - course_mode - ) - - -def reverification_info(statuses): - """ - Returns reverification-related information for *all* of user's enrollments whose - reverification status is in statuses. - - 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]: - reverifications[status].sort(key=lambda x: x.date) - return reverifications - - -def get_course_enrollments(user, org_whitelist, org_blacklist): - """ - Given a user, return a filtered set of his or her course enrollments. - - Arguments: - user (User): the user in question. - 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. - - Returns: - generator[CourseEnrollment]: a sequence of enrollments to be displayed - on the user's dashboard. - """ - for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user): - - # 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 - - # Filter out anything that is not in the whitelist. - if org_whitelist and course_overview.location.org not in org_whitelist: - continue - - # Conversely, filter out any enrollments in the blacklist. - elif org_blacklist and course_overview.location.org in org_blacklist: - continue - - # Else, include the enrollment. - else: - yield enrollment - - -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) - - -def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disable=unused-argument - """ - Implements the logic for cert_info -- split out for testing. - - Arguments: - user (User): A user. - course_overview (CourseOverview): A course. - course_mode (str): The enrollment mode (honor, verified, audit, etc.) - """ - # simplify the status for the template using this lookup table - template_state = { - CertificateStatuses.generating: 'generating', - CertificateStatuses.downloadable: 'downloadable', - CertificateStatuses.notpassing: 'notpassing', - CertificateStatuses.restricted: 'restricted', - CertificateStatuses.auditing: 'auditing', - CertificateStatuses.audit_passing: 'auditing', - CertificateStatuses.audit_notpassing: 'auditing', - CertificateStatuses.unverified: 'unverified', - } - - certificate_earned_but_not_available_status = 'certificate_earned_but_not_available' - default_status = 'processing' - - default_info = { - 'status': default_status, - 'show_survey_button': False, - 'can_unenroll': True, - } - - if cert_status is None: - return default_info - - status = template_state.get(cert_status['status'], default_status) - is_hidden_status = status in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') - - 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 - - if ( - course_overview.certificates_display_behavior == 'early_no_info' and - is_hidden_status - ): - return default_info - - status_dict = { - 'status': status, - 'mode': cert_status.get('mode', None), - 'linked_in_url': None, - 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, - } - - if not status == default_status and course_overview.end_of_course_survey_url is not None: - status_dict.update({ - 'show_survey_button': True, - 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) - else: - status_dict['show_survey_button'] = False - - if status == 'downloadable': - # showing the certificate web view button if certificate is downloadable state and feature flags are enabled. - if has_html_certificates_enabled(course_overview): - if course_overview.has_any_active_web_certificate: - status_dict.update({ - 'show_cert_web_view': True, - 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) - }) - else: - # don't show download certificate button if we don't have an active certificate for course - status_dict['status'] = 'unavailable' - elif 'download_url' not in cert_status: - log.warning( - u"User %s has a downloadable cert for %s, but no download url", - user.username, - course_overview.id - ) - return default_info - else: - status_dict['download_url'] = cert_status['download_url'] - - # 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() - - # posting certificates to LinkedIn is not currently - # supported in White Labels - if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): - status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( - course_overview.id, - course_overview.display_name, - cert_status.get('mode'), - cert_status['download_url'] - ) - - if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}: - cert_grade_percent = -1 - persisted_grade_percent = -1 - persisted_grade = CourseGradeFactory().read(user, course=course_overview, create_if_needed=False) - if persisted_grade is not None: - 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: - # 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. - return default_info - - status_dict['grade'] = unicode(max(cert_grade_percent, persisted_grade_percent)) - - return status_dict - - -@ensure_csrf_cookie -def signin_user(request): - """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 - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated(): - 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: - third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string - break - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - # 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', - 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'third_party_auth_error': third_party_auth_error - } - - return render_to_response('login.html', context) - - -@ensure_csrf_cookie -def register_user(request, extra_context=None): - """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated(): - return redirect(redirect_to) - - external_auth_response = external_auth_register(request) - if external_auth_response is not None: - return external_auth_response - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - 'email': '', - 'name': '', - 'running_pipeline': None, - 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'selected_provider': '', - 'username': '', - } - - if extra_context is not None: - context.update(extra_context) - - if context.get("extauth_domain", '').startswith( - openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX - ): - return render_to_response('register-shib.html', context) - - # If third-party auth is enabled, prepopulate the form with data from the - # selected provider. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_from_pipeline(running_pipeline) - 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) - - return render_to_response('register.html', context) - - -def complete_course_mode_info(course_id, enrollment, modes=None): - """ - 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 - """ - if modes is None: - modes = CourseMode.modes_for_course_dict(course_id) - - mode_info = {'show_upsell': False, 'days_for_upsell': None} - # 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: - mode_info['show_upsell'] = True - mode_info['verified_sku'] = modes['verified'].sku - mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku - # if there is an expiration date, find out how long from now it is - if modes['verified'].expiration_datetime: - today = datetime.datetime.now(UTC).date() - mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days - - return mode_info - - -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: - # 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 - if redeemed_registration.invoice_item: - if not redeemed_registration.invoice_item.invoice.is_valid: - blocked = True - # disabling email notifications for unpaid registration courses - Optout.objects.get_or_create(user=request.user, course_id=course_key) - log.info( - u"User %s (%s) opted out of receiving emails from course %s", - request.user.username, - request.user.email, - course_key, - ) - track.views.server_track( - request, - "change-email1-settings", - {"receive_emails": "no", "course": text_type(course_key)}, - page='dashboard', - ) - break - - return blocked - - -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), - } - - -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) - context = generate_activation_email_context(user, user_registration) - 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) - 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) - 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) - - -@login_required -@ensure_csrf_cookie -def dashboard(request): - """ - 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. - - """ - user = request.user - if not UserProfile.objects.filter(user=user).exists(): - return redirect(reverse('account_settings')) - - platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) - 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) - ) - activation_email_support_link = configuration_helpers.get_value( - 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK - - # 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)) - - # Get the entitlements for the user and a mapping to all available sessions for that entitlement - # If an entitlement has no available sessions, pass through a mock course overview object - course_entitlements = list(CourseEntitlement.get_active_entitlements_for_user(user)) - course_entitlement_available_sessions = {} - unfulfilled_entitlement_pseudo_sessions = {} - for course_entitlement in course_entitlements: - course_entitlement.update_expired_at() - available_sessions = get_visible_sessions_for_entitlement(course_entitlement) - course_entitlement_available_sessions[str(course_entitlement.uuid)] = available_sessions - if not course_entitlement.enrollment_course_run: - # Unfulfilled entitlements need a mock session for metadata - pseudo_session = get_pseudo_session_for_entitlement(course_entitlement) - unfulfilled_entitlement_pseudo_sessions[str(course_entitlement.uuid)] = pseudo_session - - # Record how many courses there are so that we can get a better - # understanding of usage patterns on prod. - monitoring_utils.accumulate('num_courses', len(course_enrollments)) - - # Sort the enrollment pairs by the enrollment date - course_enrollments.sort(key=lambda x: x.created, reverse=True) - - # Retrieve the course modes for each course - enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments] - __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) - course_modes_by_course = { - course_id: { - mode.slug: mode - for mode in modes - } - for course_id, modes in unexpired_course_modes.iteritems() - } - - # Check to see if the student has recently enrolled in a course. - # If so, display a notification message confirming the enrollment. - enrollment_message = _create_recent_enrollment_message( - course_enrollments, course_modes_by_course - ) - - course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) - - 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', - { - 'email': user.email, - 'platform_name': platform_name, - 'activation_email_support_link': activation_email_support_link - } - ) - elif not user.is_active: - banner_account_activation_message = render_to_string( - 'registration/activate_account_notice.html', - {'email': user.email} - ) - - enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) - - # Disable lookup of Enterprise consent_required_course due to ENT-727 - # Will re-enable after fixing WL-1315 - consent_required_courses = set() - enterprise_customer_name = None - - # Account activation message - account_activation_messages = [ - message for message in messages.get_messages(request) if 'account-activation' in message.tags - ] - - # Global staff can see what courses encountered an error on their dashboard - staff_access = False - errored_courses = {} - if has_access(user, 'staff', 'global'): - # Show any courses that encountered an error on load - staff_access = True - errored_courses = modulestore().get_errored_courses() - - show_courseware_links_for = frozenset( - enrollment.course_id for enrollment in course_enrollments - if has_access(request.user, 'load', enrollment.course_overview) - ) - - # Find programs associated with course runs being displayed. This information - # is passed in the template context to allow rendering of program-related - # information on the dashboard. - meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments) - ecommerce_service = EcommerceService() - inverted_programs = meter.invert_programs() - - urls, program_data = {}, {} - bundles_on_dashboard_flag = WaffleFlag(WaffleFlagNamespace(name=u'student.experiments'), u'bundles_on_dashboard') - - if bundles_on_dashboard_flag.is_enabled(): - programs_data = meter.programs - if programs_data and inverted_programs and inverted_programs.values(): - program_uuid = inverted_programs.values()[0][0]['uuid'] - meter.programs = [get_programs(request.site, uuid=program_uuid)] - program_data = meter.programs[0] - program_data = ProgramDataExtender(program_data, request.user).extend() - - skus = program_data.get('skus') - - urls = { - 'commerce_api_url': reverse('commerce_api:v0:baskets:create'), - 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus) - } - urls['completeProgramURL'] = urls['buy_button_url'] + '&bundle=' + program_data.get('uuid') - - # 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 = { - enrollment.course_id: complete_course_mode_info( - enrollment.course_id, enrollment, - modes=course_modes_by_course[enrollment.course_id] - ) - for enrollment in course_enrollments - } - - # 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. - verify_status_by_course = check_verify_status_by_course(user, course_enrollments) - cert_statuses = { - enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode) - for enrollment in course_enrollments - } - - # only show email settings for Mongo course and when bulk email is turned on - show_email_settings_for = frozenset( - enrollment.course_id for enrollment in course_enrollments if ( - BulkEmailFlag.feature_enabled(enrollment.course_id) - ) - ) - - # Verification Attempts - # Used to generate the "you must reverify for course x" banner - verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user) - verification_errors = get_verification_error_reasons_for_display(verification_error_codes) - - # Gets data for midcourse reverifications, if any are necessary or have failed - statuses = ["approved", "denied", "pending", "must_reverify"] - reverifications = reverification_info(statuses) - - 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 - ) - ) - - enrolled_courses_either_paid = frozenset( - enrollment.course_id for enrollment in course_enrollments - if enrollment.is_paid_course() - ) - - # 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"]) - - # Populate the Order History for the side-bar. - order_history_list = order_history(user, course_org_filter=site_org_whitelist, org_filter_out_set=site_org_blacklist) - - # get list of courses having pre-requisites yet to be completed - courses_having_prerequisites = frozenset( - enrollment.course_id for enrollment in course_enrollments - if enrollment.course_overview.pre_requisite_courses - ) - courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) - - if 'notlive' in request.GET: - redirect_message = _("The course you are looking for does not start until {date}.").format( - date=request.GET['notlive'] - ) - 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'] - ) - else: - redirect_message = '' - - valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] - display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses - - # Filter out any course enrollment course cards that are associated with fulfilled entitlements - for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]: - course_enrollments = [enr for enr in course_enrollments if entitlement.enrollment_course_run.course_id != enr.course_id] # pylint: disable=line-too-long - - context = { - 'urls': urls, - 'program_data': program_data, - 'enterprise_message': enterprise_message, - 'consent_required_courses': consent_required_courses, - 'enterprise_customer_name': enterprise_customer_name, - 'enrollment_message': enrollment_message, - 'redirect_message': redirect_message, - 'account_activation_messages': account_activation_messages, - 'course_enrollments': course_enrollments, - 'course_entitlements': course_entitlements, - 'course_entitlement_available_sessions': course_entitlement_available_sessions, - 'unfulfilled_entitlement_pseudo_sessions': unfulfilled_entitlement_pseudo_sessions, - 'course_optouts': course_optouts, - 'banner_account_activation_message': banner_account_activation_message, - 'sidebar_account_activation_message': sidebar_account_activation_message, - 'staff_access': staff_access, - 'errored_courses': errored_courses, - 'show_courseware_links_for': show_courseware_links_for, - 'all_course_modes': course_mode_info, - 'cert_statuses': cert_statuses, - 'credit_statuses': _credit_statuses(user, course_enrollments), - 'show_email_settings_for': show_email_settings_for, - 'reverifications': reverifications, - 'verification_status': verification_status, - 'verification_status_by_course': verify_status_by_course, - 'verification_errors': verification_errors, - 'block_courses': block_courses, - 'denied_banner': denied_banner, - 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, - 'user': user, - 'logout_url': reverse('logout'), - 'platform_name': platform_name, - 'enrolled_courses_either_paid': enrolled_courses_either_paid, - 'provider_states': [], - 'order_history_list': order_history_list, - 'courses_requirements_not_met': courses_requirements_not_met, - 'nav_hidden': True, - 'inverted_programs': inverted_programs, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'disable_courseware_js': True, - 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard, - 'display_sidebar_on_dashboard': display_sidebar_on_dashboard, - } - - if ecommerce_service.is_enabled(request.user): - context.update({ - 'use_ecommerce_payment_flow': True, - 'ecommerce_payment_page': ecommerce_service.payment_page_url(), - }) - - response = render_to_response('dashboard.html', context) - set_user_info_cookie(response, request) - return response - - -@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) - - -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 - - -def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name - """ - Builds a recent course enrollment message. - - Constructs a new message template based on any recent course enrollments - for the student. - - Args: - course_enrollments (list[CourseEnrollment]): a list of course enrollments. - course_modes (dict): Mapping of course ID's to course mode dictionaries. - - Returns: - A string representing the HTML message output from the message template. - None if there are no recently enrolled courses. - - """ - recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments) - - if recently_enrolled_courses: - 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) - for enrollment in recently_enrolled_courses - ) - - platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) - - return render_to_string( - 'enrollment/course_enrollment_message.html', - { - '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 - } - ) - - -def _get_recently_enrolled_courses(course_enrollments): - """ - Given a list of enrollments, filter out all but recent enrollments. - - Args: - course_enrollments (list[CourseEnrollment]): A list of course enrollments. - - Returns: - list[CourseEnrollment]: A list of recent course enrollments. - """ - seconds = DashboardConfiguration.current().recent_enrollment_time_delta - time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds)) - return [ - enrollment for enrollment in course_enrollments - # 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 - ] - - -def _allow_donation(course_modes, course_id, enrollment): - """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. - enrollment(CourseEnrollment): The enrollment object in which the user is enrolled - - Returns: - True if the course is allowing donations. - - """ - 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 - ) - donations_enabled = configuration_helpers.get_value( - 'ENABLE_DONATIONS', - DonationConfiguration.current().enabled - ) - return ( - donations_enabled and - enrollment.mode in course_modes[course_id] and - course_modes[course_id][enrollment.mode].min_price == 0 - ) - - -def _update_email_opt_in(request, org): - """Helper function used to hit the profile API if email opt-in is enabled.""" - - email_opt_in = request.POST.get('email_opt_in') - if email_opt_in is not None: - email_opt_in_boolean = email_opt_in == 'true' - preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) - - -def _credit_statuses(user, course_enrollments): - """ - 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. - course_enrollments (list[CourseEnrollment]): List of enrollments for the - user. - - 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: - >>> _credit_statuses(user, course_enrollments) - { - 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 - - # Feature flag off - if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"): - return {} - - request_status_by_course = { - request["course_key"]: request["status"] - for request in credit_api.get_credit_requests_for_user(user.username) - } - - credit_enrollments = { - enrollment.course_id: enrollment - for enrollment in course_enrollments - 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): - course_key = CourseKey.from_string(unicode(eligibility["course_key"])) - providers_names = get_credit_provider_display_names(course_key) - status = { - "course_key": unicode(course_key), - "eligible": True, - "deadline": eligibility["deadline"], - "purchased": course_key in credit_enrollments, - "provider_name": make_providers_strings(providers_names), - "provider_status_url": None, - "provider_id": None, - "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") - status["provider_id"] = provider_id - - statuses[course_key] = status - - return statuses - - -@transaction.non_atomic_requests -@require_POST -@outer_atomic(read_committed=True) -def change_enrollment(request, check_access=True): - """ - Modify the enrollment status for the logged-in user. - - TODO: This is lms specific and does not belong in common code. - - The request parameter must be a POST request (other methods return 405) - that specifies course_id and enrollment_action parameters. If course_id or - enrollment_action is not specified, if course_id is not valid, if - enrollment_action is something other than "enroll" or "unenroll", if - enrollment_action is "enroll" and enrollment is closed for the course, or - if enrollment_action is "unenroll" and the user is not enrolled in the - course, a 400 error will be returned. If the user is not logged in, 403 - will be returned; it is important that only this case return 403 so the - front end can redirect the user to a registration or login page when this - happens. This function should only be called from an AJAX request, so - the error messages in the responses should never actually be user-visible. - - Args: - request (`Request`): The Django request object - - Keyword Args: - 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. - - Returns: - Response - - """ - # Get the user - user = request.user - - # Ensure the user is authenticated - if not user.is_authenticated(): - return HttpResponseForbidden() - - # Ensure we received a course_id - action = request.POST.get("enrollment_action") - if 'course_id' not in request.POST: - return HttpResponseBadRequest(_("Course id not specified")) - - try: - course_id = CourseKey.from_string(request.POST.get("course_id")) - except InvalidKeyError: - log.warning( - u"User %s tried to %s with invalid course id: %s", - user.username, - action, - request.POST.get("course_id"), - ) - return HttpResponseBadRequest(_("Invalid course id")) - - # Allow us to monitor performance of this transaction on a per-course basis since we often roll-out features - # on a per-course basis. - monitoring_utils.set_custom_metric('course_id', unicode(course_id)) - - if action == "enroll": - # 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): - log.warning( - u"User %s tried to enroll in non-existent course %s", - user.username, - course_id - ) - return HttpResponseBadRequest(_("Course id is invalid")) - - # Record the user's email opt-in preference - if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'): - _update_email_opt_in(request, course_id.org) - - available_modes = CourseMode.modes_for_course_dict(course_id) - - # 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) - - # Check that auto enrollment is allowed for this course - # (= the course is NOT behind a paywall) - if CourseMode.can_auto_enroll(course_id): - # Enroll the user using the default mode (audit) - # We're assuming that users of the course enrollment table - # will NOT try to look up the course enrollment model - # by its slug. If they do, it's possible (based on the state of the database) - # for no such model to exist, even though we've set the enrollment type - # to "audit". - try: - enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) - if enroll_mode: - CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) - except Exception: # pylint: disable=broad-except - return HttpResponseBadRequest(_("Could not enroll")) - - # If we have more than one course mode or professional ed is enabled, - # then send the user to the choose your track page. - # (In the case of no-id-professional/professional ed, this will redirect to a page that - # funnels users directly into the verification / payment flow) - if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes): - return HttpResponse( - reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)}) - ) - - # Otherwise, there is only one mode available (the default) - return HttpResponse() - elif action == "unenroll": - enrollment = CourseEnrollment.get_enrollment(user, course_id) - if not enrollment: - return HttpResponseBadRequest(_("You are not enrolled in this course")) - - certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode) - if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: - return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) - - CourseEnrollment.unenroll(user, course_id) - REFUND_ORDER.send(sender=None, course_enrollment=enrollment) - return HttpResponse() - else: - return HttpResponseBadRequest(_("Enrollment action is invalid")) - - -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.

' - 'We just sent an activation link to {email}. If ' - 'you do not receive an email, check your spam folders or ' - 'contact {platform} Support.') - - not_activated_message = not_activated_msg_template.format( - email=user.email, - support_url=support_url, - platform=platform_name - ) - - return not_activated_message - - -# Need different levels of logging -@ensure_csrf_cookie -def login_user(request, error=""): # pylint: disable=too-many-statements,unused-argument - """AJAX request to log in the user.""" - - backend_name = None - email = None - password = None - redirect_url = None - response = None - running_pipeline = None - third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) - third_party_auth_successful = False - trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) - user = None - platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) - - 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'] - third_party_uid = running_pipeline['kwargs']['uid'] - requested_provider = provider.Registry.get_from_pipeline(running_pipeline) - - try: - user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid) - third_party_auth_successful = True - except User.DoesNotExist: - AUDIT_LOG.info( - 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 += "

" - 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 += "

" - message += _( - "If you don't have an {platform_name} account yet, " - "click Register at the top of the page." - ).format( - platform_name=platform_name - ) - - return HttpResponse(message, content_type="text/plain", status=403) - - else: - - if 'email' not in request.POST or 'password' not in request.POST: - return JsonResponse({ - "success": False, - # TODO: User error message - "value": _('There was an error receiving your login information. Please email us.'), - }) # TODO: this should be status code 400 - - 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)) - - # 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. - if settings.FEATURES.get('AUTH_USE_SHIB') and user: - try: - eamap = ExternalAuthMap.objects.get(user=user) - if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): - return JsonResponse({ - "success": False, - "redirect": reverse('shib-login'), - }) # TODO: this should be status code 301 # pylint: disable=fixme - except ExternalAuthMap.DoesNotExist: - # This is actually the common case, logging in user without external linked login - AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) - - # see if account has been locked out due to excessive login failures - 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): - lockout_message = _('This account has been temporarily locked due ' - 'to excessive login failures. Try again later.') - return JsonResponse({ - "success": False, - "value": lockout_message, - }) # TODO: this should be status code 429 # pylint: disable=fixme - - # see if the user must reset his/her password due to any policy settings - if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): - 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 ' - '"Forgot Password" link on this page to reset your password before logging in again.'), - }) # TODO: this should be status code 403 # pylint: disable=fixme - - # 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 "" - - 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 - - if user is None: - # 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) - - # 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 != "": - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - loggable_id = user_found_by_email_lookup.id if user_found_by_email_lookup else "" - 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)) - return JsonResponse({ - "success": False, - "value": _('Email or password is incorrect.'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - # successful login, clear failed login attempts counters, if applicable - if LoginFailures.is_feature_enabled(): - LoginFailures.clear_lockout_counter(user) - - # Track the user's sign in - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - 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 - } - ) - - analytics.track( - user.id, - "edx.bi.user.account.authenticated", - { - 'category': "conversion", - 'label': request.POST.get('course_id'), - 'provider': None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - if user is not None and user.is_active: - try: - # We do not log here, because we have a handler registered - # to perform logging on successful logins. - login(request, user) - if request.POST.get('remember') == 'true': - request.session.set_expiry(604800) - log.debug("Setting user session to never expire") - else: - request.session.set_expiry(0) - except Exception as exc: # pylint: disable=broad-except - AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") - log.critical("Login failed - Could not create session. Is memcached running?") - log.exception(exc) - raise - - redirect_url = None # The AJAX method calling should know the default destination upon success - if third_party_auth_successful: - redirect_url = pipeline.get_complete_url(backend_name) - - response = JsonResponse({ - "success": True, - "redirect_url": redirect_url, - }) - - # Ensure that the external marketing site can - # detect that the user is logged in. - return set_logged_in_cookies(request, response, user) - - 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)) - - reactivation_email_for_user(user) - - return JsonResponse({ - "success": False, - "value": _generate_not_activated_message(user), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - -@csrf_exempt -@require_POST -@social_utils.psa("social:complete") -def login_oauth_token(request, backend): - """ - Authenticate the client using an OAuth access token by using the token to - retrieve information from a third party and matching that information to an - existing user. - """ - warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) - - backend = request.backend - if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): - if "access_token" in request.POST: - # Tell third party auth pipeline that this is an API call - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API - user = None - access_token = request.POST["access_token"] - try: - user = backend.do_auth(access_token) - except (HTTPError, AuthException): - pass - # do_auth can return a non-User object if it fails - if user and isinstance(user, User): - login(request, user) - return JsonResponse(status=204) - else: - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline(access_token) - return JsonResponse({"error": "invalid_token"}, status=401) - else: - return JsonResponse({"error": "invalid_request"}, status=400) - raise Http404 - - -@require_GET -@login_required -@ensure_csrf_cookie -def manage_user_standing(request): - """ - Renders the view used to manage user standing. Also displays a table - of user accounts that have been disabled and who disabled them. - """ - if not request.user.is_staff: - raise Http404 - all_disabled_accounts = UserStanding.objects.filter( - account_status=UserStanding.ACCOUNT_DISABLED - ) - - all_disabled_users = [standing.user for standing in all_disabled_accounts] - - headers = ['username', 'account_changed_by'] - rows = [] - for user in all_disabled_users: - row = [user.username, user.standing.changed_by] - rows.append(row) - - context = {'headers': headers, 'rows': rows} - - return render_to_response("manage_user_standing.html", context) - - -@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() == '': - context['message'] = _('Please enter a username') - return JsonResponse(context, status=400) - - account_action = request.POST.get('account_action') - if account_action is None: - context['message'] = _('Please choose an option') - return JsonResponse(context, status=400) - - username = username.strip() - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - context['message'] = _("User with username {} does not exist").format(username) - return JsonResponse(context, status=400) - else: - user_account, _success = UserStanding.objects.get_or_create( - user=user, defaults={'changed_by': request.user}, - ) - if account_action == 'disable': - user_account.account_status = UserStanding.ACCOUNT_DISABLED - context['message'] = _("Successfully disabled {}'s account").format(username) - log.info(u"%s disabled %s's account", request.user, username) - elif account_action == 'reenable': - user_account.account_status = UserStanding.ACCOUNT_ENABLED - context['message'] = _("Successfully reenabled {}'s account").format(username) - log.info(u"%s reenabled %s's account", request.user, username) - else: - context['message'] = _("Unexpected account status") - return JsonResponse(context, status=400) - user_account.changed_by = request.user - user_account.standing_last_changed_at = datetime.datetime.now(UTC) - user_account.save() - - return JsonResponse(context) - - -@login_required -@ensure_csrf_cookie -def change_setting(request): - """JSON call to change a profile setting: Right now, location""" - # TODO (vshnayder): location is no longer used - u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache - if 'location' in request.POST: - u_prof.location = request.POST['location'] - u_prof.save() - - return JsonResponse({ - "success": True, - "location": u_prof.location, - }) - - -class AccountValidationError(Exception): - def __init__(self, message, field): - super(AccountValidationError, self).__init__(message) - self.field = field - - -@receiver(post_save, sender=User) -def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument - """ - handler that saves the user Signup Source - when the user is created - """ - if 'created' in kwargs and kwargs['created']: - site = configuration_helpers.get_value('SITE_NAME') - if site: - user_signup_source = UserSignupSource(user=kwargs['instance'], site=site) - user_signup_source.save() - log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) - - -def _do_create_account(form, custom_form=None): - """ - 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. - """ - # 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() - - errors = {} - errors.update(form.errors) - if custom_form: - errors.update(custom_form.errors) - - if errors: - raise ValidationError(errors) - - user = User( - username=form.cleaned_data["username"], - email=form.cleaned_data["email"], - is_active=False - ) - user.set_password(form.cleaned_data["password"]) - registration = Registration() - - # 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: - with transaction.atomic(): - user.save() - if custom_form: - custom_model = custom_form.save(commit=False) - custom_model.user = user - custom_model.save() - except IntegrityError: - # Figure out the cause of the integrity error - # 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.") - if len(User.objects.filter(username=user.username)) > 0: - raise AccountValidationError( - _("An account with the Public Username '{username}' already exists.").format(username=user.username), - field="username" - ) - elif len(User.objects.filter(email=user.email)) > 0: - raise AccountValidationError( - _("An account with the Email '{email}' already exists.").format(email=user.email), - field="email" - ) - else: - raise - - # 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) - - registration.register(user) - - 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 - if extended_profile: - profile.meta = json.dumps(extended_profile) - try: - profile.save() - except Exception: # pylint: disable=broad-except - log.exception("UserProfile creation failed for user {id}.".format(id=user.id)) - raise - - return (user, profile, registration) - - -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) - - -def create_account_with_params(request, params): - """ - 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. - - Does not return anything. - - 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. - - 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"). - * 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. - """ - # 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()) - - # allow to define custom set of required/optional/hidden fields via configuration - extra_fields = configuration_helpers.get_value( - 'REGISTRATION_EXTRA_FIELDS', - getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - ) - # 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 - - is_third_party_auth_enabled = third_party_auth.is_enabled() - - if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): - params["password"] = pipeline.make_random_password() - - # 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')) - ]} - ) - - # 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 - # unless originally we didn't get a valid email or name from the external auth - # TODO: We do not check whether these values meet all necessary criteria, such as email length - do_external_auth = 'ExternalAuthMap' in request.session - if do_external_auth: - eamap = request.session['ExternalAuthMap'] - try: - validate_email(eamap.external_email) - params["email"] = eamap.external_email - except ValidationError: - pass - if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH: - 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"]) - - extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) - enforce_password_policy = ( - settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and - not do_external_auth - ) - # Can't have terms of service for certain SHIB users, like at Stanford - registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - tos_required = ( - registration_fields.get('terms_of_service') != 'hidden' or - registration_fields.get('honor_code') != 'hidden' - ) and ( - not settings.FEATURES.get("AUTH_USE_SHIB") or - not settings.FEATURES.get("SHIB_DISABLE_TOS") or - not do_external_auth or - not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) - ) - - form = AccountCreationForm( - data=params, - extra_fields=extra_fields, - extended_profile_fields=extended_profile_fields, - enforce_username_neq_password=True, - enforce_password_policy=enforce_password_policy, - tos_required=tos_required, - ) - custom_form = get_registration_extension_form(data=params) - - # Perform operations within a transaction that are critical to account creation - with transaction.atomic(): - # first, create the account - (user, profile, registration) = _do_create_account(form, custom_form) - - # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth - # (If the user is using the normal register page, the social auth pipeline does the linking, not this code) - - # 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: - 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) - social_access_token = params.get('access_token') - if not social_access_token: - raise ValidationError({ - 'access_token': [ - _("An access_token is required when passing value ({}) for provider.").format( - params['provider'] - ) - ] - }) - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API - pipeline_user = None - error_message = "" - try: - pipeline_user = request.backend.do_auth(social_access_token, user=user) - except AuthAlreadyAssociated: - error_message = _("The provided access_token is already associated with another user.") - except (HTTPError, AuthException): - error_message = _("The provided access_token is not valid.") - if not pipeline_user or not isinstance(pipeline_user, User): - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline(social_access_token) - raise ValidationError({'access_token': [error_message]}) - - # Perform operations that are non-critical parts of account creation - _create_or_set_user_attribute_created_on_site(user, request.site) - - preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) - - if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): - try: - enable_notifications(user) - except Exception: # pylint: disable=broad-except - log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) - - dog_stats_api.increment("common.student.account_created") - - # If the user is registering via 3rd party auth, track which provider they use - third_party_provider = None - running_pipeline = None - if is_third_party_auth_enabled and pipeline.running(request): - running_pipeline = pipeline.get(request) - third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) - - # Track the user's registration - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - identity_args = [ - user.id, # pylint: disable=no-member - { - 'email': user.email, - 'username': user.username, - 'name': profile.name, - # 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, - 'education': profile.level_of_education_display, - 'address': profile.mailing_address, - 'gender': profile.gender_display, - 'country': unicode(profile.country), - } - ] - - if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): - identity_args.append({ - "MailChimp": { - "listId": settings.MAILCHIMP_NEW_USER_LIST_ID - } - }) - - analytics.identify(*identity_args) - - analytics.track( - user.id, - "edx.bi.user.account.registered", - { - 'category': 'conversion', - 'label': params.get('course_id'), - 'provider': third_party_provider.name if third_party_provider else None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - - # Announce registration - REGISTER_USER.send(sender=None, user=user, registration=registration) - - create_comments_service_user(user) - - # Check if we system is configured to skip activation email for the current user. - skip_email = skip_activation_email( - user, do_external_auth, running_pipeline, third_party_provider, - ) - - if skip_email: - registration.activate() - _enroll_user_in_pending_courses(user) # Enroll student in any pending courses - else: - compose_and_send_activation_email(user, profile, registration) - - new_user = authenticate_new_user(request, user.username, params['password']) - login(request, new_user) - request.session.set_expiry(0) - - 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.') - - # TODO: there is no error checking here to see that the user actually logged in successfully, - # and is not yet an active user. - if new_user is not None: - AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) - - if do_external_auth: - eamap.user = new_user - eamap.dtsignup = datetime.datetime.now(UTC) - eamap.save() - 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) - - if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): - log.info('bypassing activation email') - 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)) - - return new_user - - -def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider): - """ - Return `True` if activation email should be skipped. - - Skip email if we are: - 1. Doing load testing. - 2. Random user generation for other forms of testing. - 3. External auth bypassing activation. - 4. Have the platform configured to not require e-mail activation. - 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) - - Note that this feature is only tested as a flag set one way or - the other for *new* systems. we need to be careful about - changing settings on a running system to make sure no users are - left in an inconsistent state (or doing a migration if they are). - - Arguments: - user (User): Django User object for the current user. - do_external_auth (bool): True if external authentication is in progress. - running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication. - third_party_provider (ProviderConfig): An instance of third party provider configuration. - - Returns: - (bool): `True` if account activation email should be skipped, `False` if account activation email should be - sent. - """ - sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email') - - # Email is valid if the SAML assertion email matches the user account email or - # no email was provided in the SAML assertion. Some IdP's use a callback - # to retrieve additional user account information (including email) after the - # initial account creation. - valid_email = ( - sso_pipeline_email == user.email or ( - sso_pipeline_email is None and - third_party_provider and - getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY - ) - ) - - # log the cases where skip activation email flag is set, but email validity check fails - if third_party_provider and third_party_provider.skip_email_verification and not valid_email: - log.info( - '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] ' - 'Account activation email sent as user\'s system email differs from SSO email.', - user.email, - sso_pipeline_email, - getattr(third_party_provider, "provider_id", None), - getattr(third_party_provider, "identity_provider_type", None) - ) - - return ( - settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or - settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or - (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or - (third_party_provider and third_party_provider.skip_email_verification and valid_email) - ) - - -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 - ) - - -def record_affiliate_registration_attribution(request, user): - """ - Attribute this user's registration to the referring affiliate, if - applicable. - """ - affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) - if user and affiliate_id: - UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) - - -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) - 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 - ) - 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) - UserAttribute.set_user_attribute( - user, - REGISTRATION_UTM_CREATED_AT, - created_at_datetime - ) - - -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) - - -@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 header.html - """ - # 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.")) - - warnings.warn("Please use RegistrationView instead.", DeprecationWarning) - - try: - user = create_account_with_params(request, post_override or request.POST) - except AccountValidationError as exc: - return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400) - except ValidationError as exc: - field, error_list = next(exc.message_dict.iteritems()) - return JsonResponse( - { - "success": False, - "field": field, - "value": error_list[0], - }, - status=400 - ) - - redirect_url = None # The AJAX method calling should know the default destination upon success - - # Resume the third-party-auth pipeline if necessary. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - redirect_url = pipeline.get_complete_url(running_pipeline['backend']) - - response = JsonResponse({ - 'success': True, - 'redirect_url': redirect_url, - }) - set_logged_in_cookies(request, response, user) - return response - - -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 - - -def auto_auth(request): - """ - Create or configure a user account, then log in as that user. - - Enabled only when - settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. - - 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` - * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` - * `no_login`: Define this to create the user but not login - * `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 - * `is_active` : make/update account with status provided as 'is_active' - If username, email, or password are not provided, use - randomly generated credentials. - """ - - # Generate a unique name to use if none provided - generated_username = uuid.uuid4().hex[0:30] - - # Use the params from the request, otherwise use these defaults - username = request.GET.get('username', generated_username) - password = request.GET.get('password', username) - email = request.GET.get('email', username + "@example.com") - full_name = request.GET.get('full_name', username) - 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)) - - # Valid modes: audit, credit, honor, no-id-professional, professional, verified - enrollment_mode = request.GET.get('enrollment_mode', 'honor') - - # 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(',')) - - redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to - login_when_done = 'no_login' not in request.GET - - form = AccountCreationForm( - data={ - 'username': username, - 'email': email, - 'password': password, - 'name': full_name, - }, - tos_required=False - ) - - # Attempt to create the account. - # If successful, this will return a tuple containing - # the new user object. - try: - user, profile, reg = _do_create_account(form) - except (AccountValidationError, ValidationError): - # Attempt to retrieve the existing user. - user = User.objects.get(username=username) - user.email = email - user.set_password(password) - user.is_active = is_active - user.save() - profile = UserProfile.objects.get(user=user) - reg = Registration.objects.get(user=user) - except PermissionDenied: - return HttpResponseForbidden(_('Account creation not allowed.')) - - user.is_staff = is_staff - user.is_superuser = is_superuser - user.save() - - if is_active: - reg.activate() - reg.save() - - # 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() - - _create_or_set_user_attribute_created_on_site(user, request.site) - - # Enroll the user in a course - course_key = None - if course_id: - course_key = CourseLocator.from_string(course_id) - CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) - - # 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) - - # Log in as the user - if login_when_done: - user = authenticate_new_user(request, username, password) - login(request, user) - - create_comments_service_user(user) - - if redirect_when_done: - if redirect_to: - # Redirect to page specified by the client - redirect_url = redirect_to - elif course_id: - # Redirect to the course homepage (in LMS) or outline page (in Studio) - try: - redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id}) - except NoReverseMatch: - redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) - else: - # Redirect to the learner dashboard (in LMS) or homepage (in Studio) - try: - redirect_url = reverse('dashboard') - except NoReverseMatch: - redirect_url = reverse('home') - - return redirect(redirect_url) - else: - response = JsonResponse({ - 'created_status': 'Logged in' if login_when_done else 'Created', - 'username': username, - 'email': email, - 'password': password, - 'user_id': user.id, # pylint: disable=no-member - 'anonymous_id': anonymous_id_for_user(user, None), - }) - response.set_cookie('csrftoken', csrf(request)['csrf_token']) - return response - - -@ensure_csrf_cookie -def activate_account(request, key): - """When link in activation e-mail is clicked""" - - # 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, - HTML(_( - '{html_start}Your account could not be activated{html_end}' - 'Something went wrong, please contact support to resolve this issue.' - )).format( - support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), - html_start=HTML('

'), - html_end=HTML('

'), - ), - extra_tags='account-activation aa-icon' - ) - else: - if not registration.user.is_active: - registration.activate() - # 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. - messages.success( - request, - HTML(message).format( - html_start=HTML('

'), - html_end=HTML('

'), - ), - extra_tags='account-activation aa-icon', - ) - else: - messages.info( - request, - HTML(_('{html_start}This account has already been activated.{html_end}')).format( - html_start=HTML('

'), - html_end=HTML('

'), - ), - extra_tags='account-activation aa-icon', - ) - - # 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: - user_logged_in = request.user.is_authenticated() - already_active = True - if not registration.user.is_active: - registration.activate() - already_active = False - - # Enroll student in any pending courses he/she may have if auto_enroll flag is set - _enroll_user_in_pending_courses(registration.user) - - return render_to_response( - "registration/activation_complete.html", - { - 'user_logged_in': user_logged_in, - 'already_active': already_active - } - ) - - -@csrf_exempt -@require_POST -def password_reset(request): - """ Attempts to send a password reset e-mail. """ - # Add some rate limiting here by re-using the RateLimitMixin as a helper class - limiter = BadRequestRateLimiter() - if limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Rate limit exceeded in password_reset") - return HttpResponseForbidden() - - form = PasswordResetFormNoActive(request.POST) - if form.is_valid(): - form.save(use_https=request.is_secure(), - from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), - request=request) - # 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, - } - ) - destroy_oauth_tokens(request.user) - else: - # bad user? tick the rate limiter counter - AUDIT_LOG.info("Bad password_reset user passed in.") - limiter.tick_bad_request_counter(request) - - return JsonResponse({ - 'success': True, - 'value': render_to_string('registration/password_reset_done.html', {}), - }) - - -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 - """ - 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 - - -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): - """ - 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`. - """ - - err_msg = None - # 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) - - return err_msg - - -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 = { - "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) - } - try: - uid_int = base36_to_int(uidb36) - user = User.objects.get(id=uid_int) - except (ValueError, User.DoesNotExist): - # 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 - ) - - if request.method == 'POST': - password = request.POST['new_password1'] - 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: - # We have a password reset attempt which violates some security - # policy, or any other validation. Use the existing Django template to communicate that - # back to the user. - context = { - 'validlink': valid_link, - 'form': None, - 'title': _('Password reset unsuccessful'), - 'err_msg': error_message, - } - context.update(platform_name) - return TemplateResponse( - request, 'registration/password_reset_confirm.html', context - ) - - # remember what the old password hash is before we call down - old_password_hash = user.password - - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - # 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). - 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 - - # get the updated user - updated_user = User.objects.get(id=uid_int) - - # did the password hash change, if so record it in the PasswordHistory - if updated_user.password != old_password_hash: - entry = PasswordHistory() - entry.create(updated_user) - - else: - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - response_was_successful = response.context_data.get('validlink') - if response_was_successful and not user.is_active: - user.is_active = True - user.save() - - return response - - -def reactivation_email_for_user(user): - try: - registration = Registration.objects.get(user=user) - except Registration.DoesNotExist: - return JsonResponse({ - "success": False, - "error": _('No inactive user with this e-mail exists'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - try: - context = generate_activation_email_context(user, registration) - 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') - }) - - subject = render_to_string('emails/activation_email_subject.txt', context) - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/activation_email.txt', context) - 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) - - try: - user.email_user(subject, message, from_address) - except Exception: # pylint: disable=broad-except - log.error( - u'Unable to send reactivation email from "%s" to "%s"', - from_address, - user.email, - exc_info=True - ) - return JsonResponse({ - "success": False, - "error": _('Unable to send reactivation email') - }) # TODO: this should be status code 500 # pylint: disable=fixme - - return JsonResponse({"success": True}) - - -def validate_new_email(user, new_email): - """ - 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. - """ - 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.')) - - if User.objects.filter(email=new_email).count() != 0: - raise ValueError(_('An account with this e-mail already exists.')) - - -def do_email_change_request(user, new_email, activation_key=None): - """ - 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. - """ - pec_list = PendingEmailChange.objects.filter(user=user) - if len(pec_list) == 0: - pec = PendingEmailChange() - pec.user = user - else: - pec = pec_list[0] - - # if activation_key is not passing as an argument, generate a random key - if not activation_key: - activation_key = uuid.uuid4().hex - - pec.new_email = new_email - pec.activation_key = activation_key - pec.save() - - context = { - 'key': pec.activation_key, - 'old_email': user.email, - 'new_email': pec.new_email - } - - subject = render_to_string('emails/email_change_subject.txt', context) - subject = ''.join(subject.splitlines()) - - message = render_to_string('emails/email_change.txt', context) - - from_address = configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ) - try: - mail.send_mail(subject, message, from_address, [pec.new_email]) - except Exception: # pylint: disable=broad-except - log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) - raise ValueError(_('Unable to send email activation link. Please try again later.')) - - # 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, - } - ) - - -@ensure_csrf_cookie -def confirm_email_change(request, key): # pylint: disable=unused-argument - """ - User requested a new e-mail. This is called when the activation - link is clicked. We confirm with the old e-mail, and update - """ - with transaction.atomic(): - try: - pec = PendingEmailChange.objects.get(activation_key=key) - except PendingEmailChange.DoesNotExist: - response = render_to_response("invalid_email_key.html", {}) - transaction.set_rollback(True) - return response - - user = pec.user - address_context = { - 'old_email': user.email, - 'new_email': pec.new_email - } - - if len(User.objects.filter(email=pec.new_email)) != 0: - response = render_to_response("email_exists.html", {}) - transaction.set_rollback(True) - return response - - 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) - u_prof = UserProfile.objects.get(user=user) - meta = u_prof.get_meta() - if 'old_emails' not in meta: - meta['old_emails'] = [] - meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()]) - u_prof.set_meta(meta) - u_prof.save() - # Send it to the old email... - try: - user.email_user( - subject, - message, - configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - ) - except Exception: # pylint: disable=broad-except - log.warning('Unable to send confirmation email to old address', exc_info=True) - response = render_to_response("email_change_failed.html", {'email': user.email}) - transaction.set_rollback(True) - return response - - user.email = pec.new_email - user.save() - pec.delete() - # And send it to the new email... - try: - user.email_user( - subject, - message, - configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - ) - except Exception: # pylint: disable=broad-except - log.warning('Unable to send confirmation email to new address', exc_info=True) - response = render_to_response("email_change_failed.html", {'email': pec.new_email}) - transaction.set_rollback(True) - return response - - response = render_to_response("email_change_successful.html", address_context) - return response - - -@require_POST -@login_required -@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") - course_key = CourseKey.from_string(course_id) - receive_emails = request.POST.get("receive_emails") - if receive_emails: - optout_object = Optout.objects.filter(user=user, course_id=course_key) - if optout_object: - optout_object.delete() - log.info( - u"User %s (%s) opted in to receive emails from course %s", - user.username, - user.email, - course_id, - ) - track.views.server_track( - request, - "change-email-settings", - {"receive_emails": "yes", "course": course_id}, - page='dashboard', - ) - else: - Optout.objects.get_or_create(user=user, course_id=course_key) - log.info( - u"User %s (%s) opted out of receiving emails from course %s", - user.username, - user.email, - course_id, - ) - track.views.server_track( - request, - "change-email-settings", - {"receive_emails": "no", "course": course_id}, - page='dashboard', - ) - - return JsonResponse({"success": True}) - - -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. - 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 - - 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. - if self.oauth_client_ids: - 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 - - -@ensure_csrf_cookie -def text_me_the_app(request): - """ - Text me the app view. - """ - text_me_fragment = TextMeTheAppFragmentView().render_to_fragment(request) - context = { - 'nav_hidden': True, - 'show_dashboard_tabs': True, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'fragment': text_me_fragment - } - - return render_to_response('text-me-the-app.html', context) diff --git a/common/djangoapps/student/views/__init__.py b/common/djangoapps/student/views/__init__.py new file mode 100644 index 0000000000..eefb35ad17 --- /dev/null +++ b/common/djangoapps/student/views/__init__.py @@ -0,0 +1,8 @@ +""" +Combines all of the broken out student views +""" + +# pylint: disable=wildcard-import +from dashboard import * +from login import * +from management import * diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py new file mode 100644 index 0000000000..f4862d60b3 --- /dev/null +++ b/common/djangoapps/student/views/dashboard.py @@ -0,0 +1,764 @@ +""" +Dashboard view and supporting methods +""" + +import datetime +import logging +from collections import defaultdict + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from six import text_type, iteritems + +import track.views +from bulk_email.models import BulkEmailFlag, Optout # pylint: disable=import-error +from course_modes.models import CourseMode +from courseware.access import has_access +from edxmako.shortcuts import render_to_response, render_to_string +from entitlements.models import CourseEntitlement +from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error +from openedx.core.djangoapps import monitoring_utils +from openedx.core.djangoapps.catalog.utils import ( + get_programs, + get_pseudo_session_for_entitlement, + get_visible_sessions_for_entitlement +) +from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import ProgramDataExtender, ProgramProgressMeter +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace +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 set_user_info_cookie +from student.helpers import cert_info, check_verify_status_by_course +from student.models import ( + CourseEnrollment, + CourseEnrollmentAttribute, + DashboardConfiguration, + UserProfile +) +from util.milestones_helpers import get_pre_requisite_courses_not_completed +from xmodule.modulestore.django import modulestore + +log = logging.getLogger("edx.student") + + +def get_org_black_and_whitelist_for_site(): + """ + 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 + + +def _get_recently_enrolled_courses(course_enrollments): + """ + Given a list of enrollments, filter out all but recent enrollments. + + Args: + course_enrollments (list[CourseEnrollment]): A list of course enrollments. + + Returns: + list[CourseEnrollment]: A list of recent course enrollments. + """ + seconds = DashboardConfiguration.current().recent_enrollment_time_delta + time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds)) + return [ + enrollment for enrollment in course_enrollments + # 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 + ] + + +def _allow_donation(course_modes, course_id, enrollment): + """ + 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. + enrollment(CourseEnrollment): The enrollment object in which the user is enrolled + + Returns: + True if the course is allowing donations. + + """ + if course_id not in course_modes: + flat_unexpired_modes = { + text_type(course_id): [mode for mode in modes] + for course_id, modes in iteritems(course_modes) + } + flat_all_modes = { + text_type(course_id): [mode.slug for mode in modes] + for course_id, modes in iteritems(CourseMode.all_modes_for_courses([course_id])) + } + log.error( + u'Can not find `%s` in course modes.`%s`. All modes: `%s`', + course_id, + flat_unexpired_modes, + flat_all_modes + ) + donations_enabled = configuration_helpers.get_value( + 'ENABLE_DONATIONS', + DonationConfiguration.current().enabled + ) + return ( + donations_enabled and + enrollment.mode in course_modes[course_id] and + course_modes[course_id][enrollment.mode].min_price == 0 + ) + + +def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name + """ + Builds a recent course enrollment message. + + Constructs a new message template based on any recent course enrollments + for the student. + + Args: + course_enrollments (list[CourseEnrollment]): a list of course enrollments. + course_modes (dict): Mapping of course ID's to course mode dictionaries. + + Returns: + A string representing the HTML message output from the message template. + None if there are no recently enrolled courses. + + """ + recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments) + + if recently_enrolled_courses: + 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) + for enrollment in recently_enrolled_courses + ) + + platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) + + return render_to_string( + 'enrollment/course_enrollment_message.html', + { + '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 + } + ) + + +def get_course_enrollments(user, org_whitelist, org_blacklist): + """ + Given a user, return a filtered set of his or her course enrollments. + + Arguments: + user (User): the user in question. + 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. + + Returns: + generator[CourseEnrollment]: a sequence of enrollments to be displayed + on the user's dashboard. + """ + for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user): + + # 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 + + # Filter out anything that is not in the whitelist. + if org_whitelist and course_overview.location.org not in org_whitelist: + continue + + # Conversely, filter out any enrollments in the blacklist. + elif org_blacklist and course_overview.location.org in org_blacklist: + continue + + # Else, include the enrollment. + else: + yield enrollment + + +def complete_course_mode_info(course_id, enrollment, modes=None): + """ + 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 + """ + if modes is None: + modes = CourseMode.modes_for_course_dict(course_id) + + mode_info = {'show_upsell': False, 'days_for_upsell': None} + # 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: + mode_info['show_upsell'] = True + mode_info['verified_sku'] = modes['verified'].sku + mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku + # if there is an expiration date, find out how long from now it is + if modes['verified'].expiration_datetime: + today = datetime.datetime.now(UTC).date() + mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days + + return mode_info + + +def is_course_blocked(request, redeemed_registration_codes, course_key): + """ + Checking if registration is blocked or not. + """ + blocked = False + for redeemed_registration in redeemed_registration_codes: + # 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 + if redeemed_registration.invoice_item: + if not redeemed_registration.invoice_item.invoice.is_valid: + blocked = True + # disabling email notifications for unpaid registration courses + Optout.objects.get_or_create(user=request.user, course_id=course_key) + log.info( + u"User %s (%s) opted out of receiving emails from course %s", + request.user.username, + request.user.email, + course_key, + ) + track.views.server_track( + request, + "change-email1-settings", + {"receive_emails": "no", "course": text_type(course_key)}, + page='dashboard', + ) + break + + return blocked + + +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 + + +def reverification_info(statuses): + """ + Returns reverification-related information for *all* of user's enrollments whose + reverification status is in statuses. + + 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]: + reverifications[status].sort(key=lambda x: x.date) + return reverifications + + +def _credit_statuses(user, course_enrollments): + """ + 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. + course_enrollments (list[CourseEnrollment]): List of enrollments for the + user. + + 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: + >>> _credit_statuses(user, course_enrollments) + { + 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 + + # Feature flag off + if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"): + return {} + + request_status_by_course = { + request["course_key"]: request["status"] + for request in credit_api.get_credit_requests_for_user(user.username) + } + + credit_enrollments = { + enrollment.course_id: enrollment + for enrollment in course_enrollments + 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): + course_key = CourseKey.from_string(text_type(eligibility["course_key"])) + providers_names = get_credit_provider_display_names(course_key) + status = { + "course_key": text_type(course_key), + "eligible": True, + "deadline": eligibility["deadline"], + "purchased": course_key in credit_enrollments, + "provider_name": make_providers_strings(providers_names), + "provider_status_url": None, + "provider_id": None, + "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") + status["provider_id"] = provider_id + + statuses[course_key] = status + + return statuses + + +@login_required +@ensure_csrf_cookie +def student_dashboard(request): + """ + 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. + + """ + user = request.user + if not UserProfile.objects.filter(user=user).exists(): + return redirect(reverse('account_settings')) + + platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) + 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) + ) + activation_email_support_link = configuration_helpers.get_value( + 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK + + # 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() + course_enrollments = list(get_course_enrollments(user, site_org_whitelist, site_org_blacklist)) + + # Get the entitlements for the user and a mapping to all available sessions for that entitlement + # If an entitlement has no available sessions, pass through a mock course overview object + course_entitlements = list(CourseEntitlement.get_active_entitlements_for_user(user)) + course_entitlement_available_sessions = {} + unfulfilled_entitlement_pseudo_sessions = {} + for course_entitlement in course_entitlements: + course_entitlement.update_expired_at() + available_sessions = get_visible_sessions_for_entitlement(course_entitlement) + course_entitlement_available_sessions[str(course_entitlement.uuid)] = available_sessions + if not course_entitlement.enrollment_course_run: + # Unfulfilled entitlements need a mock session for metadata + pseudo_session = get_pseudo_session_for_entitlement(course_entitlement) + unfulfilled_entitlement_pseudo_sessions[str(course_entitlement.uuid)] = pseudo_session + + # Record how many courses there are so that we can get a better + # understanding of usage patterns on prod. + monitoring_utils.accumulate('num_courses', len(course_enrollments)) + + # Sort the enrollment pairs by the enrollment date + course_enrollments.sort(key=lambda x: x.created, reverse=True) + + # Retrieve the course modes for each course + enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments] + __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) + course_modes_by_course = { + course_id: { + mode.slug: mode + for mode in modes + } + for course_id, modes in iteritems(unexpired_course_modes) + } + + # Check to see if the student has recently enrolled in a course. + # If so, display a notification message confirming the enrollment. + enrollment_message = _create_recent_enrollment_message( + course_enrollments, course_modes_by_course + ) + + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) + + 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', + { + 'email': user.email, + 'platform_name': platform_name, + 'activation_email_support_link': activation_email_support_link + } + ) + elif not user.is_active: + banner_account_activation_message = render_to_string( + 'registration/activate_account_notice.html', + {'email': user.email} + ) + + enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) + + # Disable lookup of Enterprise consent_required_course due to ENT-727 + # Will re-enable after fixing WL-1315 + consent_required_courses = set() + enterprise_customer_name = None + + # Account activation message + account_activation_messages = [ + message for message in messages.get_messages(request) if 'account-activation' in message.tags + ] + + # Global staff can see what courses encountered an error on their dashboard + staff_access = False + errored_courses = {} + if has_access(user, 'staff', 'global'): + # Show any courses that encountered an error on load + staff_access = True + errored_courses = modulestore().get_errored_courses() + + show_courseware_links_for = frozenset( + enrollment.course_id for enrollment in course_enrollments + if has_access(request.user, 'load', enrollment.course_overview) + ) + + # Find programs associated with course runs being displayed. This information + # is passed in the template context to allow rendering of program-related + # information on the dashboard. + meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments) + ecommerce_service = EcommerceService() + inverted_programs = meter.invert_programs() + + urls, program_data = {}, {} + bundles_on_dashboard_flag = WaffleFlag(WaffleFlagNamespace(name=u'student.experiments'), u'bundles_on_dashboard') + + if bundles_on_dashboard_flag.is_enabled(): + programs_data = meter.programs + + if programs_data and inverted_programs and inverted_programs.values(): + program_uuid = inverted_programs.values()[0][0]['uuid'] + meter.programs = [get_programs(request.site, uuid=program_uuid)] + program_data = meter.programs[0] + program_data = ProgramDataExtender(program_data, request.user).extend() + + skus = program_data.get('skus') + + urls = { + 'commerce_api_url': reverse('commerce_api:v0:baskets:create'), + 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus) + } + urls['completeProgramURL'] = urls['buy_button_url'] + '&bundle=' + program_data.get('uuid') + + # 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 = { + enrollment.course_id: complete_course_mode_info( + enrollment.course_id, enrollment, + modes=course_modes_by_course[enrollment.course_id] + ) + for enrollment in course_enrollments + } + + # 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. + verify_status_by_course = check_verify_status_by_course(user, course_enrollments) + cert_statuses = { + enrollment.course_id: cert_info(request.user, enrollment.course_overview) + for enrollment in course_enrollments + } + + # only show email settings for Mongo course and when bulk email is turned on + show_email_settings_for = frozenset( + enrollment.course_id for enrollment in course_enrollments if ( + BulkEmailFlag.feature_enabled(enrollment.course_id) + ) + ) + + # Verification Attempts + # Used to generate the "you must reverify for course x" banner + verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user) + verification_errors = get_verification_error_reasons_for_display(verification_error_codes) + + # Gets data for midcourse reverifications, if any are necessary or have failed + statuses = ["approved", "denied", "pending", "must_reverify"] + reverifications = reverification_info(statuses) + + 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 + ) + ) + + enrolled_courses_either_paid = frozenset( + enrollment.course_id for enrollment in course_enrollments + if enrollment.is_paid_course() + ) + + # 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"]) + + # Populate the Order History for the side-bar. + order_history_list = order_history( + user, + course_org_filter=site_org_whitelist, + org_filter_out_set=site_org_blacklist + ) + + # get list of courses having pre-requisites yet to be completed + courses_having_prerequisites = frozenset( + enrollment.course_id for enrollment in course_enrollments + if enrollment.course_overview.pre_requisite_courses + ) + courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) + + if 'notlive' in request.GET: + redirect_message = _("The course you are looking for does not start until {date}.").format( + date=request.GET['notlive'] + ) + 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'] + ) + else: + redirect_message = '' + + valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] + display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses + + # Filter out any course enrollment course cards that are associated with fulfilled entitlements + for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]: + course_enrollments = [ + enr for enr in course_enrollments if entitlement.enrollment_course_run.course_id != enr.course_id + ] + + context = { + 'urls': urls, + 'program_data': program_data, + 'enterprise_message': enterprise_message, + 'consent_required_courses': consent_required_courses, + 'enterprise_customer_name': enterprise_customer_name, + 'enrollment_message': enrollment_message, + 'redirect_message': redirect_message, + 'account_activation_messages': account_activation_messages, + 'course_enrollments': course_enrollments, + 'course_entitlements': course_entitlements, + 'course_entitlement_available_sessions': course_entitlement_available_sessions, + 'unfulfilled_entitlement_pseudo_sessions': unfulfilled_entitlement_pseudo_sessions, + 'course_optouts': course_optouts, + 'banner_account_activation_message': banner_account_activation_message, + 'sidebar_account_activation_message': sidebar_account_activation_message, + 'staff_access': staff_access, + 'errored_courses': errored_courses, + 'show_courseware_links_for': show_courseware_links_for, + 'all_course_modes': course_mode_info, + 'cert_statuses': cert_statuses, + 'credit_statuses': _credit_statuses(user, course_enrollments), + 'show_email_settings_for': show_email_settings_for, + 'reverifications': reverifications, + 'verification_status': verification_status, + 'verification_status_by_course': verify_status_by_course, + 'verification_errors': verification_errors, + 'block_courses': block_courses, + 'denied_banner': denied_banner, + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, + 'user': user, + 'logout_url': reverse('logout'), + 'platform_name': platform_name, + 'enrolled_courses_either_paid': enrolled_courses_either_paid, + 'provider_states': [], + 'order_history_list': order_history_list, + 'courses_requirements_not_met': courses_requirements_not_met, + 'nav_hidden': True, + 'inverted_programs': inverted_programs, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'show_dashboard_tabs': True, + 'disable_courseware_js': True, + 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard, + 'display_sidebar_on_dashboard': display_sidebar_on_dashboard, + } + + if ecommerce_service.is_enabled(request.user): + context.update({ + 'use_ecommerce_payment_flow': True, + 'ecommerce_payment_page': ecommerce_service.payment_page_url(), + }) + + response = render_to_response('dashboard.html', context) + set_user_info_cookie(response, request) + return response diff --git a/common/djangoapps/student/views/login.py b/common/djangoapps/student/views/login.py new file mode 100644 index 0000000000..04b19dbc81 --- /dev/null +++ b/common/djangoapps/student/views/login.py @@ -0,0 +1,759 @@ +""" +Views for login / logout and associated functionality + +Much of this file was broken out from views.py, previous history can be found there. +""" + +import datetime +import logging +import uuid +import warnings +from urlparse import parse_qs, urlsplit, urlunsplit + +import analytics +import edx_oauth2_provider +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import authenticate, load_backend, login as django_login, logout +from django.contrib.auth.models import AnonymousUser, User +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy +from django.core.validators import ValidationError, validate_email +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.shortcuts import redirect +from django.template.context_processors import csrf +from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_POST +from django.views.generic import TemplateView +from opaque_keys.edx.locator import CourseLocator +from provider.oauth2.models import Client +from ratelimitbackend.exceptions import RateLimitException +from requests import HTTPError +from six import text_type +from social_core.backends import oauth as social_oauth +from social_core.exceptions import AuthAlreadyAssociated, AuthException +from social_django import utils as social_utils + +import openedx.core.djangoapps.external_auth.views +import third_party_auth +from django_comment_common.models import assign_role +from edxmako.shortcuts import render_to_response, render_to_string +from eventtracking import tracker +from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.features.course_experience import course_home_url_name +from student.cookies import delete_logged_in_cookies, set_logged_in_cookies +from student.forms import AccountCreationForm +from student.helpers import ( + AccountValidationError, + auth_pipeline_urls, + create_or_set_user_attribute_created_on_site, + generate_activation_email_context, + get_next_url_for_login_page +) +from student.models import ( + CourseAccessRole, + CourseEnrollment, + LoginFailures, + PasswordHistory, + Registration, + UserProfile, + anonymous_id_for_user, + create_comments_service_user +) +from student.helpers import authenticate_new_user, do_create_account +from third_party_auth import pipeline, provider +from util.json_request import JsonResponse + +log = logging.getLogger("edx.student") +AUDIT_LOG = logging.getLogger("audit") + + +class AuthFailedError(Exception): + """ + This is a helper for the login view, allowing the various sub-methods to early out with an appropriate failure + message. + """ + def __init__(self, value=None, redirect=None, redirect_url=None): + self.value = value + self.redirect = redirect + self.redirect_url = redirect_url + + def get_response(self): + resp = {'success': False} + for attr in ('value', 'redirect', 'redirect_url'): + if self.__getattribute__(attr) and len(self.__getattribute__(attr)): + resp[attr] = self.__getattribute__(attr) + + return resp + + +def _do_third_party_auth(request): + """ + User is already authenticated via 3rd party, now try to find and return their associated Django user. + """ + running_pipeline = pipeline.get(request) + username = running_pipeline['kwargs'].get('username') + backend_name = running_pipeline['backend'] + third_party_uid = running_pipeline['kwargs']['uid'] + requested_provider = provider.Registry.get_from_pipeline(running_pipeline) + platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) + + try: + return pipeline.get_authenticated_user(requested_provider, username, third_party_uid) + except User.DoesNotExist: + AUDIT_LOG.info( + 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 += "

" + 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 += "

" + message += _( + "If you don't have an {platform_name} account yet, " + "click Register at the top of the page." + ).format( + platform_name=platform_name + ) + + raise AuthFailedError(message) + + +def _get_user_by_email(request): + """ + Finds a user object in the database based on the given request, ignores all fields except for email. + """ + if 'email' not in request.POST or 'password' not in request.POST: + raise AuthFailedError(_('There was an error receiving your login information. Please email us.')) + + email = request.POST['email'] + + try: + return 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)) + + +def _check_shib_redirect(user): + """ + See 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. + """ + if settings.FEATURES.get('AUTH_USE_SHIB') and user: + try: + eamap = ExternalAuthMap.objects.get(user=user) + if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): + raise AuthFailedError('', redirect=reverse('shib-login')) + except ExternalAuthMap.DoesNotExist: + # This is actually the common case, logging in user without external linked login + AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) + + +def _check_excessive_login_attempts(user): + """ + See if account has been locked out due to excessive login failures + """ + if user and LoginFailures.is_feature_enabled(): + if LoginFailures.is_user_locked_out(user): + raise AuthFailedError(_('This account has been temporarily locked due ' + 'to excessive login failures. Try again later.')) + + +def _check_forced_password_reset(user): + """ + See if the user must reset his/her password due to any policy settings + """ + if user and PasswordHistory.should_user_reset_password_now(user): + raise AuthFailedError(_('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 ' + '"Forgot Password" link on this page to reset your password before logging in again.')) + + +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. + """ + + 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.

' + 'We just sent an activation link to {email}. If ' + 'you do not receive an email, check your spam folders or ' + 'contact {platform} Support.') + + not_activated_message = not_activated_msg_template.format( + email=user.email, + support_url=support_url, + platform=platform_name + ) + + return not_activated_message + + +def _log_and_raise_inactive_user_auth_error(unauthenticated_user): + """ + Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt + by an inactive user, re-sending the activation email, and raising an error with the correct message. + """ + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning( + u"Login failed - Account not active for user.id: {0}, resending activation".format( + unauthenticated_user.id) + ) + else: + AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format( + unauthenticated_user.username) + ) + + send_reactivation_email_for_user(unauthenticated_user) + raise AuthFailedError(_generate_not_activated_message(unauthenticated_user)) + + +def _authenticate_first_party(request, unauthenticated_user): + """ + Use Django authentication on the given request, using rate limiting if configured + """ + + # 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 = unauthenticated_user.username if unauthenticated_user else "" + + try: + return authenticate( + username=username, + password=request.POST['password'], + request=request) + + # This occurs when there are too many attempts from the same IP address + except RateLimitException: + raise AuthFailedError(_('Too many failed login attempts. Try again later.')) + + +def _handle_failed_authentication(user): + """ + Handles updating the failed login count, inactive user notifications, and logging failed authentications. + """ + if user: + if LoginFailures.is_feature_enabled(): + LoginFailures.increment_lockout_counter(user) + + if not user.is_active: + _log_and_raise_inactive_user_auth_error(user) + + # if we didn't find this username earlier, the account for this email + # doesn't exist, and doesn't have a corresponding password + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + loggable_id = user.id if user else "" + 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(user.email)) + + raise AuthFailedError(_('Email or password is incorrect.')) + + +def _handle_successful_authentication_and_login(user, request): + """ + Handles clearing the failed login counter, login tracking, and setting session timeout. + """ + if LoginFailures.is_feature_enabled(): + LoginFailures.clear_lockout_counter(user) + + _track_user_login(user, request) + + try: + django_login(request, user) + if request.POST.get('remember') == 'true': + request.session.set_expiry(604800) + log.debug("Setting user session to never expire") + else: + request.session.set_expiry(0) + except Exception as exc: # pylint: disable=broad-except + AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") + log.critical("Login failed - Could not create session. Is memcached running?") + log.exception(exc) + raise + + +def _track_user_login(user, request): + """ + Sends a tracking event for a successful login. + """ + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + analytics.identify( + user.id, + { + 'email': request.POST['email'], + 'username': user.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 + } + ) + + analytics.track( + user.id, + "edx.bi.user.account.authenticated", + { + 'category': "conversion", + 'label': request.POST.get('course_id'), + 'provider': None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + +def send_reactivation_email_for_user(user): + try: + registration = Registration.objects.get(user=user) + except Registration.DoesNotExist: + return JsonResponse({ + "success": False, + "error": _('No inactive user with this e-mail exists'), + }) + + try: + context = generate_activation_email_context(user, registration) + 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') + }) + + subject = render_to_string('emails/activation_email_subject.txt', context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/activation_email.txt', context) + 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) + + try: + user.email_user(subject, message, from_address) + except Exception: # pylint: disable=broad-except + log.error( + u'Unable to send reactivation email from "%s" to "%s"', + from_address, + user.email, + exc_info=True + ) + return JsonResponse({ + "success": False, + "error": _('Unable to send reactivation email') + }) + + return JsonResponse({"success": True}) + + +@ensure_csrf_cookie +def login_user(request): + """ + AJAX request to log in the user. + """ + third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) + trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) + was_authenticated_third_party = False + + try: + 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. + + # This nested try is due to us only returning an HttpResponse in this + # one case vs. JsonResponse everywhere else. + try: + email_user = _do_third_party_auth(request) + was_authenticated_third_party = True + except AuthFailedError as e: + return HttpResponse(e.value, content_type="text/plain", status=403) + else: + email_user = _get_user_by_email(request) + + _check_shib_redirect(email_user) + _check_excessive_login_attempts(email_user) + _check_forced_password_reset(email_user) + + possibly_authenticated_user = email_user + + if not was_authenticated_third_party: + possibly_authenticated_user = _authenticate_first_party(request, email_user) + + if possibly_authenticated_user is None or not possibly_authenticated_user.is_active: + _handle_failed_authentication(email_user) + + _handle_successful_authentication_and_login(possibly_authenticated_user, request) + + redirect_url = None # The AJAX method calling should know the default destination upon success + if was_authenticated_third_party: + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend']) + + response = JsonResponse({ + 'success': True, + 'redirect_url': redirect_url, + }) + + # Ensure that the external marketing site can + # detect that the user is logged in. + return set_logged_in_cookies(request, response, possibly_authenticated_user) + except AuthFailedError as error: + return JsonResponse(error.get_response()) + + +@csrf_exempt +@require_POST +@social_utils.psa("social:complete") +def login_oauth_token(request, backend): + """ + Authenticate the client using an OAuth access token by using the token to + retrieve information from a third party and matching that information to an + existing user. + """ + warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) + + backend = request.backend + if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): + if "access_token" in request.POST: + # Tell third party auth pipeline that this is an API call + request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API + user = None + access_token = request.POST["access_token"] + try: + user = backend.do_auth(access_token) + except (HTTPError, AuthException): + pass + # do_auth can return a non-User object if it fails + if user and isinstance(user, User): + django_login(request, user) + return JsonResponse(status=204) + else: + # Ensure user does not re-enter the pipeline + request.social_strategy.clean_partial_pipeline(access_token) + return JsonResponse({"error": "invalid_token"}, status=401) + else: + return JsonResponse({"error": "invalid_request"}, status=400) + raise Http404 + + +@ensure_csrf_cookie +def signin_user(request): + """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 + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated(): + 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: + third_party_auth_error = _(text_type(msg)) # pylint: disable=translation-of-non-string + break + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + # 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', + 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'third_party_auth_error': third_party_auth_error + } + + return render_to_response('login.html', context) + + +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 + + +def auto_auth(request): + """ + Create or configure a user account, then log in as that user. + + Enabled only when + settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. + + 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` + * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` + * `no_login`: Define this to create the user but not login + * `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 + * `is_active` : make/update account with status provided as 'is_active' + If username, email, or password are not provided, use + randomly generated credentials. + """ + + # Generate a unique name to use if none provided + generated_username = uuid.uuid4().hex[0:30] + + # Use the params from the request, otherwise use these defaults + username = request.GET.get('username', generated_username) + password = request.GET.get('password', username) + email = request.GET.get('email', username + "@example.com") + full_name = request.GET.get('full_name', username) + 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)) + + # Valid modes: audit, credit, honor, no-id-professional, professional, verified + enrollment_mode = request.GET.get('enrollment_mode', 'honor') + + # 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(',')) + + redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to + login_when_done = 'no_login' not in request.GET + + form = AccountCreationForm( + data={ + 'username': username, + 'email': email, + 'password': password, + 'name': full_name, + }, + tos_required=False + ) + + # Attempt to create the account. + # If successful, this will return a tuple containing + # the new user object. + try: + user, profile, reg = do_create_account(form) + except (AccountValidationError, ValidationError): + # Attempt to retrieve the existing user. + user = User.objects.get(username=username) + user.email = email + user.set_password(password) + user.is_active = is_active + user.save() + profile = UserProfile.objects.get(user=user) + reg = Registration.objects.get(user=user) + except PermissionDenied: + return HttpResponseForbidden(_('Account creation not allowed.')) + + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save() + + if is_active: + reg.activate() + reg.save() + + # 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() + + create_or_set_user_attribute_created_on_site(user, request.site) + + # Enroll the user in a course + course_key = None + if course_id: + course_key = CourseLocator.from_string(course_id) + CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) + + # 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) + + # Log in as the user + if login_when_done: + user = authenticate_new_user(request, username, password) + django_login(request, user) + + create_comments_service_user(user) + + if redirect_when_done: + if redirect_to: + # Redirect to page specified by the client + redirect_url = redirect_to + elif course_id: + # Redirect to the course homepage (in LMS) or outline page (in Studio) + try: + redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id}) + except NoReverseMatch: + redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) + else: + # Redirect to the learner dashboard (in LMS) or homepage (in Studio) + try: + redirect_url = reverse('dashboard') + except NoReverseMatch: + redirect_url = reverse('home') + + return redirect(redirect_url) + else: + response = JsonResponse({ + 'created_status': 'Logged in' if login_when_done else 'Created', + 'username': username, + 'email': email, + 'password': password, + 'user_id': user.id, # pylint: disable=no-member + 'anonymous_id': anonymous_id_for_user(user, None), + }) + response.set_cookie('csrftoken', csrf(request)['csrf_token']) + return response + + +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. + 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 + + 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. + if self.oauth_client_ids: + 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 diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py new file mode 100644 index 0000000000..28227b8e03 --- /dev/null +++ b/common/djangoapps/student/views/management.py @@ -0,0 +1,1498 @@ +""" +Student Views +""" + +import datetime +import json +import logging +import uuid +import warnings +from collections import namedtuple + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import authenticate, load_backend, login as django_login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.auth.views import password_reset_confirm +from django.core import mail +from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy +from django.core.validators import ValidationError, validate_email +from django.db import IntegrityError, transaction +from django.db.models.signals import post_save +from django.dispatch import Signal, receiver +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.shortcuts import redirect +from django.template.context_processors import csrf +from django.template.response import TemplateResponse +from django.utils.encoding import force_bytes, force_text +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 +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_POST +from ipware.ip import get_ip +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from requests import HTTPError +from six import text_type, iteritems +from social_core.exceptions import AuthAlreadyAssociated, AuthException +from social_django import utils as social_utils + +import analytics +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 Optout # pylint: disable=import-error +from course_modes.models import CourseMode +from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error +from edxmako.shortcuts import render_to_response, render_to_string +from eventtracking import tracker +# Note that this lives in LMS, so this dependency should be refactored. +from notification_prefs.views import enable_notifications +from openedx.core.djangoapps import monitoring_utils +from openedx.core.djangoapps.catalog.utils import ( + get_programs_with_type, +) +from openedx.core.djangoapps.embargo import api as embargo_api +from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming import helpers as theming_helpers +from openedx.core.djangoapps.user_api import accounts as accounts_settings +from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangolib.markup import HTML +from student.cookies import set_logged_in_cookies +from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form +from student.helpers import ( + DISABLE_UNENROLL_CERT_STATES, + AccountValidationError, + auth_pipeline_urls, + authenticate_new_user, + cert_info, + create_or_set_user_attribute_created_on_site, + destroy_oauth_tokens, + do_create_account, + generate_activation_email_context, + get_next_url_for_login_page +) +from student.models import ( + ALLOWEDTOENROLL_TO_ENROLLED, + CourseEnrollment, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PasswordHistory, + PendingEmailChange, + Registration, + RegistrationCookieConfiguration, + UserAttribute, + UserProfile, + UserSignupSource, + UserStanding, + create_comments_service_user, +) +from student.signals import REFUND_ORDER +from student.tasks import send_activation_email +from student.text_me_the_app import TextMeTheAppFragmentView +from third_party_auth import pipeline, provider +from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY +from util.bad_request_rate_limiter import BadRequestRateLimiter +from util.db import outer_atomic +from util.json_request import JsonResponse +from util.password_policy_validators import validate_password_length, validate_password_strength +from xmodule.modulestore.django import modulestore + +log = logging.getLogger("edx.student") + +AUDIT_LOG = logging.getLogger("audit") +ReverifyInfo = namedtuple( + 'ReverifyInfo', + 'course_id course_name course_number date status display' +) # pylint: disable=invalid-name +SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' +# Used as the name of the user attribute for tracking affiliate registrations +REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' +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' +# used to announce a registration +REGISTER_USER = Signal(providing_args=["user", "registration"]) + + +def csrf_token(context): + """ + A csrf token that can be included in a form. + """ + token = context.get('csrf_token', '') + if token == 'NOTPROVIDED': + return '' + return (u'
'.format(token)) + + +# 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) +def index(request, extra_context=None, user=AnonymousUser()): + """ + Render the edX main page. + + extra_context is used to allow immediate display of certain modal windows, eg signup, + as used by external_auth. + """ + if extra_context is None: + extra_context = {} + + courses = get_courses(user) + + if configuration_helpers.get_value( + "ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"], + ): + courses = sort_by_start_date(courses) + else: + courses = sort_by_announcement(courses) + + context = {'courses': courses} + + context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html') + + # This appears to be an unused context parameter, at least for the master templates... + context['show_partners'] = configuration_helpers.get_value('show_partners', True) + + # TO DISPLAY A YOUTUBE WELCOME VIDEO + # 1) Change False to True + context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False) + + # Maximum number of courses to display on the homepage. + context['homepage_course_max'] = configuration_helpers.get_value( + 'HOMEPAGE_COURSE_MAX', settings.HOMEPAGE_COURSE_MAX + ) + + # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration + # Note: This value should be moved into a configuration setting and plumbed-through to the + # context via the site configuration workflow, versus living here + youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id") + context['homepage_promo_video_youtube_id'] = youtube_video_id + + # allow for theme override of the courses list + context['courses_list'] = theming_helpers.get_template_path('courses_list.html') + + # Insert additional context for use in the template + context.update(extra_context) + + # Add marketable programs to the context. + context['programs_list'] = get_programs_with_type(request.site, include_hidden=False) + + return render_to_response('index.html', context) + + +@ensure_csrf_cookie +def register_user(request, extra_context=None): + """ + Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`. + """ + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated(): + return redirect(redirect_to) + + external_auth_response = external_auth_register(request) + if external_auth_response is not None: + return external_auth_response + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + 'email': '', + 'name': '', + 'running_pipeline': None, + 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'selected_provider': '', + 'username': '', + } + + if extra_context is not None: + context.update(extra_context) + + if context.get("extauth_domain", '').startswith( + openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX + ): + return render_to_response('register-shib.html', context) + + # If third-party auth is enabled, prepopulate the form with data from the + # selected provider. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) + 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) + + return render_to_response('register.html', context) + + +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) + context = generate_activation_email_context(user, user_registration) + 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) + 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) + 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) + + +@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) + + +def _update_email_opt_in(request, org): + """ + Helper function used to hit the profile API if email opt-in is enabled. + """ + + email_opt_in = request.POST.get('email_opt_in') + if email_opt_in is not None: + email_opt_in_boolean = email_opt_in == 'true' + preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) + + +@transaction.non_atomic_requests +@require_POST +@outer_atomic(read_committed=True) +def change_enrollment(request, check_access=True): + """ + Modify the enrollment status for the logged-in user. + + TODO: This is lms specific and does not belong in common code. + + The request parameter must be a POST request (other methods return 405) + that specifies course_id and enrollment_action parameters. If course_id or + enrollment_action is not specified, if course_id is not valid, if + enrollment_action is something other than "enroll" or "unenroll", if + enrollment_action is "enroll" and enrollment is closed for the course, or + if enrollment_action is "unenroll" and the user is not enrolled in the + course, a 400 error will be returned. If the user is not logged in, 403 + will be returned; it is important that only this case return 403 so the + front end can redirect the user to a registration or login page when this + happens. This function should only be called from an AJAX request, so + the error messages in the responses should never actually be user-visible. + + Args: + request (`Request`): The Django request object + + Keyword Args: + 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. + + Returns: + Response + + """ + # Get the user + user = request.user + + # Ensure the user is authenticated + if not user.is_authenticated(): + return HttpResponseForbidden() + + # Ensure we received a course_id + action = request.POST.get("enrollment_action") + if 'course_id' not in request.POST: + return HttpResponseBadRequest(_("Course id not specified")) + + try: + course_id = CourseKey.from_string(request.POST.get("course_id")) + except InvalidKeyError: + log.warning( + u"User %s tried to %s with invalid course id: %s", + user.username, + action, + request.POST.get("course_id"), + ) + return HttpResponseBadRequest(_("Invalid course id")) + + # Allow us to monitor performance of this transaction on a per-course basis since we often roll-out features + # on a per-course basis. + monitoring_utils.set_custom_metric('course_id', text_type(course_id)) + + if action == "enroll": + # 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): + log.warning( + u"User %s tried to enroll in non-existent course %s", + user.username, + course_id + ) + return HttpResponseBadRequest(_("Course id is invalid")) + + # Record the user's email opt-in preference + if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'): + _update_email_opt_in(request, course_id.org) + + available_modes = CourseMode.modes_for_course_dict(course_id) + + # 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) + + # Check that auto enrollment is allowed for this course + # (= the course is NOT behind a paywall) + if CourseMode.can_auto_enroll(course_id): + # Enroll the user using the default mode (audit) + # We're assuming that users of the course enrollment table + # will NOT try to look up the course enrollment model + # by its slug. If they do, it's possible (based on the state of the database) + # for no such model to exist, even though we've set the enrollment type + # to "audit". + try: + enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) + if enroll_mode: + CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + except Exception: # pylint: disable=broad-except + return HttpResponseBadRequest(_("Could not enroll")) + + # If we have more than one course mode or professional ed is enabled, + # then send the user to the choose your track page. + # (In the case of no-id-professional/professional ed, this will redirect to a page that + # funnels users directly into the verification / payment flow) + if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes): + return HttpResponse( + reverse("course_modes_choose", kwargs={'course_id': text_type(course_id)}) + ) + + # Otherwise, there is only one mode available (the default) + return HttpResponse() + elif action == "unenroll": + enrollment = CourseEnrollment.get_enrollment(user, course_id) + if not enrollment: + return HttpResponseBadRequest(_("You are not enrolled in this course")) + + certificate_info = cert_info(user, enrollment.course_overview) + if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: + return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) + + CourseEnrollment.unenroll(user, course_id) + REFUND_ORDER.send(sender=None, course_enrollment=enrollment) + return HttpResponse() + else: + return HttpResponseBadRequest(_("Enrollment action is invalid")) + + +@require_GET +@login_required +@ensure_csrf_cookie +def manage_user_standing(request): + """ + Renders the view used to manage user standing. Also displays a table + of user accounts that have been disabled and who disabled them. + """ + if not request.user.is_staff: + raise Http404 + all_disabled_accounts = UserStanding.objects.filter( + account_status=UserStanding.ACCOUNT_DISABLED + ) + + all_disabled_users = [standing.user for standing in all_disabled_accounts] + + headers = ['username', 'account_changed_by'] + rows = [] + for user in all_disabled_users: + row = [user.username, user.standing.changed_by] + rows.append(row) + + context = {'headers': headers, 'rows': rows} + + return render_to_response("manage_user_standing.html", context) + + +@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() == '': + context['message'] = _('Please enter a username') + return JsonResponse(context, status=400) + + account_action = request.POST.get('account_action') + if account_action is None: + context['message'] = _('Please choose an option') + return JsonResponse(context, status=400) + + username = username.strip() + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + context['message'] = _("User with username {} does not exist").format(username) + return JsonResponse(context, status=400) + else: + user_account, _success = UserStanding.objects.get_or_create( + user=user, defaults={'changed_by': request.user}, + ) + if account_action == 'disable': + user_account.account_status = UserStanding.ACCOUNT_DISABLED + context['message'] = _("Successfully disabled {}'s account").format(username) + log.info(u"%s disabled %s's account", request.user, username) + elif account_action == 'reenable': + user_account.account_status = UserStanding.ACCOUNT_ENABLED + context['message'] = _("Successfully reenabled {}'s account").format(username) + log.info(u"%s reenabled %s's account", request.user, username) + else: + context['message'] = _("Unexpected account status") + return JsonResponse(context, status=400) + user_account.changed_by = request.user + user_account.standing_last_changed_at = datetime.datetime.now(UTC) + user_account.save() + + return JsonResponse(context) + + +@login_required +@ensure_csrf_cookie +def change_setting(request): + """ + JSON call to change a profile setting: Right now, location + """ + # TODO (vshnayder): location is no longer used + u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache + if 'location' in request.POST: + u_prof.location = request.POST['location'] + u_prof.save() + + return JsonResponse({ + "success": True, + "location": u_prof.location, + }) + + +@receiver(post_save, sender=User) +def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + Handler that saves the user Signup Source when the user is created + """ + if 'created' in kwargs and kwargs['created']: + site = configuration_helpers.get_value('SITE_NAME') + if site: + user_signup_source = UserSignupSource(user=kwargs['instance'], site=site) + user_signup_source.save() + log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) + + +def create_account_with_params(request, params): + """ + 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. + + Does not return anything. + + 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. + + 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"). + * 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. + """ + # 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()) + + # allow to define custom set of required/optional/hidden fields via configuration + extra_fields = configuration_helpers.get_value( + 'REGISTRATION_EXTRA_FIELDS', + getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + ) + # 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 + + is_third_party_auth_enabled = third_party_auth.is_enabled() + + if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): + params["password"] = pipeline.make_random_password() + + # 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')) + ]} + ) + + # 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 + # unless originally we didn't get a valid email or name from the external auth + # TODO: We do not check whether these values meet all necessary criteria, such as email length + do_external_auth = 'ExternalAuthMap' in request.session + if do_external_auth: + eamap = request.session['ExternalAuthMap'] + try: + validate_email(eamap.external_email) + params["email"] = eamap.external_email + except ValidationError: + pass + if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH: + 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"]) + + extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) + enforce_password_policy = ( + settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and + not do_external_auth + ) + # Can't have terms of service for certain SHIB users, like at Stanford + registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + tos_required = ( + registration_fields.get('terms_of_service') != 'hidden' or + registration_fields.get('honor_code') != 'hidden' + ) and ( + not settings.FEATURES.get("AUTH_USE_SHIB") or + not settings.FEATURES.get("SHIB_DISABLE_TOS") or + not do_external_auth or + not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) + ) + + form = AccountCreationForm( + data=params, + extra_fields=extra_fields, + extended_profile_fields=extended_profile_fields, + enforce_username_neq_password=True, + enforce_password_policy=enforce_password_policy, + tos_required=tos_required, + ) + custom_form = get_registration_extension_form(data=params) + + # Perform operations within a transaction that are critical to account creation + with transaction.atomic(): + # first, create the account + (user, profile, registration) = do_create_account(form, custom_form) + + # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth + # (If the user is using the normal register page, the social auth pipeline does the linking, not this code) + + # 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: + 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) + social_access_token = params.get('access_token') + if not social_access_token: + raise ValidationError({ + 'access_token': [ + _("An access_token is required when passing value ({}) for provider.").format( + params['provider'] + ) + ] + }) + request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API + pipeline_user = None + error_message = "" + try: + pipeline_user = request.backend.do_auth(social_access_token, user=user) + except AuthAlreadyAssociated: + error_message = _("The provided access_token is already associated with another user.") + except (HTTPError, AuthException): + error_message = _("The provided access_token is not valid.") + if not pipeline_user or not isinstance(pipeline_user, User): + # Ensure user does not re-enter the pipeline + request.social_strategy.clean_partial_pipeline(social_access_token) + raise ValidationError({'access_token': [error_message]}) + + # Perform operations that are non-critical parts of account creation + create_or_set_user_attribute_created_on_site(user, request.site) + + preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) + + if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): + try: + enable_notifications(user) + except Exception: # pylint: disable=broad-except + log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) + + dog_stats_api.increment("common.student.account_created") + + # If the user is registering via 3rd party auth, track which provider they use + third_party_provider = None + running_pipeline = None + if is_third_party_auth_enabled and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + + # Track the user's registration + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + identity_args = [ + user.id, # pylint: disable=no-member + { + 'email': user.email, + 'username': user.username, + 'name': profile.name, + # 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, + 'education': profile.level_of_education_display, + 'address': profile.mailing_address, + 'gender': profile.gender_display, + 'country': text_type(profile.country), + } + ] + + if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): + identity_args.append({ + "MailChimp": { + "listId": settings.MAILCHIMP_NEW_USER_LIST_ID + } + }) + + analytics.identify(*identity_args) + + analytics.track( + user.id, + "edx.bi.user.account.registered", + { + 'category': 'conversion', + 'label': params.get('course_id'), + 'provider': third_party_provider.name if third_party_provider else None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + # Announce registration + REGISTER_USER.send(sender=None, user=user, registration=registration) + + create_comments_service_user(user) + + # Check if we system is configured to skip activation email for the current user. + skip_email = skip_activation_email( + user, do_external_auth, running_pipeline, third_party_provider, + ) + + if skip_email: + registration.activate() + _enroll_user_in_pending_courses(user) # Enroll student in any pending courses + else: + compose_and_send_activation_email(user, profile, registration) + + new_user = authenticate_new_user(request, user.username, params['password']) + django_login(request, new_user) + request.session.set_expiry(0) + + 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.') + + # TODO: there is no error checking here to see that the user actually logged in successfully, + # and is not yet an active user. + if new_user is not None: + AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) + + if do_external_auth: + eamap.user = new_user + eamap.dtsignup = datetime.datetime.now(UTC) + eamap.save() + 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) + + if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): + log.info('bypassing activation email') + 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)) + + return new_user + + +def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider): + """ + Return `True` if activation email should be skipped. + + Skip email if we are: + 1. Doing load testing. + 2. Random user generation for other forms of testing. + 3. External auth bypassing activation. + 4. Have the platform configured to not require e-mail activation. + 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) + + Note that this feature is only tested as a flag set one way or + the other for *new* systems. we need to be careful about + changing settings on a running system to make sure no users are + left in an inconsistent state (or doing a migration if they are). + + Arguments: + user (User): Django User object for the current user. + do_external_auth (bool): True if external authentication is in progress. + running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication. + third_party_provider (ProviderConfig): An instance of third party provider configuration. + + Returns: + (bool): `True` if account activation email should be skipped, `False` if account activation email should be + sent. + """ + sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email') + + # Email is valid if the SAML assertion email matches the user account email or + # no email was provided in the SAML assertion. Some IdP's use a callback + # to retrieve additional user account information (including email) after the + # initial account creation. + valid_email = ( + sso_pipeline_email == user.email or ( + sso_pipeline_email is None and + third_party_provider and + getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY + ) + ) + + # log the cases where skip activation email flag is set, but email validity check fails + if third_party_provider and third_party_provider.skip_email_verification and not valid_email: + log.info( + '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] ' + 'Account activation email sent as user\'s system email differs from SSO email.', + user.email, + sso_pipeline_email, + getattr(third_party_provider, "provider_id", None), + getattr(third_party_provider, "identity_provider_type", None) + ) + + return ( + settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or + settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or + (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or + (third_party_provider and third_party_provider.skip_email_verification and valid_email) + ) + + +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 + ) + + +def record_affiliate_registration_attribution(request, user): + """ + Attribute this user's registration to the referring affiliate, if + applicable. + """ + affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) + if user and affiliate_id: + UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) + + +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) + 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 + ) + 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) + UserAttribute.set_user_attribute( + user, + REGISTRATION_UTM_CREATED_AT, + created_at_datetime + ) + + +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) + + +@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 header.html + """ + # 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.")) + + warnings.warn("Please use RegistrationView instead.", DeprecationWarning) + + try: + user = create_account_with_params(request, post_override or request.POST) + except AccountValidationError as exc: + return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400) + except ValidationError as exc: + field, error_list = next(iteritems(exc.message_dict)) + return JsonResponse( + { + "success": False, + "field": field, + "value": error_list[0], + }, + status=400 + ) + + redirect_url = None # The AJAX method calling should know the default destination upon success + + # Resume the third-party-auth pipeline if necessary. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(running_pipeline['backend']) + + response = JsonResponse({ + 'success': True, + 'redirect_url': redirect_url, + }) + set_logged_in_cookies(request, response, user) + return response + + +@ensure_csrf_cookie +def activate_account(request, key): + """ + When link in activation e-mail is clicked + """ + # 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, + HTML(_( + '{html_start}Your account could not be activated{html_end}' + 'Something went wrong, please contact support to resolve this issue.' + )).format( + support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), + html_start=HTML('

'), + html_end=HTML('

'), + ), + extra_tags='account-activation aa-icon' + ) + else: + if not registration.user.is_active: + registration.activate() + # 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. + messages.success( + request, + HTML(message).format( + html_start=HTML('

'), + html_end=HTML('

'), + ), + extra_tags='account-activation aa-icon', + ) + else: + messages.info( + request, + HTML(_('{html_start}This account has already been activated.{html_end}')).format( + html_start=HTML('

'), + html_end=HTML('

'), + ), + extra_tags='account-activation aa-icon', + ) + + # 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: + user_logged_in = request.user.is_authenticated() + already_active = True + if not registration.user.is_active: + registration.activate() + already_active = False + + # Enroll student in any pending courses he/she may have if auto_enroll flag is set + _enroll_user_in_pending_courses(registration.user) + + return render_to_response( + "registration/activation_complete.html", + { + 'user_logged_in': user_logged_in, + 'already_active': already_active + } + ) + + +@csrf_exempt +@require_POST +def password_reset(request): + """ + Attempts to send a password reset e-mail. + """ + # Add some rate limiting here by re-using the RateLimitMixin as a helper class + limiter = BadRequestRateLimiter() + if limiter.is_rate_limit_exceeded(request): + AUDIT_LOG.warning("Rate limit exceeded in password_reset") + return HttpResponseForbidden() + + form = PasswordResetFormNoActive(request.POST) + if form.is_valid(): + form.save(use_https=request.is_secure(), + from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), + request=request) + # 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, + } + ) + destroy_oauth_tokens(request.user) + else: + # bad user? tick the rate limiter counter + AUDIT_LOG.info("Bad password_reset user passed in.") + limiter.tick_bad_request_counter(request) + + return JsonResponse({ + 'success': True, + 'value': render_to_string('registration/password_reset_done.html', {}), + }) + + +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 + """ + 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 + + +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): + """ + 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`. + """ + + err_msg = None + # 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) + + return err_msg + + +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 = { + "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) + } + try: + uid_int = base36_to_int(uidb36) + user = User.objects.get(id=uid_int) + except (ValueError, User.DoesNotExist): + # 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 + ) + + if request.method == 'POST': + password = request.POST['new_password1'] + 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: + # We have a password reset attempt which violates some security + # policy, or any other validation. Use the existing Django template to communicate that + # back to the user. + context = { + 'validlink': valid_link, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': error_message, + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + + # remember what the old password hash is before we call down + old_password_hash = user.password + + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + # 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). + 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 + + # get the updated user + updated_user = User.objects.get(id=uid_int) + + # did the password hash change, if so record it in the PasswordHistory + if updated_user.password != old_password_hash: + entry = PasswordHistory() + entry.create(updated_user) + + else: + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + response_was_successful = response.context_data.get('validlink') + if response_was_successful and not user.is_active: + user.is_active = True + user.save() + + return response + + +def validate_new_email(user, new_email): + """ + 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. + """ + 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.')) + + if User.objects.filter(email=new_email).count() != 0: + raise ValueError(_('An account with this e-mail already exists.')) + + +def do_email_change_request(user, new_email, activation_key=None): + """ + 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. + """ + pec_list = PendingEmailChange.objects.filter(user=user) + if len(pec_list) == 0: + pec = PendingEmailChange() + pec.user = user + else: + pec = pec_list[0] + + # if activation_key is not passing as an argument, generate a random key + if not activation_key: + activation_key = uuid.uuid4().hex + + pec.new_email = new_email + pec.activation_key = activation_key + pec.save() + + context = { + 'key': pec.activation_key, + 'old_email': user.email, + 'new_email': pec.new_email + } + + subject = render_to_string('emails/email_change_subject.txt', context) + subject = ''.join(subject.splitlines()) + + message = render_to_string('emails/email_change.txt', context) + + from_address = configuration_helpers.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ) + try: + mail.send_mail(subject, message, from_address, [pec.new_email]) + except Exception: # pylint: disable=broad-except + log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) + raise ValueError(_('Unable to send email activation link. Please try again later.')) + + # 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, + } + ) + + +@ensure_csrf_cookie +def confirm_email_change(request, key): # pylint: disable=unused-argument + """ + User requested a new e-mail. This is called when the activation + link is clicked. We confirm with the old e-mail, and update + """ + with transaction.atomic(): + try: + pec = PendingEmailChange.objects.get(activation_key=key) + except PendingEmailChange.DoesNotExist: + response = render_to_response("invalid_email_key.html", {}) + transaction.set_rollback(True) + return response + + user = pec.user + address_context = { + 'old_email': user.email, + 'new_email': pec.new_email + } + + if len(User.objects.filter(email=pec.new_email)) != 0: + response = render_to_response("email_exists.html", {}) + transaction.set_rollback(True) + return response + + 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) + u_prof = UserProfile.objects.get(user=user) + meta = u_prof.get_meta() + if 'old_emails' not in meta: + meta['old_emails'] = [] + meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()]) + u_prof.set_meta(meta) + u_prof.save() + # Send it to the old email... + try: + user.email_user( + subject, + message, + configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + ) + except Exception: # pylint: disable=broad-except + log.warning('Unable to send confirmation email to old address', exc_info=True) + response = render_to_response("email_change_failed.html", {'email': user.email}) + transaction.set_rollback(True) + return response + + user.email = pec.new_email + user.save() + pec.delete() + # And send it to the new email... + try: + user.email_user( + subject, + message, + configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + ) + except Exception: # pylint: disable=broad-except + log.warning('Unable to send confirmation email to new address', exc_info=True) + response = render_to_response("email_change_failed.html", {'email': pec.new_email}) + transaction.set_rollback(True) + return response + + response = render_to_response("email_change_successful.html", address_context) + return response + + +@require_POST +@login_required +@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") + course_key = CourseKey.from_string(course_id) + receive_emails = request.POST.get("receive_emails") + if receive_emails: + optout_object = Optout.objects.filter(user=user, course_id=course_key) + if optout_object: + optout_object.delete() + log.info( + u"User %s (%s) opted in to receive emails from course %s", + user.username, + user.email, + course_id, + ) + track.views.server_track( + request, + "change-email-settings", + {"receive_emails": "yes", "course": course_id}, + page='dashboard', + ) + else: + Optout.objects.get_or_create(user=user, course_id=course_key) + log.info( + u"User %s (%s) opted out of receiving emails from course %s", + user.username, + user.email, + course_id, + ) + track.views.server_track( + request, + "change-email-settings", + {"receive_emails": "no", "course": course_id}, + page='dashboard', + ) + + return JsonResponse({"success": True}) + + +@ensure_csrf_cookie +def text_me_the_app(request): + """ + Text me the app view. + """ + text_me_fragment = TextMeTheAppFragmentView().render_to_fragment(request) + context = { + 'nav_hidden': True, + 'show_dashboard_tabs': True, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'fragment': text_me_fragment + } + + return render_to_response('text-me-the-app.html', context) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 094c9a6653..e09de2021a 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -1,4 +1,6 @@ -"""Base integration test for provider implementations.""" +""" +Base integration test for provider implementations. +""" import unittest diff --git a/common/djangoapps/track/management/commands/__init__.py b/common/djangoapps/track/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/track/management/commands/tracked_dummy_command.py b/common/djangoapps/track/management/commands/tracked_dummy_command.py new file mode 100644 index 0000000000..62c6ae6bc5 --- /dev/null +++ b/common/djangoapps/track/management/commands/tracked_dummy_command.py @@ -0,0 +1,19 @@ +""" +Command used for testing TrackedCommands +""" + +import json + +from eventtracking import tracker as eventtracker +from track.management.tracked_command import TrackedCommand + + +class Command(TrackedCommand): + """A locally-defined command, for testing, that returns the current context as a JSON string.""" + def add_arguments(self, parser): + parser.add_argument('dummy_arg') + parser.add_argument('--key1') + parser.add_argument('--key2') + + def handle(self, *args, **options): + return json.dumps(eventtracker.get_tracker().resolve_context()) diff --git a/common/djangoapps/track/management/tests/test_tracked_command.py b/common/djangoapps/track/management/tests/test_tracked_command.py index 695703a192..94a8c7b1d3 100644 --- a/common/djangoapps/track/management/tests/test_tracked_command.py +++ b/common/djangoapps/track/management/tests/test_tracked_command.py @@ -1,24 +1,20 @@ import json from StringIO import StringIO + +from django.core.management import call_command from django.test import TestCase -from eventtracking import tracker as eventtracker - -from track.management.tracked_command import TrackedCommand - - -class DummyCommand(TrackedCommand): - """A locally-defined command, for testing, that returns the current context as a JSON string.""" - def handle(self, *args, **options): - return json.dumps(eventtracker.get_tracker().resolve_context()) - class CommandsTestBase(TestCase): - + """ + Command for testing track functionality + """ def _run_dummy_command(self, *args, **kwargs): - """Runs the test command's execute method directly, and outputs a dict of the current context.""" + """ + Calls the test command and outputs a dict of the current context. + """ out = StringIO() - DummyCommand().execute(*args, stdout=out, **kwargs) + call_command('tracked_dummy_command', *args, stdout=out, **kwargs) out.seek(0) return json.loads(out.read()) @@ -26,4 +22,4 @@ class CommandsTestBase(TestCase): args = ['whee'] kwargs = {'key1': 'default', 'key2': True} json_out = self._run_dummy_command(*args, **kwargs) - self.assertEquals(json_out['command'], 'unknown') + self.assertEquals(json_out['command'].strip(), 'tracked_dummy_command') diff --git a/lms/djangoapps/branding/tests/test_page.py b/lms/djangoapps/branding/tests/test_page.py index 888d26e35e..ce8b14151a 100644 --- a/lms/djangoapps/branding/tests/test_page.py +++ b/lms/djangoapps/branding/tests/test_page.py @@ -192,7 +192,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase): ) self.factory = RequestFactory() - @patch('student.views.render_to_response', RENDER_MOCK) + @patch('student.views.management.render_to_response', RENDER_MOCK) @patch('courseware.views.views.render_to_response', RENDER_MOCK) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_COURSE_DISCOVERY': False}) def test_course_discovery_off(self): @@ -216,7 +216,7 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase): # make sure we have the special css class on the section self.assertIn('
', response.content) self.assertIn('
", field_errors["name"]["user_message"]) @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_update_sending_email_fails(self, send_mail): """Test what happens if all validation checks pass, but sending the email for email change fails.""" send_mail.side_effect = [Exception, None] diff --git a/pavelib/assets.py b/pavelib/assets.py index d6add35acd..f309ec2b46 100644 --- a/pavelib/assets.py +++ b/pavelib/assets.py @@ -287,7 +287,7 @@ def debounce(seconds=1): @wraps(func) def wrapper(*args, **kwargs): # pylint: disable=missing-docstring - def call(): # pylint: disable=missing-docstring + def call(): func(*args, **kwargs) func.timer = None if func.timer: @@ -347,8 +347,9 @@ class SassWatcher(PatternMatchingEventHandler): paths.extend(glob.glob(dirname)) else: paths.append(dirname) - for dirname in paths: - observer.schedule(self, dirname, recursive=True) + + for obs_dirname in paths: + observer.schedule(self, obs_dirname, recursive=True) @debounce() def on_any_event(self, event): diff --git a/pavelib/i18n.py b/pavelib/i18n.py index 0ba968c5f7..560cc3fbfd 100644 --- a/pavelib/i18n.py +++ b/pavelib/i18n.py @@ -315,7 +315,7 @@ def find_release_resources(): if len(resources) == 2: return resources - if len(resources) == 0: + if not resources: raise ValueError("You need two release-* resources defined to use this command.") else: msg = "Strange Transifex config! Found these release-* resources:\n" + "\n".join(resources) diff --git a/pavelib/paver_tests/test_assets.py b/pavelib/paver_tests/test_assets.py index 0aa7463e31..5692ed97d0 100644 --- a/pavelib/paver_tests/test_assets.py +++ b/pavelib/paver_tests/test_assets.py @@ -14,7 +14,7 @@ from ..utils.envs import Env from .utils import PaverTestCase ROOT_PATH = path(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) -TEST_THEME_DIR = ROOT_PATH / "common/test/test-theme" # pylint: disable=invalid-name +TEST_THEME_DIR = ROOT_PATH / "common/test/test-theme" class TestPaverWatchAssetTasks(TestCase): @@ -162,7 +162,7 @@ class TestCollectAssets(PaverTestCase): for i, sys in enumerate(systems): msg = self.task_messages[i] self.assertTrue(msg.startswith('python manage.py {}'.format(sys))) - self.assertIn(' collectstatic '.format(Env.DEVSTACK_SETTINGS), msg) + self.assertIn(' collectstatic ', msg) self.assertIn('--settings={}'.format(Env.DEVSTACK_SETTINGS), msg) self.assertTrue(msg.endswith(' {}'.format(log_location))) diff --git a/pavelib/paver_tests/test_database.py b/pavelib/paver_tests/test_database.py index 5197f49720..0112f2d277 100644 --- a/pavelib/paver_tests/test_database.py +++ b/pavelib/paver_tests/test_database.py @@ -110,7 +110,7 @@ class TestPaverDatabaseTasks(MockS3Mixin, TestCase): fingerprint_file.write(self.expected_fingerprint) with patch.object(db_utils, 'get_file_from_s3', wraps=db_utils.get_file_from_s3) as _mock_get_file: - database.update_local_bokchoy_db_from_s3() + database.update_local_bokchoy_db_from_s3() # pylint: disable=no-value-for-parameter # Make sure that the local cache files are used - NOT downloaded from s3 self.assertFalse(_mock_get_file.called) calls = [ @@ -149,7 +149,7 @@ class TestPaverDatabaseTasks(MockS3Mixin, TestCase): fingerprint_file.write(local_fingerprint) with patch.object(db_utils, 'get_file_from_s3', wraps=db_utils.get_file_from_s3) as _mock_get_file: - database.update_local_bokchoy_db_from_s3() + database.update_local_bokchoy_db_from_s3() # pylint: disable=no-value-for-parameter # Make sure that the fingerprint file is downloaded from s3 _mock_get_file.assert_called_once_with( 'moto_test_bucket', self.fingerprint_filename, db_utils.CACHE_FOLDER @@ -181,7 +181,7 @@ class TestPaverDatabaseTasks(MockS3Mixin, TestCase): with open(db_utils.FINGERPRINT_FILEPATH, 'w') as fingerprint_file: fingerprint_file.write(local_fingerprint) - database.update_local_bokchoy_db_from_s3() + database.update_local_bokchoy_db_from_s3() # pylint: disable=no-value-for-parameter calls = [ call('{}/scripts/reset-test-db.sh --calculate_migrations'.format(Env.REPO_ROOT)), call('{}/scripts/reset-test-db.sh --rebuild_cache'.format(Env.REPO_ROOT)) @@ -208,5 +208,5 @@ class TestPaverDatabaseTasks(MockS3Mixin, TestCase): with open(db_utils.FINGERPRINT_FILEPATH, 'w') as fingerprint_file: fingerprint_file.write(local_fingerprint) - database.update_local_bokchoy_db_from_s3() + database.update_local_bokchoy_db_from_s3() # pylint: disable=no-value-for-parameter self.assertTrue(self.bucket.get_key(self.fingerprint_filename)) diff --git a/pavelib/paver_tests/test_paver_quality.py b/pavelib/paver_tests/test_paver_quality.py index d02fa3bdd1..7b2f33e81a 100644 --- a/pavelib/paver_tests/test_paver_quality.py +++ b/pavelib/paver_tests/test_paver_quality.py @@ -12,10 +12,10 @@ import paver.tasks from ddt import ddt, file_data, data, unpack from mock import MagicMock, mock_open, patch from path import Path as path -from paver.easy import BuildFailure +from paver.easy import BuildFailure # pylint: disable=ungrouped-imports import pavelib.quality -from pavelib.paver_tests.utils import fail_on_eslint, fail_on_pylint +from pavelib.paver_tests.utils import fail_on_eslint @ddt diff --git a/pavelib/paver_tests/test_servers.py b/pavelib/paver_tests/test_servers.py index 087f712f2b..a4fc9caa68 100644 --- a/pavelib/paver_tests/test_servers.py +++ b/pavelib/paver_tests/test_servers.py @@ -203,6 +203,7 @@ class TestPaverServerTasks(PaverTestCase): ] ) + # pylint: disable=too-many-statements def verify_server_task(self, task_name, options, contracts_default=False): """ Verify the output of a server task. @@ -247,7 +248,9 @@ class TestPaverServerTasks(PaverTestCase): expected_messages.append(u"xmodule_assets common/static/xmodule") expected_messages.append(u"install npm_assets") expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root)) - expected_messages.extend([c.format(settings=expected_asset_settings) for c in EXPECTED_PRINT_SETTINGS_COMMAND]) + expected_messages.extend( + [c.format(settings=expected_asset_settings) for c in EXPECTED_PRINT_SETTINGS_COMMAND] + ) expected_messages.append(EXPECTED_WEBPACK_COMMAND.format( node_env="production" if expected_asset_settings != Env.DEVSTACK_SETTINGS else "development", static_root_lms=None, @@ -291,7 +294,9 @@ class TestPaverServerTasks(PaverTestCase): expected_messages.append(u"xmodule_assets common/static/xmodule") expected_messages.append(u"install npm_assets") expected_messages.append(EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root)) - expected_messages.extend([c.format(settings=expected_asset_settings) for c in EXPECTED_PRINT_SETTINGS_COMMAND]) + expected_messages.extend( + [c.format(settings=expected_asset_settings) for c in EXPECTED_PRINT_SETTINGS_COMMAND] + ) expected_messages.append(EXPECTED_WEBPACK_COMMAND.format( node_env="production" if expected_asset_settings != Env.DEVSTACK_SETTINGS else "development", static_root_lms=None, diff --git a/pavelib/paver_tests/test_stylelint.py b/pavelib/paver_tests/test_stylelint.py index b7d853b246..6385134dbb 100644 --- a/pavelib/paver_tests/test_stylelint.py +++ b/pavelib/paver_tests/test_stylelint.py @@ -13,11 +13,6 @@ class TestPaverStylelint(PaverTestCase): """ Tests for Paver's Stylelint tasks. """ - - def setUp(self): - super(TestPaverStylelint, self).setUp() - pass - @ddt.data( [0, False], [99, False], diff --git a/pavelib/paver_tests/utils.py b/pavelib/paver_tests/utils.py index cce3af0404..ba5a837edf 100644 --- a/pavelib/paver_tests/utils.py +++ b/pavelib/paver_tests/utils.py @@ -63,7 +63,7 @@ class MockEnvironment(tasks.Environment): self.messages.append(unicode(output)) -def fail_on_eslint(*args, **kwargs): +def fail_on_eslint(*args): """ For our tests, we need the call for diff-quality running pep8 reports to fail, since that is what is going to fail when we pass in a percentage ("p") requirement. @@ -75,7 +75,7 @@ def fail_on_eslint(*args, **kwargs): return -def fail_on_pylint(*args, **kwargs): +def fail_on_pylint(*args): """ For our tests, we need the call for diff-quality running pep8 reports to fail, since that is what is going to fail when we pass in a percentage ("p") requirement. @@ -87,7 +87,7 @@ def fail_on_pylint(*args, **kwargs): return -def fail_on_npm_install(*args, **kwargs): +def fail_on_npm_install(*args): """ For our tests, we need the call for diff-quality running pep8 reports to fail, since that is what is going to fail when we pass in a percentage ("p") requirement. @@ -98,7 +98,7 @@ def fail_on_npm_install(*args, **kwargs): return -def unexpected_fail_on_npm_install(*args, **kwargs): +def unexpected_fail_on_npm_install(*args): """ For our tests, we need the call for diff-quality running pep8 reports to fail, since that is what is going to fail when we pass in a percentage ("p") requirement. diff --git a/pavelib/quality.py b/pavelib/quality.py index ef0a4ed1b8..5f5be1bfc7 100644 --- a/pavelib/quality.py +++ b/pavelib/quality.py @@ -134,7 +134,7 @@ def run_pylint(options): errors = getattr(options, 'errors', False) systems = getattr(options, 'system', ALL_SYSTEMS).split(',') - num_violations, violations_list = _get_pylint_violations(systems, errors) + num_violations, _ = _get_pylint_violations(systems, errors) # Print number of violations to log violations_count_str = "Number of pylint violations: " + str(num_violations) @@ -496,7 +496,7 @@ def run_xsslint(options): violations_limit=violation_thresholds['rules'][threshold_key], ) - if error_message is not "": + if error_message: raise BuildFailure( "FAILURE: XSSLinter Failed.\n{error_message}\n" "See {xsslint_report} or run the following command to hone in on the problem:\n" @@ -604,7 +604,8 @@ def _get_count_from_last_line(filename, file_type): It is returning only the value (as a floating number). """ last_line = _get_report_contents(filename, last_line_only=True).strip() - if file_type is "python_complexity": + + if file_type == "python_complexity": # Example of the last line of a complexity report: "Average complexity: A (1.93953443446)" regex = r'\d+.\d+' else: @@ -687,6 +688,7 @@ def _get_xsscommitlint_count(filename): ("limit=", "l", "Limits for number of acceptable violations - either or :"), ]) @timed +# pylint: disable=too-many-statements def run_quality(options): """ Build the html diff quality reports, and print the reports to the console. diff --git a/pavelib/tests.py b/pavelib/tests.py index cd0b38f9c3..e7396b382d 100644 --- a/pavelib/tests.py +++ b/pavelib/tests.py @@ -80,8 +80,8 @@ def test_system(options, passthrough_options): test_id = getattr(options, 'test_id', None) django_version = getattr(options, 'django_version', None) - assert(system in (None, 'lms', 'cms')) - assert(django_version in (None, '1.8', '1.9', '1.10', '1.11')) + assert system in (None, 'lms', 'cms') + assert django_version in (None, '1.8', '1.9', '1.10', '1.11') if test_id: # Testing a single test ID. @@ -159,7 +159,7 @@ def test_lib(options, passthrough_options): test_id = getattr(options, 'test_id', lib) django_version = getattr(options, 'django_version', None) - assert(django_version in (None, '1.8', '1.9', '1.10', '1.11')) + assert django_version in (None, '1.8', '1.9', '1.10', '1.11') if test_id: # Testing a single test id. diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index cb968388d6..fc4b9172a9 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -225,7 +225,7 @@ class Env(object): SERVICE_VARIANT = 'lms' @classmethod - def get_django_setting(self, django_setting, system, settings=None): + def get_django_setting(cls, django_setting, system, settings=None): """ Interrogate Django environment for specific settings values :param django_setting: the django setting to get diff --git a/pavelib/utils/process.py b/pavelib/utils/process.py index 9b88783a7e..5cc8254b29 100644 --- a/pavelib/utils/process.py +++ b/pavelib/utils/process.py @@ -18,8 +18,6 @@ def kill_process(proc): Kill the process `proc` created with `subprocess`. """ p1_group = psutil.Process(proc.pid) - - # pylint: disable=unexpected-keyword-arg child_pids = p1_group.get_children(recursive=True) for child_pid in child_pids: @@ -112,8 +110,6 @@ def run_background_process(cmd, out_log=None, err_log=None, cwd=None): killed properly. """ p1_group = psutil.Process(proc.pid) - - # pylint: disable=unexpected-keyword-arg child_pids = p1_group.get_children(recursive=True) for child_pid in child_pids: diff --git a/pavelib/utils/test/suites/acceptance_suite.py b/pavelib/utils/test/suites/acceptance_suite.py index 0914cd9d47..9532df33b5 100644 --- a/pavelib/utils/test/suites/acceptance_suite.py +++ b/pavelib/utils/test/suites/acceptance_suite.py @@ -37,22 +37,22 @@ def setup_acceptance_db(): definitions to sync and migrate. """ - for db in DBS.keys(): + for db in DBS: if DBS[db].isfile(): # Since we are using SQLLite, we can reset the database by deleting it on disk. DBS[db].remove() settings = 'acceptance_docker' if Env.USING_DOCKER else 'acceptance' - if all(DB_CACHES[cache].isfile() for cache in DB_CACHES.keys()): + if all(DB_CACHES[cache].isfile() for cache in DB_CACHES): # To speed up migrations, we check for a cached database file and start from that. # The cached database file should be checked into the repo # Copy the cached database to the test root directory - for db_alias in DBS.keys(): + for db_alias in DBS: sh("cp {db_cache} {db}".format(db_cache=DB_CACHES[db_alias], db=DBS[db_alias])) # Run migrations to update the db, starting from its cached state - for db_alias in sorted(DBS.keys()): + for db_alias in sorted(DBS): # pylint: disable=line-too-long sh("./manage.py lms --settings {} migrate --traceback --noinput --fake-initial --database {}".format(settings, db_alias)) sh("./manage.py cms --settings {} migrate --traceback --noinput --fake-initial --database {}".format(settings, db_alias)) diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index 8e13b78490..6a750baac8 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -1,6 +1,7 @@ """ Class used for defining and running Bok Choy acceptance test suite """ +import os from time import sleep from textwrap import dedent @@ -23,8 +24,6 @@ from pavelib.utils.test import utils as test_utils from pavelib.utils.timer import timed from pavelib.database import update_local_bokchoy_db_from_s3 -import os - try: from pygments.console import colorize except ImportError: @@ -142,7 +141,7 @@ def reset_test_database(): If not, reset the test database and apply migrations """ if os.environ.get('USER', None) == 'jenkins': - update_local_bokchoy_db_from_s3() + update_local_bokchoy_db_from_s3() # pylint: disable=no-value-for-parameter else: sh("{}/scripts/reset-test-db.sh --migrations".format(Env.REPO_ROOT)) @@ -238,7 +237,7 @@ class BokChoyTestSuite(TestSuite): check_services() if not self.testsonly: - call_task('prepare_bokchoy_run', options={'log_dir': self.log_dir}) # pylint: disable=no-value-for-parameter + call_task('prepare_bokchoy_run', options={'log_dir': self.log_dir}) else: # load data in db_fixtures load_bok_choy_data() # pylint: disable=no-value-for-parameter diff --git a/pavelib/utils/test/suites/pytest_suite.py b/pavelib/utils/test/suites/pytest_suite.py index d891be7647..1fd30213d8 100644 --- a/pavelib/utils/test/suites/pytest_suite.py +++ b/pavelib/utils/test/suites/pytest_suite.py @@ -7,10 +7,6 @@ from pavelib.utils.test import utils as test_utils from pavelib.utils.test.suites.suite import TestSuite from pavelib.utils.envs import Env -try: - from pygments.console import colorize -except ImportError: - colorize = lambda color, text: text __test__ = False # do not collect @@ -123,12 +119,8 @@ class SystemTestSuite(PytestSuite): self.processes = int(self.processes) - def __enter__(self): - super(SystemTestSuite, self).__enter__() - @property def cmd(self): - if self.django_toxenv: cmd = ['tox', '-e', self.django_toxenv, '--'] else: diff --git a/pavelib/utils/test/suites/suite.py b/pavelib/utils/test/suites/suite.py index 74ee8f708a..91a59eeb41 100644 --- a/pavelib/utils/test/suites/suite.py +++ b/pavelib/utils/test/suites/suite.py @@ -114,14 +114,14 @@ class TestSuite(object): for suite in self.subsuites: suite.run_suite_tests() - if len(suite.failed_suites) > 0: + if suite.failed_suites: self.failed_suites.extend(suite.failed_suites) def report_test_results(self): """ Writes a list of failed_suites to sys.stderr """ - if len(self.failed_suites) > 0: + if self.failed_suites: msg = colorize('red', "\n\n{bar}\nTests failed in the following suites:\n* ".format(bar="=" * 48)) msg += colorize('red', '\n* '.join([s.root for s in self.failed_suites]) + '\n\n') else: @@ -140,5 +140,5 @@ class TestSuite(object): self.report_test_results() - if len(self.failed_suites) > 0: + if self.failed_suites: sys.exit(1) diff --git a/pavelib/utils/timer.py b/pavelib/utils/timer.py index 6115e9c20d..08e21acc1e 100644 --- a/pavelib/utils/timer.py +++ b/pavelib/utils/timer.py @@ -37,7 +37,7 @@ def timed(wrapped, instance, args, kwargs): # pylint: disable=unused-argument exception_info = {} try: return wrapped(*args, **kwargs) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: exception_info = { 'exception': "".join(traceback.format_exception_only(type(exc), exc)).strip() }