Merge branch 'master' into release-mergeback-to-master

This commit is contained in:
Feanil Patel
2019-12-13 10:28:35 -05:00
committed by GitHub
55 changed files with 1355 additions and 612 deletions

View File

@@ -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 '#'

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,21 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="initial-scale=1.0"> <!-- So that mobile webkit will display zoomed in -->
<meta name="format-detection" content="telephone=no"> <!-- disable auto telephone linking in iOS -->
</head>
<body style="font-family:Arial,'Helvetica Neue',Helvetica,sans-serif;font-size:14px;line-height:150%;margin:auto">
<table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0" style="padding: 5px;">
<tr>
<td align="" valign="top">
{% block body %}
{% endblock body %}
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{% extends "email/email_base.html" %}
{% block body %}
<!-- Message Body -->
<p>
Dear Admin,
<p>
<p>
Please find the attached CSV containing a list of all staff users
who have logged in within the last {{ time_period }} days
</p>
<p>Thanks,</p>
<p>The edX Team</p>
<!-- End Message Body -->
{% endblock body %}

View File

@@ -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

View File

@@ -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)

View File

@@ -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, '<span class="grade-value">45%</span>')
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, '<span class="grade-value">45%</span>')
@patch.multiple('django.conf.settings', **MOCK_SETTINGS)
@ddt.data(
*itertools.product(

View File

@@ -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),

View File

@@ -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
"""

View File

@@ -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():

View File

@@ -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&#39;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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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<token>[a-zA-Z0-9-_=]+)/{}/$'.format(
settings.COURSE_ID_PATTERN,
),
views.opt_out_email_updates,
name='bulk_email_opt_out',
),
]

View File

@@ -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)

View File

@@ -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 = '<textarea rows="2" id="reason-field-id" name="reason-field" placeholder="Reason" spellcheck="false"></textarea>' # 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

View File

@@ -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):

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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")

View File

@@ -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"
),
)

View File

@@ -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',
},
),
]

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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(
'<li>{msg}</li>', {
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,' +

View File

@@ -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>
<section class="container unsubscribe">
<section class="message">
<h1>
<%block name="pageheader">${header()}</%block>
</h1>
<p>
<%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
)}
<br /><br />
<form method="post">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<button name="submit" value="confirm" type="submit">${Text(_('Unsubscribe'))}</button>
<button name="submit" value="cancel" type="submit">${Text(_('Cancel'))}</button>
</form>
%endif
</%block>
</p>
</section>
</section>

View File

@@ -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:
</div>
% else:
<div class="message message-status ${status_css_class} is-shown">
<p class="message-copy">${_("Your final grade:")}
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
<p class="message-copy">
% if should_display_grade(course_overview.end):
${_("Your final grade:")}
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
% endif
% if cert_status['status'] == 'notpassing':
% if enrollment.mode != 'audit':
${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)}

View File

@@ -24,12 +24,14 @@ from openedx.core.djangolib.markup import HTML, Text
</label>
</div>
<label>
${_("Enter the reason why the students are to be manually enrolled or unenrolled.")}
${_("This cannot be left blank and will be recorded and presented in Enrollment Reports.")}
${_("Therefore, please give enough detail to account for this action.")}
<textarea rows="2" id="reason-field-id" name="reason-field" placeholder="${_('Reason')}" spellcheck="false"></textarea>
</label>
% if section_data['is_reason_field_enabled']:
<label>
${_("Enter the reason why the students are to be manually enrolled or unenrolled.")}
${_("This cannot be left blank and will be recorded and presented in Enrollment Reports.")}
${_("Therefore, please give enough detail to account for this action.")}
<textarea rows="2" id="reason-field-id" name="reason-field" placeholder="${_('Reason')}" spellcheck="false"></textarea>
</label>
%endif
<div class="enroll-option">
<label class="has-hint">
<input type="checkbox" name="auto-enroll" id="auto-enroll" value="Auto-Enroll" checked="yes" aria-describedby="heading-batch-enrollment">

View File

@@ -1,5 +1,5 @@
<%page expression_filter="h"/>
% if settings.OPTIMIZELY_PROJECT_ID and not disable_optimizely and not is_from_mobile_app:
<script src=${ '//cdn.optimizely.com/js/{}.js'.format(settings.OPTIMIZELY_PROJECT_ID) }></script>
<script src=${ 'https://www.edx.org/optimizelyjs/{}.js'.format(settings.OPTIMIZELY_PROJECT_ID) }></script>
% endif

View File

@@ -722,6 +722,10 @@ if settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
),
]
urlpatterns += [
url(r'^bulk_email/', include('bulk_email.urls')),
]
urlpatterns += [
url(
r'^courses/{}/tab/(?P<tab_type>[^/]+)/$'.format(

View File

@@ -177,6 +177,13 @@
{{ contact_mailing_address }}
</td>
</tr>
{% if unsubscribe_url %}
<tr>
<td>
<a href="{% with_link_tracking unsubscribe_url %}">{% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }}</a>
</td>
</tr>
{% endif %}
</table>
</td>
</tr>

View File

@@ -3,9 +3,13 @@ Contains configuration for schedules app
"""
from __future__ import absolute_import
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlag, WaffleFlagNamespace
from openedx.core.djangoapps.waffle_utils import (
WaffleFlagNamespace, CourseWaffleFlag, WaffleFlag,
WaffleSwitch, WaffleSwitchNamespace,
)
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name=u'schedules')
WAFFLE_SWITCH_NAMESPACE = WaffleSwitchNamespace(name=u'schedules')
CREATE_SCHEDULE_WAFFLE_FLAG = CourseWaffleFlag(
waffle_namespace=WAFFLE_FLAG_NAMESPACE,
@@ -20,3 +24,5 @@ COURSE_UPDATE_WAFFLE_FLAG = CourseWaffleFlag(
)
DEBUG_MESSAGE_WAFFLE_FLAG = WaffleFlag(WAFFLE_FLAG_NAMESPACE, u'enable_debugging')
COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH = WaffleSwitch(WAFFLE_SWITCH_NAMESPACE, u'course_update_show_unsubscribe')

View File

@@ -0,0 +1,30 @@
"""
Management command to perform data migration for copying values between date fields of Schedule Model
"""
import time
from django.core.management.base import BaseCommand
from django.db import transaction
from openedx.core.djangoapps.schedules.models import Schedule
class Command(BaseCommand):
"""
Command to perform data migration for Schedule Model
"""
help = 'Copy values from start to start_date in Schedule model'
def add_arguments(self, parser):
parser.add_argument('--delay', type=float, default=0.2, help='Time delay in each iteration')
parser.add_argument('--size', type=int, default=1000, help='Batch size for atomic migration')
def handle(self, *args, **kwargs):
delay = kwargs['delay']
size = kwargs['size']
while Schedule.objects.filter(start_date__isnull=True).exists():
time.sleep(delay)
with transaction.atomic():
for row in Schedule.objects.filter(start_date__isnull=True)[:size]:
time.sleep(delay)
row.start_date = row.start
row.save()

View File

@@ -15,7 +15,9 @@ from edx_ace.recipient_resolver import RecipientResolver
from edx_django_utils.monitoring import function_trace, set_custom_metric
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
@@ -91,6 +93,13 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
with function_trace('enqueue_send_task'):
self.async_send_task.apply_async((self.site.id, str(msg)), retry=False)
@classmethod
def bin_num_for_user_id(cls, user_id):
"""
Returns the bin number used for the given (numeric) user ID.
"""
return user_id % cls.num_bins
def get_schedules_with_target_date_by_bin_and_orgs(
self, order_by='enrollment__user__id'
):
@@ -109,7 +118,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
courseenrollment__is_active=True,
**schedule_day_equals_target_day_filter
).annotate(
id_mod=F('id') % self.num_bins
id_mod=self.bin_num_for_user_id(F('id'))
).filter(
id_mod=self.bin_num
)
@@ -363,6 +372,14 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
)
# continue to the next schedule, don't yield an email for this one
else:
unsubscribe_url = None
if (COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH.is_enabled() and
'bulk_email_optout' in settings.ACE_ENABLED_POLICIES):
unsubscribe_url = reverse('bulk_email_opt_out', kwargs={
'token': UsernameCipher.encrypt(user.username),
'course_id': str(enrollment.course_id),
})
template_context.update({
'course_name': schedule.enrollment.course.display_name,
'course_url': _get_trackable_course_home_url(enrollment.course_id),
@@ -372,6 +389,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
# This is used by the bulk email optout policy
'course_ids': [str(enrollment.course_id)],
'unsubscribe_url': unsubscribe_url,
})
template_context.update(_get_upsell_information_for_schedule(user, schedule))

