diff --git a/common/djangoapps/edxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py index e5a2c639eb..64838852a4 100644 --- a/common/djangoapps/edxmako/shortcuts.py +++ b/common/djangoapps/edxmako/shortcuts.py @@ -15,16 +15,17 @@ from __future__ import absolute_import import logging -import six -from six.moves.urllib.parse import urljoin +import six from django.conf import settings -from django.urls import reverse from django.http import HttpResponse from django.template import engines +from django.urls import reverse +from six.moves.urllib.parse import urljoin from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site +from xmodule.util.xmodule_django import get_current_request_hostname from . import Engines @@ -70,9 +71,13 @@ def marketing_link(name): elif not enable_mktg_site and name in link_map: # don't try to reverse disabled marketing links if link_map[name] is not None: - return reverse(link_map[name]) + host_name = get_current_request_hostname() + if all([host_name and 'edge' in host_name, 'http' in link_map[name]]): + return link_map[name] + else: + return reverse(link_map[name]) else: - log.debug("Cannot find corresponding link for name: %s", name) + log.debug(u"Cannot find corresponding link for name: %s", name) return '#' diff --git a/common/djangoapps/student/management/commands/export_staff_users.py b/common/djangoapps/student/management/commands/export_staff_users.py new file mode 100644 index 0000000000..964f1dc712 --- /dev/null +++ b/common/djangoapps/student/management/commands/export_staff_users.py @@ -0,0 +1,134 @@ +from __future__ import absolute_import, print_function + +import csv +import logging +from datetime import datetime, timedelta +from django.core.management.base import BaseCommand +from django.conf import settings +from django.core.mail.message import EmailMultiAlternatives +from django.template.loader import get_template +from pytz import utc +from os import remove + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from student.models import CourseAccessRole + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Example usage: + $ ./manage.py lms export_staff_users -d 7 --settings=devstack_docker + $ ./manage.py lms export_staff_users --days 7 --settings=devstack_docker + $ ./manage.py lms export_staff_users --days 7 --dry true --settings=devstack_docker + """ + + help = """ + This command will export a csv of all users who have logged in within the given days and + have staff access role in active courses (Courses with end date in the future). + """ + + def add_arguments(self, parser): + parser.add_argument( + '-d', + '--days', + type=int, + default=7, + help='Indicate the login time period in days starting from today' + ) + parser.add_argument( + '-r', + '--dry', + type=str, + help='Indicate that the email should not be sent to author-support' + ) + + subject = 'Staff users CSV' + to_addresses = ['author-support@edx.org'] + from_address = settings.DEFAULT_FROM_EMAIL + txt_template_path = 'email/export_staff_users.txt' + html_template_path = 'email/export_staff_users.html' + csv_filename = 'staff_users.csv' + + def write_csv(self, query_set, filename): + """ + Writes the queryset into a csv file with the given filename + + Arguments: + query_set: query_set to be converted + filename: filename for the csv + """ + writer = csv.DictWriter( + filename, + fieldnames=['id', 'user__username', 'user__email', 'role'] + ) + writer.writeheader() + for data_item in query_set: + writer.writerow(data_item) + + def handle(self, *args, **kwargs): + days = kwargs['days'] + dry = kwargs.get('dry') + if dry: + self.to_addresses = ['sustaining-mavericks@edx.org'] + current_date = datetime.now(tz=utc) + starting_date = current_date - timedelta(days=days) + active_courses = CourseOverview.objects.filter(end__gte=current_date).values_list('id', flat=True) + course_access_roles = CourseAccessRole.objects.filter( + role__in=['staff', 'instructor'], + user__last_login__range=(starting_date, current_date), + course_id__in=active_courses, + user__is_staff=False + ).values('id', 'user__username', 'user__email', 'role') + if not course_access_roles: + return + with open(self.csv_filename, 'a+') as csv_file: + self.write_csv( + query_set=course_access_roles, + filename=csv_file + ) + context = {'time_period': days} + try: + self.send_email(context) + logger.info( + 'Sent staff users email for the period {} to {}. Staff users count:{}'.format( + starting_date, + current_date, + course_access_roles.count() + ) + ) + except Exception: + logger.exception( + 'Failed to send staff users email for the period {}-{}'.format(starting_date, current_date) + ) + + def send_email(self, context): + """ + Sends an email to admin containing a csv of all users who have logged in within the given days and + have staff access role in active courses (Courses with end date in the future). + + Arguments: + context: context for the email template + """ + plain_content = self.render_template(self.txt_template_path, context) + html_content = self.render_template(self.html_template_path, context) + + with open(self.csv_filename, 'r') as csv_file: + email_message = EmailMultiAlternatives(self.subject, plain_content, self.from_address, to=self.to_addresses) + email_message.attach_alternative(html_content, 'text/html') + email_message.attach(self.csv_filename, csv_file.read(), 'text/csv') + email_message.send() + + remove(self.csv_filename) + + def render_template(self, path, context): + """ + Takes a template path and context and returns a rendered template + + Arguments: + path: path of the file + context: context for the template + """ + txt_template = get_template(path) + return txt_template.render(context) diff --git a/common/djangoapps/student/management/tests/test_export_staff_users.py b/common/djangoapps/student/management/tests/test_export_staff_users.py new file mode 100644 index 0000000000..9afc3f667c --- /dev/null +++ b/common/djangoapps/student/management/tests/test_export_staff_users.py @@ -0,0 +1,35 @@ +""" +Unit tests for export_staff_users management command. +""" +from datetime import timedelta + +from django.core import mail +from django.core.management import call_command +from django.test import TestCase +from django.utils.timezone import now + +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from student.tests.factories import CourseAccessRoleFactory, UserFactory + + +class TestExportStaffUsers(TestCase): + """ + Tests the `export_staff_users` command. + """ + + @staticmethod + def create_users_data(): + staff_user = UserFactory(last_login=now() - timedelta(days=5)) + instructor_user = UserFactory(last_login=now() - timedelta(days=5)) + course = CourseOverviewFactory(end=now() + timedelta(days=30)) + archived_course = CourseOverviewFactory(end=now() - timedelta(days=30)) + course_ids = [course.id, archived_course.id] + for course_id in course_ids: + CourseAccessRoleFactory.create(course_id=course_id, user=staff_user, role="staff") + CourseAccessRoleFactory.create(course_id=course_id, user=instructor_user, role="instructor") + + def test_export_staff_users(self): + self.create_users_data() + self.assertEqual(len(mail.outbox), 0) + call_command('export_staff_users', days=7) + self.assertEqual(len(mail.outbox), 1) diff --git a/common/djangoapps/student/templates/email/email_base.html b/common/djangoapps/student/templates/email/email_base.html new file mode 100644 index 0000000000..0cf1739f84 --- /dev/null +++ b/common/djangoapps/student/templates/email/email_base.html @@ -0,0 +1,21 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + + + + + + + +
+ {% block body %} + {% endblock body %} +
+ + diff --git a/common/djangoapps/student/templates/email/export_staff_users.html b/common/djangoapps/student/templates/email/export_staff_users.html new file mode 100644 index 0000000000..c54f5a52b7 --- /dev/null +++ b/common/djangoapps/student/templates/email/export_staff_users.html @@ -0,0 +1,15 @@ +{% extends "email/email_base.html" %} +{% block body %} + +

