Merge branch 'master' into release-mergeback-to-master
This commit is contained in:
@@ -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 '#'
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
21
common/djangoapps/student/templates/email/email_base.html
Normal file
21
common/djangoapps/student/templates/email/email_base.html
Normal 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>
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -123,18 +123,17 @@ class HelperMixin(object):
|
||||
|
||||
def assert_json_failure_response_is_inactive_account(self, response):
|
||||
"""Asserts failure on /login for inactive account looks right."""
|
||||
self.assertEqual(200, response.status_code) # Yes, it's a 200 even though it's a failure.
|
||||
self.assertEqual(400, response.status_code)
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
self.assertFalse(payload.get('success'))
|
||||
self.assertIn('In order to sign in, you need to activate your account.', payload.get('value'))
|
||||
|
||||
def assert_json_failure_response_is_missing_social_auth(self, response):
|
||||
"""Asserts failure on /login for missing social auth looks right."""
|
||||
self.assertContains(
|
||||
response,
|
||||
u"successfully signed in to your %s account, but this account isn't linked" % self.provider.name,
|
||||
status_code=403,
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
self.assertFalse(payload.get('success'))
|
||||
self.assertEqual(payload.get('error_code'), 'third-party-auth-with-no-linked-account')
|
||||
|
||||
def assert_json_failure_response_is_username_collision(self, response):
|
||||
"""Asserts the json response indicates a username collision."""
|
||||
@@ -542,6 +541,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
request.user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
|
||||
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
|
||||
partial_data = strategy.storage.partial.load(partial_pipeline_token)
|
||||
|
||||
# Instrument the pipeline to get to the dashboard with the full
|
||||
# expected state.
|
||||
@@ -561,24 +562,14 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
|
||||
# We should be redirected back to the complete page, setting
|
||||
# the "logged in" cookie for the marketing site.
|
||||
self.assert_logged_in_cookie_redirect(actions.do_complete(
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
|
||||
))
|
||||
self.assert_logged_in_cookie_redirect(self.do_complete(strategy, request, partial_pipeline_token, partial_data))
|
||||
|
||||
# Set the cookie and try again
|
||||
self.set_logged_in_cookies(request)
|
||||
|
||||
# Fire off the auth pipeline to link.
|
||||
self.assert_redirect_after_pipeline_completes(
|
||||
actions.do_complete(
|
||||
request.backend,
|
||||
social_views._do_login, # pylint: disable=protected-access
|
||||
request.user,
|
||||
None,
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME,
|
||||
request=request
|
||||
)
|
||||
self.do_complete(strategy, request, partial_pipeline_token, partial_data)
|
||||
)
|
||||
|
||||
# Now we expect to be in the linked state, with a backend entry.
|
||||
@@ -694,6 +685,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username())
|
||||
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
|
||||
partial_data = strategy.storage.partial.load(partial_pipeline_token)
|
||||
|
||||
self.assert_social_auth_exists_for_user(user, strategy)
|
||||
self.assertTrue(user.is_active)
|
||||
|
||||
@@ -734,7 +728,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
self.set_logged_in_cookies(request)
|
||||
|
||||
self.assert_redirect_after_pipeline_completes(
|
||||
actions.do_complete(request.backend, social_views._do_login, user=user, request=request))
|
||||
self.do_complete(strategy, request, partial_pipeline_token, partial_data, user)
|
||||
)
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request))
|
||||
|
||||
def test_signin_fails_if_account_not_active(self):
|
||||
@@ -793,6 +788,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
|
||||
partial_data = strategy.storage.partial.load(partial_pipeline_token)
|
||||
|
||||
# Begin! Grab the registration page and check the login control on it.
|
||||
self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register'))
|
||||
@@ -846,15 +843,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
|
||||
# We should be redirected back to the complete page, setting
|
||||
# the "logged in" cookie for the marketing site.
|
||||
self.assert_logged_in_cookie_redirect(actions.do_complete(
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
|
||||
))
|
||||
self.assert_logged_in_cookie_redirect(self.do_complete(strategy, request, partial_pipeline_token, partial_data))
|
||||
|
||||
# Set the cookie and try again
|
||||
self.set_logged_in_cookies(request)
|
||||
self.assert_redirect_after_pipeline_completes(
|
||||
actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user, request=request))
|
||||
self.do_complete(strategy, request, partial_pipeline_token, partial_data, created_user)
|
||||
)
|
||||
# Now the user has been redirected to the dashboard. Their third party account should now be linked.
|
||||
self.assert_social_auth_exists_for_user(created_user, strategy)
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True)
|
||||
@@ -974,6 +969,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def do_complete(self, strategy, request, partial_pipeline_token, partial_data, user=None):
|
||||
"""
|
||||
Makes sure that strategy store includes the partial data object before
|
||||
calling actions.do_complete
|
||||
"""
|
||||
strategy.storage.partial.store(partial_data)
|
||||
if not user:
|
||||
user = request.user
|
||||
return actions.do_complete(
|
||||
request.backend, social_views._do_login, user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request, partial_token=partial_pipeline_token
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
@django_utils.override_settings(ECOMMERCE_API_URL=TEST_API_URL)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
82
lms/djangoapps/bulk_email/tests/test_views.py
Normal file
82
lms/djangoapps/bulk_email/tests/test_views.py
Normal 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)
|
||||
18
lms/djangoapps/bulk_email/urls.py
Normal file
18
lms/djangoapps/bulk_email/urls.py
Normal 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',
|
||||
),
|
||||
]
|
||||
72
lms/djangoapps/bulk_email/views.py
Normal file
72
lms/djangoapps/bulk_email/views.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,' +
|
||||
|
||||
48
lms/templates/bulk_email/unsubscribe.html
Normal file
48
lms/templates/bulk_email/unsubscribe.html
Normal 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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user