View File

@@ -8,25 +8,47 @@ from unittest import skipUnless
import ddt
from django.conf import settings
from mock import Mock
from django.test import TestCase
from django.test.utils import override_settings
from mock import Mock, patch
from waffle.testutils import override_switch
from openedx.core.djangoapps.schedules.resolvers import BinnedSchedulesBaseResolver
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.resolvers import (
BinnedSchedulesBaseResolver,
CourseUpdateResolver,
)
from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.djangolib.testing.utils import CacheIsolationMixin, skip_unless_lms
from student.tests.factories import CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
class SchedulesResolverTestMixin(CacheIsolationMixin):
"""
Base class for the resolver tests.
"""
def setUp(self):
super(SchedulesResolverTestMixin, self).setUp()
self.site = SiteFactory.create()
self.site_config = SiteConfigurationFactory(site=self.site)
self.schedule_config = ScheduleConfigFactory.create(site=self.site)
@ddt.ddt
@skip_unless_lms
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed")
class TestBinnedSchedulesBaseResolver(CacheIsolationTestCase):
class TestBinnedSchedulesBaseResolver(SchedulesResolverTestMixin, TestCase):
"""
Tests the BinnedSchedulesBaseResolver.
"""
def setUp(self):
super(TestBinnedSchedulesBaseResolver, self).setUp()
self.site = SiteFactory.create()
self.site_config = SiteConfigurationFactory(site=self.site)
self.schedule_config = ScheduleConfigFactory.create(site=self.site)
self.resolver = BinnedSchedulesBaseResolver(
async_send_task=Mock(name='async_send_task'),
site=self.site,
@@ -72,3 +94,64 @@ class TestBinnedSchedulesBaseResolver(CacheIsolationTestCase):
result = self.resolver.filter_by_org(mock_query)
mock_query.exclude.assert_called_once_with(enrollment__course__org__in=expected_org_list)
self.assertEqual(result, mock_query.exclude.return_value)
@skip_unless_lms
@skipUnless('openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS,
"Can't test schedules if the app isn't installed")
class TestCourseUpdateResolver(SchedulesResolverTestMixin, ModuleStoreTestCase):
"""
Tests the CourseUpdateResolver.
"""
def setUp(self):
super(TestCourseUpdateResolver, self).setUp()
self.course = CourseFactory(highlights_enabled_for_messaging=True, self_paced=True)
with self.store.bulk_operations(self.course.id):
ItemFactory.create(parent=self.course, category='chapter', highlights=[u'good stuff'])
def create_resolver(self):
"""
Creates a CourseUpdateResolver with an enrollment to schedule.
"""
with patch('openedx.core.djangoapps.schedules.signals.get_current_site') as mock_get_current_site:
mock_get_current_site.return_value = self.site_config.site
enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=u'audit')
return CourseUpdateResolver(
async_send_task=Mock(name='async_send_task'),
site=self.site_config.site,
target_datetime=enrollment.schedule.start,
day_offset=-7,
bin_num=CourseUpdateResolver.bin_num_for_user_id(self.user.id),
)
@override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street')
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
def test_schedule_context(self):
resolver = self.create_resolver()
schedules = list(resolver.schedules_for_bin())
expected_context = {
'contact_email': 'info@example.com',
'contact_mailing_address': '123 Sesame Street',
'course_ids': [str(self.course.id)],
'course_name': self.course.display_name,
'course_url': '/courses/{}/course/'.format(self.course.id),
'dashboard_url': '/dashboard',
'homepage_url': '/',
'mobile_store_urls': {},
'platform_name': u'\xe9dX',
'show_upsell': False,
'social_media_urls': {},
'template_revision': 'release',
'unsubscribe_url': None,
'week_highlights': ['good stuff'],
'week_num': 1,
}
self.assertEqual(schedules, [(self.user, None, expected_context)])
@override_waffle_flag(COURSE_UPDATE_WAFFLE_FLAG, True)
@override_switch('schedules.course_update_show_unsubscribe', True)
def test_schedule_context_show_unsubscribe(self):
resolver = self.create_resolver()
schedules = list(resolver.schedules_for_bin())
self.assertIn('optout', schedules[0][2]['unsubscribe_url'])

View File