+ Dear Admin, +

+

+ Please find the attached CSV containing a list of all staff users + who have logged in within the last {{ time_period }} days +

+ +

Thanks,

+

The edX Team

+ +{% endblock body %} diff --git a/common/djangoapps/student/templates/email/export_staff_users.txt b/common/djangoapps/student/templates/email/export_staff_users.txt new file mode 100644 index 0000000000..cda5918ceb --- /dev/null +++ b/common/djangoapps/student/templates/email/export_staff_users.txt @@ -0,0 +1,7 @@ +Dear Admin, + + Please find the attached CSV containing a list of all staff users who have logged in within the last {{ time_period }} days + + +Thanks, +The edX Team diff --git a/common/djangoapps/student/tests/test_activate_account.py b/common/djangoapps/student/tests/test_activate_account.py index 7db4e674ac..53953b5cfb 100644 --- a/common/djangoapps/student/tests/test_activate_account.py +++ b/common/djangoapps/student/tests/test_activate_account.py @@ -5,6 +5,7 @@ import unittest from uuid import uuid4 from django.conf import settings +from django.contrib.auth.models import User from django.test import TestCase, override_settings from django.urls import reverse from mock import patch @@ -103,6 +104,10 @@ class TestActivateAccount(TestCase): response = self.client.get(reverse('dashboard')) self.assertNotContains(response, expected_message) + def _assert_user_active_state(self, expected_active_state): + user = User.objects.get(username=self.user.username) + self.assertEqual(user.is_active, expected_active_state) + def test_account_activation_notification_on_logistration(self): """ Verify that logistration page displays success/error/info messages @@ -112,15 +117,19 @@ class TestActivateAccount(TestCase): login_url=reverse('signin_user'), redirect_url=reverse('dashboard'), ) + self._assert_user_active_state(expected_active_state=False) + # Access activation link, message should say that account has been activated. response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True) self.assertRedirects(response, login_page_url) self.assertContains(response, 'Success! You have activated your account.') + self._assert_user_active_state(expected_active_state=True) # Access activation link again, message should say that account is already active. response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True) self.assertRedirects(response, login_page_url) self.assertContains(response, 'This account has already been activated.') + self._assert_user_active_state(expected_active_state=True) # Open account activation page with an invalid activation link, # there should be an error message displayed. @@ -137,4 +146,4 @@ class TestActivateAccount(TestCase): response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True) self.assertRedirects(response, login_page_url) self.assertContains(response, SYSTEM_MAINTENANCE_MSG) - assert not self.user.is_active + self._assert_user_active_state(expected_active_state=False) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 3f956d2a7c..dec1487604 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -12,30 +12,19 @@ from datetime import datetime, timedelta import ddt import six from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing +from course_modes.models import CourseMode from django.conf import settings -from django.test import RequestFactory, TestCase +from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse from django.utils.timezone import now +from entitlements.tests.factories import CourseEntitlementFactory from milestones.tests.utils import MilestonesTestCaseMixin from mock import patch from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from pyquery import PyQuery as pq from six.moves import range - -from course_modes.models import CourseMode -from entitlements.tests.factories import CourseEntitlementFactory -from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG -from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context -from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data -from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag -from openedx.features.course_duration_limits.models import CourseDurationLimitConfig -from openedx.features.course_experience.tests.views.helpers import add_course_mode from student.helpers import DISABLE_UNENROLL_CERT_STATES from student.models import CourseEnrollment, UserProfile from student.signals import REFUND_ORDER @@ -46,6 +35,17 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG +from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag +from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from openedx.features.course_experience.tests.views.helpers import add_course_mode + PASSWORD = 'test' @@ -222,6 +222,33 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, response = self.client.get(self.path) self.assertRedirects(response, reverse('account_settings')) + def test_grade_doesnt_appears_before_course_end_date(self): + """ + Verify that learners are not able to see their final grade before the end + of course in the learner dashboard + """ + self.course = CourseFactory.create(end=self.TOMORROW, emit_signals=True) + self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) + GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45) + + response = self.client.get(reverse('dashboard')) + # The final grade does not appear before the course has ended + self.assertNotContains(response, 'Your final grade:') + self.assertNotContains(response, '45%') + + def test_grade_appears_after_course_has_ended(self): + """ + Verify that learners are able to see their final grade of the course in + the learner dashboard after the course had ended + """ + self.course = CourseFactory.create(end=self.THREE_YEARS_AGO, emit_signals=True) + self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) + GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45) + + response = self.client.get(reverse('dashboard')) + self.assertContains(response, 'Your final grade:') + self.assertContains(response, '45%') + @patch.multiple('django.conf.settings', **MOCK_SETTINGS) @ddt.data( *itertools.product( diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index 1ba569308f..f124535554 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -18,7 +18,6 @@ urlpatterns = [ 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$', views.change_setting, name='change_setting'), url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'), url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN), diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 131a614b1b..bd94854b20 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -468,24 +468,6 @@ def disable_account_ajax(request): 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 """ diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index b4a683e9b5..c3a17d642f 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -209,6 +209,11 @@ def get(request): """Gets the running pipeline's data from the passed request.""" strategy = social_django.utils.load_strategy(request) token = strategy.session_get('partial_pipeline_token') + + if not token: + strategy.session_set('partial_pipeline_token', strategy.session_get('partial_pipeline_token_')) + token = strategy.session_get('partial_pipeline_token') + partial_object = strategy.partial_load(token) pipeline_data = None if partial_object: @@ -560,6 +565,10 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia return (current_provider and current_provider.slug in [saml_provider.slug for saml_provider in saml_providers_list]) + if current_partial: + strategy.session_set('partial_pipeline_token_', current_partial.token) + strategy.storage.partial.store(current_partial) + if not user: # Use only email for user existence check in case of saml provider if is_provider_saml(): diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 08dfc6d175..c2af79b603 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -123,18 +123,17 @@ class HelperMixin(object): def assert_json_failure_response_is_inactive_account(self, response): """Asserts failure on /login for inactive account looks right.""" - self.assertEqual(200, response.status_code) # Yes, it's a 200 even though it's a failure. + self.assertEqual(400, response.status_code) payload = json.loads(response.content.decode('utf-8')) self.assertFalse(payload.get('success')) self.assertIn('In order to sign in, you need to activate your account.', payload.get('value')) def assert_json_failure_response_is_missing_social_auth(self, response): """Asserts failure on /login for missing social auth looks right.""" - self.assertContains( - response, - u"successfully signed in to your %s account, but this account isn't linked" % self.provider.name, - status_code=403, - ) + self.assertEqual(403, response.status_code) + payload = json.loads(response.content.decode('utf-8')) + self.assertFalse(payload.get('success')) + self.assertEqual(payload.get('error_code'), 'third-party-auth-with-no-linked-account') def assert_json_failure_response_is_username_collision(self, response): """Asserts the json response indicates a username collision.""" @@ -542,6 +541,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) request.user = self.create_user_models_for_existing_account( strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) + partial_pipeline_token = strategy.session_get('partial_pipeline_token') + partial_data = strategy.storage.partial.load(partial_pipeline_token) # Instrument the pipeline to get to the dashboard with the full # expected state. @@ -561,24 +562,14 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # We should be redirected back to the complete page, setting # the "logged in" cookie for the marketing site. - self.assert_logged_in_cookie_redirect(actions.do_complete( - request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request - )) + self.assert_logged_in_cookie_redirect(self.do_complete(strategy, request, partial_pipeline_token, partial_data)) # Set the cookie and try again self.set_logged_in_cookies(request) # Fire off the auth pipeline to link. self.assert_redirect_after_pipeline_completes( - actions.do_complete( - request.backend, - social_views._do_login, # pylint: disable=protected-access - request.user, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - request=request - ) + self.do_complete(strategy, request, partial_pipeline_token, partial_data) ) # Now we expect to be in the linked state, with a backend entry. @@ -694,6 +685,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( strategy, 'user@example.com', 'password', self.get_username()) + partial_pipeline_token = strategy.session_get('partial_pipeline_token') + partial_data = strategy.storage.partial.load(partial_pipeline_token) + self.assert_social_auth_exists_for_user(user, strategy) self.assertTrue(user.is_active) @@ -734,7 +728,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): self.set_logged_in_cookies(request) self.assert_redirect_after_pipeline_completes( - actions.do_complete(request.backend, social_views._do_login, user=user, request=request)) + self.do_complete(strategy, request, partial_pipeline_token, partial_data, user) + ) self.assert_account_settings_context_looks_correct(account_settings_context(request)) def test_signin_fails_if_account_not_active(self): @@ -793,6 +788,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): request, strategy = self.get_request_and_strategy( auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) + partial_pipeline_token = strategy.session_get('partial_pipeline_token') + partial_data = strategy.storage.partial.load(partial_pipeline_token) # Begin! Grab the registration page and check the login control on it. self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register')) @@ -846,15 +843,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # We should be redirected back to the complete page, setting # the "logged in" cookie for the marketing site. - self.assert_logged_in_cookie_redirect(actions.do_complete( - request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request - )) + self.assert_logged_in_cookie_redirect(self.do_complete(strategy, request, partial_pipeline_token, partial_data)) # Set the cookie and try again self.set_logged_in_cookies(request) self.assert_redirect_after_pipeline_completes( - actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user, request=request)) + self.do_complete(strategy, request, partial_pipeline_token, partial_data, created_user) + ) # Now the user has been redirected to the dashboard. Their third party account should now be linked. self.assert_social_auth_exists_for_user(created_user, strategy) self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) @@ -974,6 +969,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): """ raise NotImplementedError + def do_complete(self, strategy, request, partial_pipeline_token, partial_data, user=None): + """ + Makes sure that strategy store includes the partial data object before + calling actions.do_complete + """ + strategy.storage.partial.store(partial_data) + if not user: + user = request.user + return actions.do_complete( + request.backend, social_views._do_login, user, None, # pylint: disable=protected-access + redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request, partial_token=partial_pipeline_token + ) + # pylint: disable=abstract-method @django_utils.override_settings(ECOMMERCE_API_URL=TEST_API_URL) diff --git a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py index ac4f47d895..091471657d 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py @@ -4,6 +4,15 @@ from __future__ import absolute_import from third_party_auth.tests.specs import base +def get_localized_name(name): + """Returns the localizedName from the name object""" + locale = "{}_{}".format( + name["preferredLocale"]["language"], + name["preferredLocale"]["country"] + ) + return name['localized'].get(locale, '') + + class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest): """Integration tests for provider.LinkedInOauth2.""" @@ -21,11 +30,29 @@ class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest): 'expires_in': 'expires_in_value', } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', + 'lastName': { + "localized": { + "en_US": "Doe" + }, + "preferredLocale": { + "country": "US", + "language": "en" + } + }, 'id': 'id_value', - 'firstName': 'firstName_value', + 'firstName': { + "localized": { + "en_US": "Doe" + }, + "preferredLocale": { + "country": "US", + "language": "en" + } + }, } def get_username(self): response_data = self.get_response_data() - return response_data.get('firstName') + response_data.get('lastName') + first_name = get_localized_name(response_data.get('firstName')) + last_name = get_localized_name(response_data.get('lastName')) + return first_name + last_name diff --git a/common/djangoapps/third_party_auth/tests/utils.py b/common/djangoapps/third_party_auth/tests/utils.py index 8fe9ee136a..47b8bb5173 100644 --- a/common/djangoapps/third_party_auth/tests/utils.py +++ b/common/djangoapps/third_party_auth/tests/utils.py @@ -98,7 +98,7 @@ class ThirdPartyOAuthTestMixinFacebook(object): class ThirdPartyOAuthTestMixinGoogle(object): """Tests oauth with the Google backend""" BACKEND = "google-oauth2" - USER_URL = "https://www.googleapis.com/plus/v1/people/me" + USER_URL = "https://www.googleapis.com/oauth2/v3/userinfo" # In google-oauth2 responses, the "email" field is used as the user's identifier UID_FIELD = "email" diff --git a/common/djangoapps/util/course.py b/common/djangoapps/util/course.py index 1ef703d424..1c738746fb 100644 --- a/common/djangoapps/util/course.py +++ b/common/djangoapps/util/course.py @@ -10,6 +10,7 @@ import six.moves.urllib.error import six.moves.urllib.parse import six.moves.urllib.request from django.conf import settings +from django.utils.timezone import now from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -73,3 +74,9 @@ def has_certificates_enabled(course): if not settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): return False return course.cert_html_view_enabled + + +def should_display_grade(end_date): + if end_date and end_date < now().replace(hour=0, minute=0, second=0, microsecond=0): + return True + return False diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 60b7ffbade..aa6f9941e1 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/lms/djangoapps/bulk_email/tests/test_views.py b/lms/djangoapps/bulk_email/tests/test_views.py new file mode 100644 index 0000000000..54151b8b98 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_views.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +Test the bulk email opt out view. +""" +from six import text_type + +import ddt +from django.http import Http404 +from django.test.client import RequestFactory +from django.test.utils import override_settings +from django.urls import reverse + +from bulk_email.models import Optout +from bulk_email.views import opt_out_email_updates +from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher +from openedx.core.lib.tests import attr +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +@attr(shard=1) +@ddt.ddt +@override_settings(SECRET_KEY="test secret key") +class OptOutEmailUpdatesViewTest(ModuleStoreTestCase): + """ + Check the opt out email functionality. + """ + def setUp(self): + super(OptOutEmailUpdatesViewTest, self).setUp() + self.user = UserFactory.create(username="testuser1") + self.token = UsernameCipher.encrypt('testuser1') + self.request_factory = RequestFactory() + self.course = CourseFactory.create(run='testcourse1', display_name='Test Course Title') + self.url = reverse('bulk_email_opt_out', args=[self.token, text_type(self.course.id)]) + + # Ensure we start with no opt-out records + self.assertEqual(Optout.objects.count(), 0) + + def test_opt_out_email_confirm(self): + """ + Ensure that the default GET view asks for confirmation. + """ + response = self.client.get(self.url) + self.assertContains(response, "Do you want to unsubscribe from emails for Test Course Title?") + self.assertEqual(Optout.objects.count(), 0) + + def test_opt_out_email_unsubscribe(self): + """ + Ensure that the POSTing "confirm" creates the opt-out record. + """ + response = self.client.post(self.url, {'submit': 'confirm'}) + self.assertContains(response, "You have been unsubscribed from emails for Test Course Title.") + self.assertEqual(Optout.objects.count(), 1) + + def test_opt_out_email_cancel(self): + """ + Ensure that the POSTing "cancel" does not create the opt-out record + """ + response = self.client.post(self.url, {'submit': 'cancel'}) + self.assertContains(response, "You have not been unsubscribed from emails for Test Course Title.") + self.assertEqual(Optout.objects.count(), 0) + + @ddt.data( + ("ZOMG INVALID BASE64 CHARS!!!", "base64url", False), + ("Non-ASCII\xff", "base64url", False), + ("D6L8Q01ztywqnr3coMOlq0C3DG05686lXX_1ArEd0ok", "base64url", False), + ("AAAAAAAAAAA=", "initialization_vector", False), + ("nMXVK7PdSlKPOovci-M7iqS09Ux8VoCNDJixLBmj", "aes", False), + ("AAAAAAAAAAAAAAAAAAAAAMoazRI7ePLjEWXN1N7keLw=", "padding", False), + ("AAAAAAAAAAAAAAAAAAAAACpyUxTGIrUjnpuUsNi7mAY=", "username", False), + ("_KHGdCAUIToc4iaRGy7K57mNZiiXxO61qfKT08ExlY8=", "course", 'course-v1:testcourse'), + ) + @ddt.unpack + def test_unsubscribe_invalid_token(self, token, message, course): + """ + Make sure that view returns 404 in case token is not valid + """ + request = self.request_factory.get("dummy") + with self.assertRaises(Http404) as err: + opt_out_email_updates(request, token, course) + self.assertIn(message, err) diff --git a/lms/djangoapps/bulk_email/urls.py b/lms/djangoapps/bulk_email/urls.py new file mode 100644 index 0000000000..9beea793e1 --- /dev/null +++ b/lms/djangoapps/bulk_email/urls.py @@ -0,0 +1,18 @@ +""" +URLs for bulk_email app +""" + +from django.conf import settings +from django.conf.urls import url + +from bulk_email import views + +urlpatterns = [ + url( + r'^email/optout/(?P[a-zA-Z0-9-_=]+)/{}/$'.format( + settings.COURSE_ID_PATTERN, + ), + views.opt_out_email_updates, + name='bulk_email_opt_out', + ), +] diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py new file mode 100644 index 0000000000..5e59d63c7a --- /dev/null +++ b/lms/djangoapps/bulk_email/views.py @@ -0,0 +1,72 @@ +""" +Views to support bulk email functionalities like opt-out. +""" + +from __future__ import division + +import logging + +from six import text_type + +from django.contrib.auth.models import User +from django.http import Http404 + +from bulk_email.models import Optout +from courseware.courses import get_course_by_id +from edxmako.shortcuts import render_to_response +from lms.djangoapps.discussion.notification_prefs.views import ( + UsernameCipher, + UsernameDecryptionException, +) + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + + +log = logging.getLogger(__name__) + + +def opt_out_email_updates(request, token, course_id): + """ + A view that let users opt out of any email updates. + + This meant is meant to be the target of an opt-out link or button. + The `token` parameter must decrypt to a valid username. + The `course_id` is the string course key of any course. + + Raises a 404 if there are any errors parsing the input. + """ + try: + username = UsernameCipher().decrypt(token.encode()) + user = User.objects.get(username=username) + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key, depth=0) + except UnicodeDecodeError: + raise Http404("base64url") + except UsernameDecryptionException as exn: + raise Http404(text_type(exn)) + except User.DoesNotExist: + raise Http404("username") + except InvalidKeyError: + raise Http404("course") + + context = { + 'course': course, + 'cancelled': False, + 'confirmed': False, + } + + if request.method == 'POST': + if request.POST.get('submit') == 'confirm': + Optout.objects.get_or_create(user=user, course_id=course.id) + log.info( + u"User %s (%s) opted out of receiving emails from course %s", + user.username, + user.email, + course_id, + ) + context['confirmed'] = True + else: + context['cancelled'] = True + + return render_to_response('bulk_email/unsubscribe.html', context) diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index 6a14f60290..e3c159cfe7 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -158,6 +158,31 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT content('#field-course-organization b').contents()[0].strip() ) + @ddt.data(True, False) + def test_membership_reason_field_visibility(self, enbale_reason_field): + """ + Verify that reason field is enabled by site configuration flag 'ENABLE_MANUAL_ENROLLMENT_REASON_FIELD' + """ + + configuration_values = { + "ENABLE_MANUAL_ENROLLMENT_REASON_FIELD": enbale_reason_field + } + site = Site.objects.first() + SiteConfiguration.objects.create(site=site, values=configuration_values, enabled=True) + + url = reverse( + 'instructor_dashboard', + kwargs={ + 'course_id': six.text_type(self.course_info.id) + } + ) + response = self.client.get(url) + reason_field = '' # pylint: disable=line-too-long + if enbale_reason_field: + self.assertContains(response, reason_field) + else: + self.assertNotContains(response, reason_field) + def test_membership_site_configuration_role(self): """ Verify that the role choices set via site configuration are loaded in the membership tab diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1a8aed27ad..3cf7e7e1b4 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1317,6 +1317,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'id', 'username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'enrollment_mode', 'verification_status', + 'last_login', 'date_joined', ] # Provide human-friendly and translatable names for these features. These names @@ -1336,6 +1337,8 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'goals': _('Goals'), 'enrollment_mode': _('Enrollment Mode'), 'verification_status': _('Verification Status'), + 'last_login': _('Last Login'), + 'date_joined': _('Date Joined'), } if is_course_cohorted(course.id): diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 37b3df7d9c..cbe1a38d3e 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -550,7 +550,8 @@ def _section_membership(course, access): 'update_forum_role_membership', kwargs={'course_id': six.text_type(course_key)} ), - 'enrollment_role_choices': enrollment_role_choices + 'enrollment_role_choices': enrollment_role_choices, + 'is_reason_field_enabled': configuration_helpers.get_value('ENABLE_MANUAL_ENROLLMENT_REASON_FIELD', False) } return section_data diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index d45d792446..9f48160918 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -39,7 +39,8 @@ from student.models import CourseEnrollment, CourseEnrollmentAllowed log = logging.getLogger(__name__) -STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email') +STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email', + 'date_joined', 'last_login') PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'meta', 'city', 'country') diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index 81b0ec64cc..971d2d689c 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -7,7 +7,9 @@ from __future__ import absolute_import from django.contrib import admin -from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import ( + ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, + SSPVerificationRetryConfig) @admin.register(SoftwareSecurePhotoVerification) @@ -39,3 +41,11 @@ class ManualVerificationAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'status', 'reason', 'created_at', 'updated_at',) raw_id_fields = ('user',) search_fields = ('user__username', 'reason',) + + +@admin.register(SSPVerificationRetryConfig) +class SSPVerificationRetryAdmin(admin.ModelAdmin): + """ + Admin for the SSPVerificationRetryConfig table. + """ + pass diff --git a/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py b/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py index fd4c3cfbe0..3979e5d850 100644 --- a/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py @@ -3,9 +3,13 @@ Django admin commands related to verify_student """ from __future__ import absolute_import, print_function +import logging from django.core.management.base import BaseCommand +from django.core.management.base import CommandError -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig + +log = logging.getLogger('retry_photo_verification') class Command(BaseCommand): @@ -20,24 +24,59 @@ class Command(BaseCommand): "are in a state of 'must_retry'" ) + def add_arguments(self, parser): + + parser.add_argument( + '--verification-ids', + dest='verification_ids', + action='store', + nargs='+', + type=int, + help='verifications id used to retry verification' + ) + + parser.add_argument( + '--args-from-database', + action='store_true', + help='Use arguments from the SSPVerificationRetryConfig model instead of the command line.', + ) + + def get_args_from_database(self): + """ Returns an options dictionary from the current SSPVerificationRetryConfig model. """ + + sspv_retry_config = SSPVerificationRetryConfig.current() + if not sspv_retry_config.enabled: + raise CommandError('SSPVerificationRetryConfig is disabled, but --args-from-database was requested.') + + # We don't need fancy shell-style whitespace/quote handling - none of our arguments are complicated + argv = sspv_retry_config.arguments.split() + + parser = self.create_parser('manage.py', 'sspv_retry') + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object + def handle(self, *args, **options): + + options = self.get_args_from_database() if options['args_from_database'] else options + args = options.get('verification_ids', None) + if args: attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter( - receipt_id__in=args + receipt_id__in=options['verification_ids'] ) + log.info(u"Fetching retry verification ids from config model") force_must_retry = True else: attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry') force_must_retry = False - print(u"Attempting to retry {0} failed PhotoVerification submissions".format(len(attempts_to_retry))) + log.info(u"Attempting to retry {0} failed PhotoVerification submissions".format(len(attempts_to_retry))) for index, attempt in enumerate(attempts_to_retry): - print(u"Retrying submission #{0} (ID: {1}, User: {2})".format(index, attempt.id, attempt.user)) + log.info(u"Retrying submission #{0} (ID: {1}, User: {2})".format(index, attempt.id, attempt.user)) # Set the attempts status to 'must_retry' so that we can re-submit it if force_must_retry: attempt.status = 'must_retry' attempt.submit(copy_id_photo_from=attempt.copy_id_photo_from) - print(u"Retry result: {0}".format(attempt.status)) - print("Done resubmitting failed photo verifications") + log.info(u"Retry result: {0}".format(attempt.status)) + log.info("Done resubmitting failed photo verifications") diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py index 5f8fbd9979..986aa2ffe2 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py @@ -8,17 +8,21 @@ from __future__ import absolute_import import boto from django.conf import settings from django.core.management import call_command +from django.core.management.base import CommandError from django.test import TestCase from mock import patch +from testfixtures import LogCapture from common.test.utils import MockS3Mixin -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig from lms.djangoapps.verify_student.tests.test_models import ( FAKE_SETTINGS, mock_software_secure_post, mock_software_secure_post_error ) -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory # pylint: disable=import-error, useless-suppression + +LOGGER_NAME = 'retry_photo_verification' # Lots of patching to stub in our own settings, and HTTP posting @@ -64,3 +68,40 @@ class TestVerifyStudentCommand(MockS3Mixin, TestCase): call_command('retry_failed_photo_verifications') attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry') assert not attempts_to_retry + + def test_args_from_database(self): + """Test management command arguments injected from config model.""" + # Nothing in the database, should default to disabled + + # pylint: disable=deprecated-method, useless-suppression + with self.assertRaisesRegex(CommandError, 'SSPVerificationRetryConfig is disabled*'): + call_command('retry_failed_photo_verifications', '--args-from-database') + + # Add a config + config = SSPVerificationRetryConfig.current() + config.arguments = '--verification-ids 1 2 3' + config.enabled = True + config.save() + + with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error): + self.create_and_submit("RetryRoger") + + with LogCapture(LOGGER_NAME) as log: + call_command('retry_failed_photo_verifications') + + log.check_present( + ( + LOGGER_NAME, 'INFO', + u"Attempting to retry {0} failed PhotoVerification submissions".format(1) + ), + ) + + with LogCapture(LOGGER_NAME) as log: + call_command('retry_failed_photo_verifications', '--args-from-database') + + log.check_present( + ( + LOGGER_NAME, 'INFO', + u"Fetching retry verification ids from config model" + ), + ) diff --git a/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py b/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py new file mode 100644 index 0000000000..2f150fdd4e --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-10 11:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('verify_student', '0011_add_fields_to_sspv'), + ] + + operations = [ + migrations.CreateModel( + name='SSPVerificationRetryConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('arguments', models.TextField(blank=True, default='', help_text='Useful for manually running a Jenkins job. Specify like --verification-ids 1 2 3')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'verbose_name': 'sspv retry student argument', + }, + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 3f6e3b8ca0..73f2e6b201 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -16,13 +16,14 @@ import functools import json import logging import os.path -import simplejson import uuid from datetime import timedelta from email.utils import formatdate import requests +import simplejson import six +from config_models.models import ConfigurationModel from django.conf import settings from django.contrib.auth.models import User from django.core.files.base import ContentFile @@ -44,6 +45,7 @@ from lms.djangoapps.verify_student.ssencrypt import ( ) from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED from openedx.core.storage import get_storage + from .utils import earliest_allowed_verification_date log = logging.getLogger(__name__) @@ -1108,3 +1110,23 @@ class VerificationDeadline(TimeStampedModel): return deadline.deadline except cls.DoesNotExist: return None + + +class SSPVerificationRetryConfig(ConfigurationModel): # pylint: disable=model-missing-unicode, useless-suppression + """ + SSPVerificationRetryConfig used to inject arguments + to retry_failed_photo_verifications management command + """ + + class Meta(object): + app_label = 'verify_student' + verbose_name = 'sspv retry student argument' + + arguments = models.TextField( + blank=True, + help_text='Useful for manually running a Jenkins job. Specify like --verification-ids 1 2 3', + default='' + ) + + def __str__(self): + return six.text_type(self.arguments) diff --git a/lms/static/js/instructor_dashboard/membership.js b/lms/static/js/instructor_dashboard/membership.js index 1f3164dd4b..a9e5bf73a2 100644 --- a/lms/static/js/instructor_dashboard/membership.js +++ b/lms/static/js/instructor_dashboard/membership.js @@ -603,7 +603,7 @@ such that the value can be defined later than this assignment (file load order). this.$request_response_error = this.$container.find('.request-response-error'); this.$enrollment_button.click(function(event) { var sendData; - if (!batchEnroll.$reason_field.val()) { + if (batchEnroll.$reason_field.length && !batchEnroll.$reason_field.val()) { batchEnroll.fail_with_error(gettext('Reason field should not be left blank.')); return false; } diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js index 477aa7d197..7fe1f004ec 100644 --- a/lms/static/js/student_account/views/LoginView.js +++ b/lms/static/js/student_account/views/LoginView.js @@ -189,6 +189,51 @@ }, saveError: function(error) { + if (error.responseJSON !== undefined) { + this.saveErrorWithoutShim(error); + } else { + this.saveErrorWithShim(error); + } + }, + + saveErrorWithoutShim: function(error) { + var errorCode; + var msg; + if (error.status === 0) { + msg = gettext('An error has occurred. Check your Internet connection and try again.'); + } else if (error.status === 500) { + msg = gettext('An error has occurred. Try refreshing the page, or check your Internet connection.'); // eslint-disable-line max-len + } else if (error.responseJSON !== undefined) { + msg = error.responseJSON.value; + errorCode = error.responseJSON.error_code; + } else { + msg = gettext('An unexpected error has occurred.'); + } + + this.errors = [ + StringUtils.interpolate( + '
  • {msg}
  • ', { + msg: msg + } + ) + ]; + this.clearPasswordResetSuccess(); + + /* If the user successfully authenticated with a third-party provider, but they haven't + * linked the accounts, instruct the user on how to link the accounts. + */ + if (errorCode === 'third-party-auth-with-no-linked-account' && this.currentProvider) { + if (!this.hideAuthWarnings) { + this.clearFormErrors(); + this.renderThirdPartyAuthWarning(); + } + } else { + this.renderErrors(this.defaultFormErrorsTitle, this.errors); + } + this.toggleDisableButton(false); + }, + + saveErrorWithShim: function(error) { var msg = error.responseText; if (error.status === 0) { msg = gettext('An error has occurred. Check your Internet connection and try again.'); @@ -215,7 +260,7 @@ this.currentProvider) { if (!this.hideAuthWarnings) { this.clearFormErrors(); - this.renderAuthWarning(); + this.renderThirdPartyAuthWarning(); } } else { this.renderErrors(this.defaultFormErrorsTitle, this.errors); @@ -223,7 +268,7 @@ this.toggleDisableButton(false); }, - renderAuthWarning: function() { + renderThirdPartyAuthWarning: function() { var message = _.sprintf( gettext('You have successfully signed into %(currentProvider)s, but your %(currentProvider)s' + ' account does not have a linked %(platformName)s account. To link your accounts,' + diff --git a/lms/templates/bulk_email/unsubscribe.html b/lms/templates/bulk_email/unsubscribe.html new file mode 100644 index 0000000000..270f8dc604 --- /dev/null +++ b/lms/templates/bulk_email/unsubscribe.html @@ -0,0 +1,48 @@ +<%page expression_filter="h" /> +<%inherit file="../main.html" /> +<%! + from openedx.core.djangolib.markup import Text + from django.utils.translation import ugettext as _ +%> +<%def name="header()"> +%if confirmed: + ${Text(_("Unsubscribe Successful"))} +%elif cancelled: + ${Text(_("Unsubscribe Cancelled"))} +%else: + ${Text(_("Confirm Unsubscribe"))} +%endif + + +<%block name="pagetitle">${header()} +
    + +
    +

    + <%block name="pageheader">${header()} +

    +

    + <%block name="pagecontent"> + %if confirmed: + ${Text(_("You have been unsubscribed from emails for {course}.")).format( + course=course.display_name_with_default + )} + %elif cancelled: + ${Text(_("You have not been unsubscribed from emails for {course}.")).format( + course=course.display_name_with_default + )} + %else: + ${Text(_("Do you want to unsubscribe from emails for {course}?")).format( + course=course.display_name_with_default + )} +

    +

    + + + +
    + %endif + +

    +
    +
    diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index 651f80cd9e..1d8d29cc88 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -4,6 +4,7 @@ from django.utils.translation import ugettext as _ from openedx.core.djangolib.markup import HTML, Text from course_modes.models import CourseMode +from util.course import should_display_grade %> <%namespace name='static' file='../static_content.html'/> @@ -44,9 +45,11 @@ else: % else:
    -

    ${_("Your final grade:")} - ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. - +

    + % if should_display_grade(course_overview.end): + ${_("Your final grade:")} + ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. + % endif % if cert_status['status'] == 'notpassing': % if enrollment.mode != 'audit': ${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)} diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index 2662ad958e..84ac8b7b1f 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -24,12 +24,14 @@ from openedx.core.djangolib.markup import HTML, Text

    - + % if section_data['is_reason_field_enabled']: + + %endif