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
+%def>
+
+<%block name="pagetitle">${header()}%block>
+
+
+
+
+ <%block name="pageheader">${header()}%block>
+
+
+ <%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
+ %block>
+
+
+
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