@@ -331,35 +331,6 @@ def _send_email_change_requests_if_needed(data, user):
)
@helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def activate_account(activation_key):
"""Activate a user's account.
Args:
activation_key (unicode): The activation key the user received via email.
Returns:
None
Raises:
errors.UserNotAuthorized
errors.UserAPIInternalError: the operation failed due to an unexpected error.
"""
# TODO: Confirm this `activate_account` is only used for tests. If so, this should not be used for tests, and we
# should instead use the `activate_account` used for /activate.
set_custom_metric('user_api_activate_account', 'True')
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
raise errors.UserAPIInternalError(SYSTEM_MAINTENANCE_MSG)
try:
registration = Registration.objects.get(activation_key=activation_key)
except Registration.DoesNotExist:
raise errors.UserNotAuthorized
else:
# This implicitly saves the registration
registration.activate()
def get_name_validation_error(name):
"""Get the built-in validation error message for when
the user's real name is invalid in some way (we wonder how).

View File

@@ -28,7 +28,6 @@ from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixi
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.user_api.accounts import PRIVATE_VISIBILITY, USERNAME_MAX_LENGTH
from openedx.core.djangoapps.user_api.accounts.api import (
activate_account,
get_account_settings,
update_account_settings
)
@@ -530,64 +529,3 @@ class AccountSettingsOnCreationTest(CreateAccountMixin, TestCase):
expected_user_password = make_password(unicodedata.normalize('NFKC', u'Ṗŕệṿïệẅ Ṯệẍt'), salt_val)
self.assertEqual(expected_user_password, user.password)
@ddt.ddt
class AccountActivationAndPasswordChangeTest(CreateAccountMixin, TestCase):
"""
Test cases to cover the account initialization workflow
"""
USERNAME = u'claire-underwood'
PASSWORD = u'ṕáśśẃőŕd'
EMAIL = u'claire+underwood@example.com'
IS_SECURE = False
def get_activation_key(self, user):
registration = Registration.objects.get(user=user)
return registration.activation_key
@skip_unless_lms
def test_activate_account(self):
# Create the account, which is initially inactive
self.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
user = User.objects.get(username=self.USERNAME)
activation_key = self.get_activation_key(user)
request = RequestFactory().get("/api/user/v1/accounts/")
request.user = user
account = get_account_settings(request)[0]
self.assertEqual(self.USERNAME, account["username"])
self.assertEqual(self.EMAIL, account["email"])
self.assertFalse(account["is_active"])
# Activate the account and verify that it is now active
activate_account(activation_key)
account = get_account_settings(request)[0]
self.assertTrue(account['is_active'])
def test_activate_account_invalid_key(self):
with pytest.raises(UserNotAuthorized):
activate_account(u'invalid')
def test_activate_account_prevent_auth_user_writes(self):
self.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
user = User.objects.get(username=self.USERNAME)
activation_key = self.get_activation_key(user)
with pytest.raises(UserAPIInternalError, message=SYSTEM_MAINTENANCE_MSG):
with waffle().override(PREVENT_AUTH_USER_WRITES, True):
activate_account(activation_key)
def _assert_is_datetime(self, timestamp):
"""
Internal helper to validate the type of the provided timestamp
"""
if not timestamp:
return False
try:
parse_datetime(timestamp)
except ValueError:
return False
else:
return True

View File

@@ -22,18 +22,3 @@ _WAFFLE_SWITCH_NAMESPACE = WaffleSwitchNamespace(name=_WAFFLE_NAMESPACE, log_pre
ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY = WaffleSwitch(
_WAFFLE_SWITCH_NAMESPACE, 'enable_login_using_thirdparty_auth_only'
)
# .. toggle_name: user_authn.update_login_user_error_status_code
# .. toggle_implementation: WaffleSwitch
# .. toggle_default: False
# .. toggle_description: Changes auth failures (non-SSO) from 200 to 400.
# .. toggle_category: authn
# .. toggle_use_cases: incremental_release
# .. toggle_creation_date: 2019-11-21
# .. toggle_expiration_date: 2020-01-31
# .. toggle_warnings: Causes backward incompatible change. Document before removing.
# .. toggle_tickets: ARCH-1253
# .. toggle_status: supported
UPDATE_LOGIN_USER_ERROR_STATUS_CODE = WaffleSwitch(
_WAFFLE_SWITCH_NAMESPACE, 'update_login_user_error_status_code'
)

View File

@@ -34,10 +34,7 @@ from openedx.core.djangoapps.user_authn.cookies import refresh_jwt_cookies, set_
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
from openedx.core.djangoapps.user_authn.config.waffle import (
ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY,
UPDATE_LOGIN_USER_ERROR_STATUS_CODE
)
from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.api.view_utils import require_post_params
from student.models import LoginFailures, AllowedAuthUser, UserProfile
@@ -403,14 +400,11 @@ def login_user(request):
response = set_logged_in_cookies(request, response, possibly_authenticated_user)
set_custom_metric('login_user_auth_failed_error', False)
set_custom_metric('login_user_response_status', response.status_code)
set_custom_metric('login_user_redirect_url', redirect_url)
return response
except AuthFailedError as error:
log.exception(error.get_response())
# original code returned a 200 status code with status=False for errors. This flag
# is used for rolling out a transition to using a 400 status code for errors, which
# is a breaking-change, but will hopefully be a tolerable breaking-change.
status = 400 if UPDATE_LOGIN_USER_ERROR_STATUS_CODE.is_enabled() else 200
response = JsonResponse(error.get_response(), status=status)
response = JsonResponse(error.get_response(), status=400)
set_custom_metric('login_user_auth_failed_error', True)
set_custom_metric('login_user_response_status', response.status_code)
return response
@@ -490,10 +484,15 @@ def _parse_analytics_param_for_course_id(request):
modified_request = request.POST.copy()
if isinstance(request, HttpRequest):
# Works for an HttpRequest but not a rest_framework.request.Request.
# Note: This case seems to be used for tests only.
request.POST = modified_request
set_custom_metric('login_user_request_type', 'django')
else:
# The request must be a rest_framework.request.Request.
# Note: Only DRF seems to be used in Production.
request._data = modified_request # pylint: disable=protected-access
set_custom_metric('login_user_request_type', 'drf')
# Include the course ID if it's specified in the analytics info
# so it can be included in analytics events.
if "analytics" in modified_request:
@@ -573,6 +572,8 @@ def shim_student_view(view_func, check_logged_in=False):
msg = response_dict.get("value", u"")
success = response_dict.get("success")
set_custom_metric('shim_original_response_is_json', True)
set_custom_metric('shim_original_redirect_url', response_dict.get("redirect_url"))
set_custom_metric('shim_original_redirect', response_dict.get("redirect"))
except (ValueError, TypeError):
msg = response.content
success = True

View File

@@ -77,6 +77,20 @@ def _apply_third_party_auth_overrides(request, form_desc):
)
# .. toggle_name: FEATURES[ENABLE_LOGIN_POST_WITHOUT_SHIM]
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
# .. toggle_description: Toggle for enabling login post without shim_student_view (using `login_api`).
# .. toggle_category: n/a
# .. toggle_use_cases: incremental_release
# .. toggle_creation_date: 2019-12-10
# .. toggle_expiration_date: 2020-06-01
# .. toggle_warnings: n/a
# .. toggle_tickets: ARCH-1253
# .. toggle_status: supported
ENABLE_LOGIN_POST_WITHOUT_SHIM = 'ENABLE_LOGIN_POST_WITHOUT_SHIM'
def get_login_session_form(request):
"""Return a description of the login form.
@@ -91,7 +105,12 @@ def get_login_session_form(request):
HttpResponse
"""
form_desc = FormDescription("post", reverse("user_api_login_session"))
if settings.FEATURES.get(ENABLE_LOGIN_POST_WITHOUT_SHIM):
submit_url = reverse("login_api")
else:
submit_url = reverse("user_api_login_session")
form_desc = FormDescription("post", submit_url)
_apply_third_party_auth_overrides(request, form_desc)
# Translators: This label appears above a field on the login form

View File

@@ -32,9 +32,9 @@ from openedx.core.djangoapps.user_authn.cookies import jwt_cookies
from openedx.core.djangoapps.user_authn.views.login import (
shim_student_view,
AllowedAuthUser,
UPDATE_LOGIN_USER_ERROR_STATUS_CODE,
ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY
)
from openedx.core.djangoapps.user_authn.views.login_form import ENABLE_LOGIN_POST_WITHOUT_SHIM
from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
@@ -84,12 +84,10 @@ class LoginTest(SiteMixin, CacheIsolationTestCase):
self._assert_audit_log(mock_audit_log, 'info', [u'Login success', self.user_email])
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
@ddt.data(True, False)
def test_login_success_no_pii(self, is_error_status_code_enabled):
with UPDATE_LOGIN_USER_ERROR_STATUS_CODE.override(is_error_status_code_enabled):
response, mock_audit_log = self._login_response(
self.user_email, self.password, patched_audit_log='student.models.AUDIT_LOG'
)
def test_login_success_no_pii(self):
response, mock_audit_log = self._login_response(
self.user_email, self.password, patched_audit_log='student.models.AUDIT_LOG'
)
self._assert_response(response, success=True)
self._assert_audit_log(mock_audit_log, 'info', [u'Login success'])
self._assert_not_in_audit_log(mock_audit_log, 'info', [self.user_email])
@@ -118,20 +116,14 @@ class LoginTest(SiteMixin, CacheIsolationTestCase):
self.user.refresh_from_db()
assert old_last_login == self.user.last_login
@ddt.data(
(True, 400),
(False, 200),
)
@ddt.unpack
def test_login_fail_no_user_exists(self, is_error_status_code_enabled, expected_status_code):
def test_login_fail_no_user_exists(self):
nonexistent_email = u'not_a_user@edx.org'
with UPDATE_LOGIN_USER_ERROR_STATUS_CODE.override(is_error_status_code_enabled):
response, mock_audit_log = self._login_response(
nonexistent_email,
self.password,
)
response, mock_audit_log = self._login_response(
nonexistent_email,
self.password,
)
self._assert_response(
response, success=False, value=self.LOGIN_FAILED_WARNING, status_code=expected_status_code
response, success=False, value=self.LOGIN_FAILED_WARNING, status_code=400
)
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
@@ -519,7 +511,7 @@ class LoginTest(SiteMixin, CacheIsolationTestCase):
If value is provided, assert that the response contained that
value for 'value' in the JSON dict.
"""
expected_status_code = status_code or 200
expected_status_code = status_code or (400 if success is False else 200)
self.assertEqual(response.status_code, expected_status_code)
try:
@@ -670,15 +662,26 @@ class LoginSessionViewTest(ApiTestCase):
response = self.client.patch(self.url)
self.assertHttpMethodNotAllowed(response)
def test_login_form(self):
# Retrieve the login form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
@ddt.data(
{ENABLE_LOGIN_POST_WITHOUT_SHIM: True},
{ENABLE_LOGIN_POST_WITHOUT_SHIM: False},
{},
)
def test_login_form(self, features_setting):
with patch.dict("django.conf.settings.FEATURES", features_setting):
# Retrieve the login form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
if ENABLE_LOGIN_POST_WITHOUT_SHIM in features_setting and features_setting[ENABLE_LOGIN_POST_WITHOUT_SHIM]:
submit_url = reverse("login_api")
else:
submit_url = reverse("user_api_login_session")
# Verify that the form description matches what we expect
form_desc = json.loads(response.content.decode('utf-8'))
self.assertEqual(form_desc["method"], "post")
self.assertEqual(form_desc["submit_url"], self.url)
self.assertEqual(form_desc["submit_url"], submit_url)
self.assertEqual(form_desc["fields"], [
{
"name": "email",

View File

@@ -1,334 +1,41 @@
# -*- coding: utf-8 -*-
""" Tests for user authn views. """
""" Tests for Logistration views. """
from __future__ import absolute_import
import json
import logging
import re
from http.cookies import SimpleCookie
from unittest import skipUnless
import ddt
import mock
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.translation import ugettext as _
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory
from oauth2_provider.models import AccessToken as dot_access_token
from oauth2_provider.models import RefreshToken as dot_refresh_token
from provider.oauth2.models import AccessToken as dop_access_token
from provider.oauth2.models import RefreshToken as dop_refresh_token
from six.moves import range
from six.moves.urllib.parse import urlencode # pylint: disable=import-error
from testfixtures import LogCapture
from waffle.models import Switch
from course_modes.models import CourseMode
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
from openedx.core.djangoapps.user_api.accounts.api import activate_account
from openedx.core.djangoapps.user_api.accounts.utils import ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH
from openedx.core.djangoapps.user_api.errors import UserAPIInternalError
from openedx.core.djangoapps.user_authn.views.login_form import login_and_registration_form
from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from student.models import Registration
from student.tests.factories import AccountRecoveryFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
LOGGER_NAME = 'audit'
User = get_user_model() # pylint:disable=invalid-name
FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL = settings.FEATURES.copy()
FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL['ENABLE_PASSWORD_RESET_FAILURE_EMAIL'] = True
@skip_unless_lms
@ddt.ddt
class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
""" Tests for views that update the user's account information. """
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
OLD_PASSWORD = u"ḅḷüëṡḳÿ"
NEW_PASSWORD = u"B🄸🄶B🄻🅄🄴"
OLD_EMAIL = u"walter@graymattertech.com"
NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_KEY = u"123abc"
URLCONF_MODULES = ['student_accounts.urls']
ENABLED_CACHES = ['default']
def _create_account(self, username, password, email):
# pylint: disable=missing-docstring
registration_url = reverse('user_api_registration')
resp = self.client.post(registration_url, {
'username': username,
'email': email,
'password': password,
'name': username,
'honor_code': 'true',
})
self.assertEqual(resp.status_code, 200)
def setUp(self):
super(UserAccountUpdateTest, self).setUp()
# Create/activate a new account
self._create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
mail.outbox = []
user = User.objects.get(username=self.USERNAME)
registration = Registration.objects.get(user=user)
activate_account(registration.activation_key)
self.account_recovery = AccountRecoveryFactory.create(user=User.objects.get(email=self.OLD_EMAIL))
self.enable_account_recovery_switch = Switch.objects.create(
name=ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH,
active=True
)
# Login
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertTrue(result)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
def test_password_change(self):
# Request a password change while logged in, simulating
# use of the password reset link from the account page
response = self._change_password()
self.assertEqual(response.status_code, 200)
# Check that an email was sent
self.assertEqual(len(mail.outbox), 1)
# Retrieve the activation link from the email body
email_body = mail.outbox[0].body
result = re.search(r'(?P<url>https?://[^\s]+)', email_body)
self.assertIsNot(result, None)
activation_link = result.group('url')
# Visit the activation link
response = self.client.get(activation_link)
self.assertEqual(response.status_code, 200)
# Submit a new password and follow the redirect to the success page
response = self.client.post(
activation_link,
# These keys are from the form on the current password reset confirmation page.
{'new_password1': self.NEW_PASSWORD, 'new_password2': self.NEW_PASSWORD},
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Your password has been reset.")
# Log the user out to clear session data
self.client.logout()
# Verify that the new password can be used to log in
login_api_url = reverse('login_api')
response = self.client.post(login_api_url, {'email': self.OLD_EMAIL, 'password': self.NEW_PASSWORD})
assert response.status_code == 200
response_dict = json.loads(response.content.decode('utf-8'))
assert response_dict['success']
# Try reusing the activation link to change the password again
# Visit the activation link again.
response = self.client.get(activation_link)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "This password reset link is invalid. It may have been used already.")
self.client.logout()
# Verify that the old password cannot be used to log in
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertFalse(result)
# Verify that the new password continues to be valid
response = self.client.post(login_api_url, {'email': self.OLD_EMAIL, 'password': self.NEW_PASSWORD})
assert response.status_code == 200
response_dict = json.loads(response.content.decode('utf-8'))
assert response_dict['success']
def test_password_change_failure(self):
with mock.patch('openedx.core.djangoapps.user_authn.views.password_reset.request_password_change',
side_effect=UserAPIInternalError):
self._change_password()
self.assertRaises(UserAPIInternalError)
@override_settings(FEATURES=FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL)
def test_password_reset_failure_email(self):
"""Test that a password reset failure email notification is sent, when enabled."""
# Log the user out
self.client.logout()
bad_email = 'doesnotexist@example.com'
response = self._change_password(email=bad_email)
self.assertEqual(response.status_code, 200)
# Check that an email was sent
self.assertEqual(len(mail.outbox), 1)
# Verify that the body contains the failed password reset message
sent_message = mail.outbox[0]
text_body = sent_message.body
html_body = sent_message.alternatives[0][0]
for email_body in [text_body, html_body]:
msg = u'However, there is currently no user account associated with your email address: {email}'.format(
email=bad_email
)
assert u'reset for your user account at {}'.format(settings.PLATFORM_NAME) in email_body
assert 'password_reset_confirm' not in email_body, 'The link should not be added if user was not found'
assert msg in email_body
@ddt.data(True, False)
def test_password_change_logged_out(self, send_email):
# Log the user out
self.client.logout()
# Request a password change while logged out, simulating
# use of the password reset link from the login page
if send_email:
response = self._change_password(email=self.OLD_EMAIL)
self.assertEqual(response.status_code, 200)
else:
# Don't send an email in the POST data, simulating
# its (potentially accidental) omission in the POST
# data sent from the login page
response = self._change_password()
self.assertEqual(response.status_code, 400)
def test_access_token_invalidation_logged_out(self):
self.client.logout()
user = User.objects.get(email=self.OLD_EMAIL)
self._create_dop_tokens(user)
self._create_dot_tokens(user)
response = self._change_password(email=self.OLD_EMAIL)
self.assertEqual(response.status_code, 200)
self.assert_access_token_destroyed(user)
def test_access_token_invalidation_logged_in(self):
user = User.objects.get(email=self.OLD_EMAIL)
self._create_dop_tokens(user)
self._create_dot_tokens(user)
response = self._change_password()
self.assertEqual(response.status_code, 200)
self.assert_access_token_destroyed(user)
def test_password_change_inactive_user(self):
# Log out the user created during test setup
self.client.logout()
# Create a second user, but do not activate it
self._create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
mail.outbox = []
# Send the view the email address tied to the inactive user
response = self._change_password(email=self.NEW_EMAIL)
# Expect that the activation email is still sent,
# since the user may have lost the original activation email.
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1)
def test_password_change_no_user(self):
# Log out the user created during test setup
self.client.logout()
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
# Send the view an email address not tied to any user
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 200)
logger.check((LOGGER_NAME, 'INFO', 'Invalid password reset attempt'))
def test_password_change_rate_limited(self):
"""
Tests that consective password reset requests are rate limited.
"""
# Log out the user created during test setup, to prevent the view from
# selecting the logged-in user's email address over the email provided
# in the POST data
self.client.logout()
for status in [200, 403]:
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, status)
with mock.patch(
'util.request_rate_limiter.PasswordResetEmailRateLimiter.is_rate_limit_exceeded',
return_value=False
):
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 200)
@ddt.data(
('post', 'password_change_request', []),
)
@ddt.unpack
def test_require_http_method(self, correct_method, url_name, args):
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
url = reverse(url_name, args=args)
for method in wrong_methods:
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 405)
def _change_password(self, email=None):
"""Request to change the user's password. """
data = {}
if email:
data['email'] = email
return self.client.post(path=reverse('password_change_request'), data=data)
def _create_dop_tokens(self, user=None):
"""Create dop access token for given user if user provided else for default user."""
if not user:
user = User.objects.get(email=self.OLD_EMAIL)
client = ClientFactory()
access_token = AccessTokenFactory(user=user, client=client)
RefreshTokenFactory(user=user, client=client, access_token=access_token)
def _create_dot_tokens(self, user=None):
"""Create dop access token for given user if user provided else for default user."""
if not user:
user = User.objects.get(email=self.OLD_EMAIL)
application = dot_factories.ApplicationFactory(user=user)
access_token = dot_factories.AccessTokenFactory(user=user, application=application)
dot_factories.RefreshTokenFactory(user=user, application=application, access_token=access_token)
def assert_access_token_destroyed(self, user):
"""Assert all access tokens are destroyed."""
self.assertFalse(dot_access_token.objects.filter(user=user).exists())
self.assertFalse(dot_refresh_token.objects.filter(user=user).exists())
self.assertFalse(dop_access_token.objects.filter(user=user).exists())
self.assertFalse(dop_refresh_token.objects.filter(user=user).exists())
@skip_unless_lms
@ddt.ddt
class LoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
""" Tests for the student account views that update the user's account information. """
""" Tests for Login and Registration. """
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = u"password"

View File

@@ -2,51 +2,54 @@
"""
Tests for user authorization password-related functionality.
"""
import json
import logging
import re
from mock import Mock, patch
import ddt
from django.core import mail
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.test.client import RequestFactory
from django.urls import reverse
from testfixtures import LogCapture
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
from openedx.core.djangoapps.user_api.accounts.tests.test_api import CreateAccountMixin
from openedx.core.djangoapps.user_api.accounts.api import (
activate_account,
)
from openedx.core.djangoapps.user_api.errors import UserNotFound
from openedx.core.djangoapps.user_api.errors import UserNotFound, UserAPIInternalError
from openedx.core.djangoapps.user_authn.views.password_reset import request_password_change
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from oauth2_provider.models import AccessToken as dot_access_token
from oauth2_provider.models import RefreshToken as dot_refresh_token
from provider.oauth2.models import AccessToken as dop_access_token
from provider.oauth2.models import RefreshToken as dop_refresh_token
from student.models import Registration
LOGGER_NAME = 'audit'
User = get_user_model() # pylint:disable=invalid-name
class TestRequestPasswordChange(CreateAccountMixin, TestCase):
"""
Tests for users who request a password change.
"""
USERNAME = u'claire-underwood'
PASSWORD = u'ṕáśśẃőŕd'
EMAIL = u'claire+underwood@example.com'
IS_SECURE = False
def get_activation_key(self, user):
registration = Registration.objects.get(user=user)
return registration.activation_key
@skip_unless_lms
def test_request_password_change(self):
# Create and activate an account
self.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
self.assertEqual(len(mail.outbox), 1)
user = User.objects.get(username=self.USERNAME)
activation_key = self.get_activation_key(user)
activate_account(activation_key)
request = RequestFactory().post('/password')
request.user = Mock()
request.site = SiteFactory()
@@ -87,3 +90,245 @@ class TestRequestPasswordChange(CreateAccountMixin, TestCase):
# Verify that the password change email was still sent
self.assertEqual(len(mail.outbox), 2)
@skip_unless_lms
@ddt.ddt
class TestPasswordChange(CreateAccountMixin, CacheIsolationTestCase):
""" Tests for views that change the user's password. """
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
OLD_PASSWORD = u"ḅḷüëṡḳÿ"
NEW_PASSWORD = u"B🄸🄶B🄻🅄🄴"
OLD_EMAIL = u"walter@graymattertech.com"
NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_KEY = u"123abc"
ENABLED_CACHES = ['default']
def setUp(self):
super(TestPasswordChange, self).setUp()
self.create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertTrue(result)
mail.outbox = []
def test_password_change(self):
# Request a password change while logged in, simulating
# use of the password reset link from the account page
response = self._change_password()
self.assertEqual(response.status_code, 200)
# Check that an email was sent
self.assertEqual(len(mail.outbox), 1)
# Retrieve the activation link from the email body
email_body = mail.outbox[0].body
result = re.search(r'(?P<url>https?://[^\s]+)', email_body)
self.assertIsNot(result, None)
activation_link = result.group('url')
# Visit the activation link
response = self.client.get(activation_link)
self.assertEqual(response.status_code, 200)
# Submit a new password and follow the redirect to the success page
response = self.client.post(
activation_link,
# These keys are from the form on the current password reset confirmation page.
{'new_password1': self.NEW_PASSWORD, 'new_password2': self.NEW_PASSWORD},
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Your password has been reset.")
# Log the user out to clear session data
self.client.logout()
# Verify that the new password can be used to log in
login_api_url = reverse('login_api')
response = self.client.post(login_api_url, {'email': self.OLD_EMAIL, 'password': self.NEW_PASSWORD})
assert response.status_code == 200
response_dict = json.loads(response.content.decode('utf-8'))
assert response_dict['success']
# Try reusing the activation link to change the password again
# Visit the activation link again.
response = self.client.get(activation_link)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "This password reset link is invalid. It may have been used already.")
self.client.logout()
# Verify that the old password cannot be used to log in
result = self.client.login(username=self.USERNAME, password=self.OLD_PASSWORD)
self.assertFalse(result)
# Verify that the new password continues to be valid
response = self.client.post(login_api_url, {'email': self.OLD_EMAIL, 'password': self.NEW_PASSWORD})
assert response.status_code == 200
response_dict = json.loads(response.content.decode('utf-8'))
assert response_dict['success']
def test_password_change_failure(self):
with patch(
'openedx.core.djangoapps.user_authn.views.password_reset.request_password_change',
side_effect=UserAPIInternalError,
):
self._change_password()
self.assertRaises(UserAPIInternalError)
@patch.dict(settings.FEATURES, {'ENABLE_PASSWORD_RESET_FAILURE_EMAIL': True})
def test_password_reset_failure_email(self):
"""Test that a password reset failure email notification is sent, when enabled."""
# Log the user out
self.client.logout()
bad_email = 'doesnotexist@example.com'
response = self._change_password(email=bad_email)
self.assertEqual(response.status_code, 200)
# Check that an email was sent
self.assertEqual(len(mail.outbox), 1)
# Verify that the body contains the failed password reset message
sent_message = mail.outbox[0]
text_body = sent_message.body
html_body = sent_message.alternatives[0][0]
for email_body in [text_body, html_body]:
msg = u'However, there is currently no user account associated with your email address: {email}'.format(
email=bad_email
)
assert u'reset for your user account at {}'.format(settings.PLATFORM_NAME) in email_body
assert 'password_reset_confirm' not in email_body, 'The link should not be added if user was not found'
assert msg in email_body
@ddt.data(True, False)
def test_password_change_logged_out(self, send_email):
# Log the user out
self.client.logout()
# Request a password change while logged out, simulating
# use of the password reset link from the login page
if send_email:
response = self._change_password(email=self.OLD_EMAIL)
self.assertEqual(response.status_code, 200)
else:
# Don't send an email in the POST data, simulating
# its (potentially accidental) omission in the POST
# data sent from the login page
response = self._change_password()
self.assertEqual(response.status_code, 400)
def test_access_token_invalidation_logged_out(self):
self.client.logout()
user = User.objects.get(email=self.OLD_EMAIL)
self._create_dop_tokens(user)
self._create_dot_tokens(user)
response = self._change_password(email=self.OLD_EMAIL)
self.assertEqual(response.status_code, 200)
self._assert_access_token_destroyed(user)
def test_access_token_invalidation_logged_in(self):
user = User.objects.get(email=self.OLD_EMAIL)
self._create_dop_tokens(user)
self._create_dot_tokens(user)
response = self._change_password()
self.assertEqual(response.status_code, 200)
self._assert_access_token_destroyed(user)
def test_password_change_inactive_user(self):
# Log out the user created during test setup
self.client.logout()
# Create a second user, but do not activate it
self.create_account(self.ALTERNATE_USERNAME, self.OLD_PASSWORD, self.NEW_EMAIL)
mail.outbox = []
# Send the view the email address tied to the inactive user
response = self._change_password(email=self.NEW_EMAIL)
# Expect that the activation email is still sent,
# since the user may have lost the original activation email.
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1)
def test_password_change_no_user(self):
# Log out the user created during test setup
self.client.logout()
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
# Send the view an email address not tied to any user
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 200)
logger.check((LOGGER_NAME, 'INFO', 'Invalid password reset attempt'))
def test_password_change_rate_limited(self):
"""
Tests that consecutive password reset requests are rate limited.
"""
# Log out the user created during test setup, to prevent the view from
# selecting the logged-in user's email address over the email provided
# in the POST data
self.client.logout()
for status in [200, 403]:
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, status)
with patch(
'util.request_rate_limiter.PasswordResetEmailRateLimiter.is_rate_limit_exceeded',
return_value=False
):
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 200)
@ddt.data(
('post', 'password_change_request', []),
)
@ddt.unpack
def test_require_http_method(self, correct_method, url_name, args):
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
url = reverse(url_name, args=args)
for method in wrong_methods:
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 405)
def _change_password(self, email=None):
"""Request to change the user's password. """
data = {}
if email:
data['email'] = email
return self.client.post(path=reverse('password_change_request'), data=data)
def _create_dop_tokens(self, user=None):
"""Create dop access token for given user if user provided else for default user."""
if not user:
user = User.objects.get(email=self.OLD_EMAIL)
client = ClientFactory()
access_token = AccessTokenFactory(user=user, client=client)
RefreshTokenFactory(user=user, client=client, access_token=access_token)
def _create_dot_tokens(self, user=None):
"""Create dot access token for given user if user provided else for default user."""
if not user:
user = User.objects.get(email=self.OLD_EMAIL)
application = dot_factories.ApplicationFactory(user=user)
access_token = dot_factories.AccessTokenFactory(user=user, application=application)
dot_factories.RefreshTokenFactory(user=user, application=application, access_token=access_token)
def _assert_access_token_destroyed(self, user):
"""Assert all access tokens are destroyed."""
self.assertFalse(dot_access_token.objects.filter(user=user).exists())
self.assertFalse(dot_refresh_token.objects.filter(user=user).exists())
self.assertFalse(dop_access_token.objects.filter(user=user).exists())
self.assertFalse(dop_refresh_token.objects.filter(user=user).exists())

View File

@@ -48,6 +48,22 @@ class CacheIsolationMixin(object):
__settings_overrides = []
__old_settings = []
@classmethod
def setUpClass(cls):
super(CacheIsolationMixin, cls).setUpClass()
cls.start_cache_isolation()
@classmethod
def tearDownClass(cls):
cls.end_cache_isolation()
super(CacheIsolationMixin, cls).tearDownClass()
def setUp(self):
super(CacheIsolationMixin, self).setUp()
self.clear_caches()
self.addCleanup(self.clear_caches)
@classmethod
def start_cache_isolation(cls):
"""
@@ -131,21 +147,6 @@ class CacheIsolationTestCase(CacheIsolationMixin, TestCase):
:py:class:`CacheIsolationMixin`) at class setup, and flushes the cache
between every test.
"""
@classmethod
def setUpClass(cls):
super(CacheIsolationTestCase, cls).setUpClass()
cls.start_cache_isolation()
@classmethod
def tearDownClass(cls):
cls.end_cache_isolation()
super(CacheIsolationTestCase, cls).tearDownClass()
def setUp(self):
super(CacheIsolationTestCase, self).setUp()
self.clear_caches()
self.addCleanup(self.clear_caches)
class _AssertNumQueriesContext(CaptureQueriesContext):

View File

@@ -133,8 +133,8 @@ pyuca==1.1 # For more accurate sorting of translated co
recommender-xblock # https://github.com/edx/RecommenderXBlock
rest-condition # DRF's recommendation for supporting complex permissions
rfc6266-parser # Used to generate Content-Disposition headers.
social-auth-app-django<3.0.0
social-auth-core<2.0.0
social-auth-app-django==3.1.0
social-auth-core==3.2.0
pysrt # Support for SubRip subtitle files, used in the video XModule
pytz # Time zone information database
PyYAML # Used to parse XModule resource templates

View File

@@ -105,7 +105,7 @@ edx-django-release-util==0.3.2
edx-django-sites-extensions==2.3.1
edx-django-utils==2.0.2
edx-drf-extensions==2.4.5
edx-enterprise==2.0.30
edx-enterprise==2.0.31
edx-i18n-tools==0.4.8
edx-milestones==0.2.6
edx-oauth2-provider==1.3.1
@@ -120,7 +120,7 @@ edx-sga==0.10.0
edx-submissions==3.0.3
edx-user-state-client==1.1.2
edx-when==0.5.2
edxval==1.1.32
edxval==1.1.33
elasticsearch==1.9.0 # via edx-search
enum34==1.1.6
event-tracking==0.3.0
@@ -227,8 +227,8 @@ simplejson==3.17.0
singledispatch==3.4.0.3
six==1.13.0
slumber==0.7.1 # via edx-bulk-grades, edx-enterprise, edx-rest-api-client
social-auth-app-django==2.1.0
social-auth-core==1.7.0
social-auth-app-django==3.1.0
social-auth-core==3.2.0
sorl-thumbnail==12.3
sortedcontainers==2.1.0
soupsieve==1.9.5 # via beautifulsoup4
@@ -252,7 +252,7 @@ webob==1.8.5 # via xblock
wrapt==1.10.5
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.2.6#egg=xblock-drag-and-drop-v2==2.2.6
git+https://github.com/jmbowman/xblock-poll@8e78663fdd3c1d79571eb753d1c601729e9a9325#egg=xblock-poll==1.9.0
xblock-utils==1.2.2
xblock-utils==1.2.3
xblock==1.2.9
xmlsec==1.3.3 # via python3-saml
xss-utils==0.1.2

View File

@@ -8,7 +8,7 @@ configparser==4.0.2 # via importlib-metadata
contextlib2==0.6.0.post1 # via importlib-metadata
coverage==5.0b1
diff-cover==0.9.8
importlib-metadata==1.2.0 # via inflect
importlib-metadata==1.3.0 # via inflect
inflect==3.0.2 # via jinja2-pluralize
jinja2-pluralize==0.3.0 # via diff-cover
jinja2==2.10.3 # via diff-cover, jinja2-pluralize

View File

@@ -55,7 +55,7 @@ git+https://github.com/edx/openedx-chem.git@ff4e3a03d3c7610e47a9af08eb648d8aabe2
click-log==0.3.2
click==7.0
code-annotations==0.3.2
colorama==0.4.1
colorama==0.4.3
configparser==4.0.2
contextlib2==0.6.0.post1
cookies==2.2.1
@@ -127,7 +127,7 @@ edx-django-release-util==0.3.2
edx-django-sites-extensions==2.3.1
edx-django-utils==2.0.2
edx-drf-extensions==2.4.5
edx-enterprise==2.0.30
edx-enterprise==2.0.31
edx-i18n-tools==0.4.8
edx-lint==1.3.0
edx-milestones==0.2.6
@@ -144,7 +144,7 @@ edx-sphinx-theme==1.5.0
edx-submissions==3.0.3
edx-user-state-client==1.1.2
edx-when==0.5.2
edxval==1.1.32
edxval==1.1.33
elasticsearch==1.9.0
entrypoints==0.3
enum34==1.1.6
@@ -173,7 +173,7 @@ httplib2==0.14.0
httpretty==0.9.7
idna==2.8
imagesize==1.1.0 # via sphinx
importlib-metadata==1.2.0
importlib-metadata==1.3.0
inflect==3.0.2
inflection==0.3.1
ipaddress==1.0.23
@@ -238,7 +238,7 @@ polib==1.1.0
psutil==1.2.1
py2neo==3.1.2
py==1.8.0
pyaml==19.4.1
pyaml==19.12.0
pycodestyle==2.5.0
pycontracts==1.7.1
pycountry==19.8.18
@@ -305,8 +305,8 @@ singledispatch==3.4.0.3
six==1.13.0
slumber==0.7.1
snowballstemmer==2.0.0 # via sphinx
social-auth-app-django==2.1.0
social-auth-core==1.7.0
social-auth-app-django==3.1.0
social-auth-core==3.2.0
sorl-thumbnail==12.3
sortedcontainers==2.1.0
soupsieve==1.9.5
@@ -345,7 +345,7 @@ werkzeug==0.16.0
wrapt==1.10.5
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.2.6#egg=xblock-drag-and-drop-v2==2.2.6
git+https://github.com/jmbowman/xblock-poll@8e78663fdd3c1d79571eb753d1c601729e9a9325#egg=xblock-poll==1.9.0
xblock-utils==1.2.2
xblock-utils==1.2.3
xblock==1.2.9
xmlsec==1.3.3
xmltodict==0.12.0

View File

@@ -54,7 +54,7 @@ git+https://github.com/edx/openedx-chem.git@ff4e3a03d3c7610e47a9af08eb648d8aabe2
click-log==0.3.2 # via edx-lint
click==7.0
code-annotations==0.3.2
colorama==0.4.1 # via radon
colorama==0.4.3 # via radon
configparser==4.0.2
contextlib2==0.6.0.post1
cookies==2.2.1 # via moto
@@ -124,7 +124,7 @@ edx-django-release-util==0.3.2
edx-django-sites-extensions==2.3.1
edx-django-utils==2.0.2
edx-drf-extensions==2.4.5
edx-enterprise==2.0.30
edx-enterprise==2.0.31
edx-i18n-tools==0.4.8
edx-lint==1.3.0
edx-milestones==0.2.6
@@ -140,7 +140,7 @@ edx-sga==0.10.0
edx-submissions==3.0.3
edx-user-state-client==1.1.2
edx-when==0.5.2
edxval==1.1.32
edxval==1.1.33
elasticsearch==1.9.0
entrypoints==0.3 # via flake8
enum34==1.1.6
@@ -168,7 +168,7 @@ html5lib==1.0.1
httplib2==0.14.0
httpretty==0.9.7
idna==2.8
importlib-metadata==1.2.0
importlib-metadata==1.3.0
inflect==3.0.2
inflection==0.3.1
ipaddress==1.0.23
@@ -228,7 +228,7 @@ polib==1.1.0
psutil==1.2.1
py2neo==3.1.2
py==1.8.0 # via pytest, tox
pyaml==19.4.1 # via moto
pyaml==19.12.0 # via moto
pycodestyle==2.5.0
pycontracts==1.7.1
pycountry==19.8.18
@@ -292,8 +292,8 @@ simplejson==3.17.0
singledispatch==3.4.0.3
six==1.13.0
slumber==0.7.1
social-auth-app-django==2.1.0
social-auth-core==1.7.0
social-auth-app-django==3.1.0
social-auth-core==3.2.0
sorl-thumbnail==12.3
sortedcontainers==2.1.0
soupsieve==1.9.5
@@ -327,7 +327,7 @@ werkzeug==0.16.0 # via moto
wrapt==1.10.5
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.2.6#egg=xblock-drag-and-drop-v2==2.2.6
git+https://github.com/jmbowman/xblock-poll@8e78663fdd3c1d79571eb753d1c601729e9a9325#egg=xblock-poll==1.9.0
xblock-utils==1.2.2
xblock-utils==1.2.3
xblock==1.2.9
xmlsec==1.3.3
xmltodict==0.12.0 # via moto

View File

@@ -92,28 +92,25 @@ pipeline {
steps {
script {
sshagent(credentials: ['jenkins-worker'], ignoreMissing: true) {
checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: git_branch]],
doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'CloneOption', honorRefspec: true,
noTags: true, shallow: true]], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'jenkins-worker',
refspec: git_refspec, url: "git@github.com:edx/${REPO_NAME}.git"]]]
try {
checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: git_branch]],
doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'CloneOption', honorRefspec: true,
noTags: true, shallow: true]], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'jenkins-worker',
refspec: git_refspec, url: "git@github.com:edx/${REPO_NAME}.git"]]]
for (int i = 1; i <= shardCount; i++) {
unstash "bok-choy-reports-${i}"
for (int i = 1; i <= shardCount; i++) {
unstash "bok-choy-reports-${i}"
}
sh """
export TEST_SUITE=bok-choy
source scripts/jenkins-common.sh
paver coverage --rcfile=common/test/acceptance/.coveragerc
paver upload_coverage_to_s3
"""
} finally {
archiveArtifacts allowEmptyArchive: true, artifacts: 'reports/*.coverage'
sendSplunkFile excludes: '', includes: '**/timing*.log', sizeLimit: '10MB'
}
sh """
export TEST_SUITE=bok-choy
source scripts/jenkins-common.sh
paver coverage --rcfile=common/test/acceptance/.coveragerc
paver upload_coverage_to_s3
"""
}
}
}
post {
always {
script {
archiveArtifacts allowEmptyArchive: true, artifacts: 'reports/*.coverage'
sendSplunkFile excludes: '', includes: '**/timing*.log', sizeLimit: '10MB'
}
}
}