From a9476ea50eeb1c3b93bd976403f4e9492961619a Mon Sep 17 00:00:00 2001
From: Daniel Clemente Laboreo
Date: Thu, 23 May 2019 16:33:30 +0300
Subject: [PATCH 01/20] Add last_login and date_joined to the student profile
export
---
lms/djangoapps/instructor/views/api.py | 3 +++
lms/djangoapps/instructor_analytics/basic.py | 3 ++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 1a8aed27ad..3cf7e7e1b4 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -1317,6 +1317,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red
'id', 'username', 'name', 'email', 'language', 'location',
'year_of_birth', 'gender', 'level_of_education', 'mailing_address',
'goals', 'enrollment_mode', 'verification_status',
+ 'last_login', 'date_joined',
]
# Provide human-friendly and translatable names for these features. These names
@@ -1336,6 +1337,8 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red
'goals': _('Goals'),
'enrollment_mode': _('Enrollment Mode'),
'verification_status': _('Verification Status'),
+ 'last_login': _('Last Login'),
+ 'date_joined': _('Date Joined'),
}
if is_course_cohorted(course.id):
diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py
index d45d792446..9f48160918 100644
--- a/lms/djangoapps/instructor_analytics/basic.py
+++ b/lms/djangoapps/instructor_analytics/basic.py
@@ -39,7 +39,8 @@ from student.models import CourseEnrollment, CourseEnrollmentAllowed
log = logging.getLogger(__name__)
-STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
+STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email',
+ 'date_joined', 'last_login')
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
'level_of_education', 'mailing_address', 'goals', 'meta',
'city', 'country')
From 7f623d15d803de5ccba5e5ee3a5a1b14abbbac94 Mon Sep 17 00:00:00 2001
From: Matthew Piatetsky
Date: Tue, 3 Dec 2019 15:07:09 -0500
Subject: [PATCH 02/20] get optimizely from cloudflare
---
lms/templates/widgets/optimizely.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/widgets/optimizely.html b/lms/templates/widgets/optimizely.html
index 28986cf32a..614600b2a5 100644
--- a/lms/templates/widgets/optimizely.html
+++ b/lms/templates/widgets/optimizely.html
@@ -1,5 +1,5 @@
<%page expression_filter="h"/>
% if settings.OPTIMIZELY_PROJECT_ID and not disable_optimizely and not is_from_mobile_app:
-
+
% endif
From 9e4706e7bb40e76c374056f8c9c44040a568b772 Mon Sep 17 00:00:00 2001
From: Robert Raposa
Date: Thu, 5 Dec 2019 16:42:26 -0500
Subject: [PATCH 03/20] remove UPDATE_LOGIN_USER_ERROR_STATUS_CODE toggle
The toggle UPDATE_LOGIN_USER_ERROR_STATUS_CODE was added to roll out a
breaking change for `login_user` auth errors to return a 400 rather than
a 200.
This toggle was enabled in Production on 12/5/2019 with seemingly no
adverse affects.
ARCH-1253
---
.../third_party_auth/tests/specs/base.py | 2 +-
.../djangoapps/user_authn/config/waffle.py | 15 ---------
.../core/djangoapps/user_authn/views/login.py | 11 ++-----
.../user_authn/views/tests/test_login.py | 31 +++++++------------
4 files changed, 14 insertions(+), 45 deletions(-)
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 08dfc6d175..f29099308d 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -123,7 +123,7 @@ 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'))
diff --git a/openedx/core/djangoapps/user_authn/config/waffle.py b/openedx/core/djangoapps/user_authn/config/waffle.py
index 75cb4a801e..ece17d749b 100644
--- a/openedx/core/djangoapps/user_authn/config/waffle.py
+++ b/openedx/core/djangoapps/user_authn/config/waffle.py
@@ -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'
-)
diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py
index 451d88cdbb..268b5f5365 100644
--- a/openedx/core/djangoapps/user_authn/views/login.py
+++ b/openedx/core/djangoapps/user_authn/views/login.py
@@ -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
@@ -406,11 +403,7 @@ def login_user(request):
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
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
index 967f121c4d..49e6d84cda 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
@@ -32,7 +32,6 @@ 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.tests.utils import setup_login_oauth_client
@@ -84,12 +83,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 +115,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 +510,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:
From e7d5b0e6b113c9c3272b8fee1011a39fd1827f6a Mon Sep 17 00:00:00 2001
From: edX requirements bot
Date: Mon, 9 Dec 2019 05:49:49 -0500
Subject: [PATCH 04/20] Updating Python Requirements
---
requirements/edx/development.txt | 4 ++--
requirements/edx/testing.txt | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index a21ac12c02..f68f340a62 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -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
@@ -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
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 361eb7957b..d474a71397 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -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
@@ -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
From 00653433a5370378589ce3a4da4e0cd6825247dc Mon Sep 17 00:00:00 2001
From: Taranjeet Singh
Date: Thu, 13 Sep 2018 12:49:47 +0530
Subject: [PATCH 05/20] Adds optional "unsubscribe" link and api support to let
users opt out of email updates.
Scheduled emails show "unsubscribe" link if waffle switch
`schedules.course_update_show_unsubscribe` is enabled, and
settings.ACE_ENABLED_POLICIES respects `bulk_email_optout`.
API endpoint allows GET/POST requests, which:
* GET asks for confirmation of opt-out
* POST accepts "unsubscribe" or "cancel", where "unsubscribe" creates the
Optout entry, and "cancel" does nothing.
Fixes flaky tests:
* The resolvers handle users in "bins", which are groups that depend on the user ID.
* The test user ID varies depending on the test order.
* This change ensures that the bin requested matches the user for the test.
---
lms/djangoapps/bulk_email/tests/test_views.py | 82 ++++++++++++++++
lms/djangoapps/bulk_email/urls.py | 18 ++++
lms/djangoapps/bulk_email/views.py | 72 ++++++++++++++
lms/templates/bulk_email/unsubscribe.html | 48 +++++++++
lms/urls.py | 4 +
.../ace_common/edx_ace/common/base_body.html | 7 ++
openedx/core/djangoapps/schedules/config.py | 8 +-
.../core/djangoapps/schedules/resolvers.py | 20 +++-
.../schedules/tests/test_resolvers.py | 97 +++++++++++++++++--
openedx/core/djangolib/testing/utils.py | 31 +++---
10 files changed, 363 insertions(+), 24 deletions(-)
create mode 100644 lms/djangoapps/bulk_email/tests/test_views.py
create mode 100644 lms/djangoapps/bulk_email/urls.py
create mode 100644 lms/djangoapps/bulk_email/views.py
create mode 100644 lms/templates/bulk_email/unsubscribe.html
diff --git a/lms/djangoapps/bulk_email/tests/test_views.py b/lms/djangoapps/bulk_email/tests/test_views.py
new file mode 100644
index 0000000000..54151b8b98
--- /dev/null
+++ b/lms/djangoapps/bulk_email/tests/test_views.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+"""
+Test the bulk email opt out view.
+"""
+from six import text_type
+
+import ddt
+from django.http import Http404
+from django.test.client import RequestFactory
+from django.test.utils import override_settings
+from django.urls import reverse
+
+from bulk_email.models import Optout
+from bulk_email.views import opt_out_email_updates
+from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
+from openedx.core.lib.tests import attr
+from student.tests.factories import UserFactory
+from xmodule.modulestore.tests.factories import CourseFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+
+
+@attr(shard=1)
+@ddt.ddt
+@override_settings(SECRET_KEY="test secret key")
+class OptOutEmailUpdatesViewTest(ModuleStoreTestCase):
+ """
+ Check the opt out email functionality.
+ """
+ def setUp(self):
+ super(OptOutEmailUpdatesViewTest, self).setUp()
+ self.user = UserFactory.create(username="testuser1")
+ self.token = UsernameCipher.encrypt('testuser1')
+ self.request_factory = RequestFactory()
+ self.course = CourseFactory.create(run='testcourse1', display_name='Test Course Title')
+ self.url = reverse('bulk_email_opt_out', args=[self.token, text_type(self.course.id)])
+
+ # Ensure we start with no opt-out records
+ self.assertEqual(Optout.objects.count(), 0)
+
+ def test_opt_out_email_confirm(self):
+ """
+ Ensure that the default GET view asks for confirmation.
+ """
+ response = self.client.get(self.url)
+ self.assertContains(response, "Do you want to unsubscribe from emails for Test Course Title?")
+ self.assertEqual(Optout.objects.count(), 0)
+
+ def test_opt_out_email_unsubscribe(self):
+ """
+ Ensure that the POSTing "confirm" creates the opt-out record.
+ """
+ response = self.client.post(self.url, {'submit': 'confirm'})
+ self.assertContains(response, "You have been unsubscribed from emails for Test Course Title.")
+ self.assertEqual(Optout.objects.count(), 1)
+
+ def test_opt_out_email_cancel(self):
+ """
+ Ensure that the POSTing "cancel" does not create the opt-out record
+ """
+ response = self.client.post(self.url, {'submit': 'cancel'})
+ self.assertContains(response, "You have not been unsubscribed from emails for Test Course Title.")
+ self.assertEqual(Optout.objects.count(), 0)
+
+ @ddt.data(
+ ("ZOMG INVALID BASE64 CHARS!!!", "base64url", False),
+ ("Non-ASCII\xff", "base64url", False),
+ ("D6L8Q01ztywqnr3coMOlq0C3DG05686lXX_1ArEd0ok", "base64url", False),
+ ("AAAAAAAAAAA=", "initialization_vector", False),
+ ("nMXVK7PdSlKPOovci-M7iqS09Ux8VoCNDJixLBmj", "aes", False),
+ ("AAAAAAAAAAAAAAAAAAAAAMoazRI7ePLjEWXN1N7keLw=", "padding", False),
+ ("AAAAAAAAAAAAAAAAAAAAACpyUxTGIrUjnpuUsNi7mAY=", "username", False),
+ ("_KHGdCAUIToc4iaRGy7K57mNZiiXxO61qfKT08ExlY8=", "course", 'course-v1:testcourse'),
+ )
+ @ddt.unpack
+ def test_unsubscribe_invalid_token(self, token, message, course):
+ """
+ Make sure that view returns 404 in case token is not valid
+ """
+ request = self.request_factory.get("dummy")
+ with self.assertRaises(Http404) as err:
+ opt_out_email_updates(request, token, course)
+ self.assertIn(message, err)
diff --git a/lms/djangoapps/bulk_email/urls.py b/lms/djangoapps/bulk_email/urls.py
new file mode 100644
index 0000000000..9beea793e1
--- /dev/null
+++ b/lms/djangoapps/bulk_email/urls.py
@@ -0,0 +1,18 @@
+"""
+URLs for bulk_email app
+"""
+
+from django.conf import settings
+from django.conf.urls import url
+
+from bulk_email import views
+
+urlpatterns = [
+ url(
+ r'^email/optout/(?P[a-zA-Z0-9-_=]+)/{}/$'.format(
+ settings.COURSE_ID_PATTERN,
+ ),
+ views.opt_out_email_updates,
+ name='bulk_email_opt_out',
+ ),
+]
diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py
new file mode 100644
index 0000000000..5e59d63c7a
--- /dev/null
+++ b/lms/djangoapps/bulk_email/views.py
@@ -0,0 +1,72 @@
+"""
+Views to support bulk email functionalities like opt-out.
+"""
+
+from __future__ import division
+
+import logging
+
+from six import text_type
+
+from django.contrib.auth.models import User
+from django.http import Http404
+
+from bulk_email.models import Optout
+from courseware.courses import get_course_by_id
+from edxmako.shortcuts import render_to_response
+from lms.djangoapps.discussion.notification_prefs.views import (
+ UsernameCipher,
+ UsernameDecryptionException,
+)
+
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+
+
+log = logging.getLogger(__name__)
+
+
+def opt_out_email_updates(request, token, course_id):
+ """
+ A view that let users opt out of any email updates.
+
+ This meant is meant to be the target of an opt-out link or button.
+ The `token` parameter must decrypt to a valid username.
+ The `course_id` is the string course key of any course.
+
+ Raises a 404 if there are any errors parsing the input.
+ """
+ try:
+ username = UsernameCipher().decrypt(token.encode())
+ user = User.objects.get(username=username)
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_by_id(course_key, depth=0)
+ except UnicodeDecodeError:
+ raise Http404("base64url")
+ except UsernameDecryptionException as exn:
+ raise Http404(text_type(exn))
+ except User.DoesNotExist:
+ raise Http404("username")
+ except InvalidKeyError:
+ raise Http404("course")
+
+ context = {
+ 'course': course,
+ 'cancelled': False,
+ 'confirmed': False,
+ }
+
+ if request.method == 'POST':
+ if request.POST.get('submit') == 'confirm':
+ Optout.objects.get_or_create(user=user, course_id=course.id)
+ log.info(
+ u"User %s (%s) opted out of receiving emails from course %s",
+ user.username,
+ user.email,
+ course_id,
+ )
+ context['confirmed'] = True
+ else:
+ context['cancelled'] = True
+
+ return render_to_response('bulk_email/unsubscribe.html', context)
diff --git a/lms/templates/bulk_email/unsubscribe.html b/lms/templates/bulk_email/unsubscribe.html
new file mode 100644
index 0000000000..270f8dc604
--- /dev/null
+++ b/lms/templates/bulk_email/unsubscribe.html
@@ -0,0 +1,48 @@
+<%page expression_filter="h" />
+<%inherit file="../main.html" />
+<%!
+ from openedx.core.djangolib.markup import Text
+ from django.utils.translation import ugettext as _
+%>
+<%def name="header()">
+%if confirmed:
+ ${Text(_("Unsubscribe Successful"))}
+%elif cancelled:
+ ${Text(_("Unsubscribe Cancelled"))}
+%else:
+ ${Text(_("Confirm Unsubscribe"))}
+%endif
+%def>
+
+<%block name="pagetitle">${header()}%block>
+
+
+
+
+ <%block name="pageheader">${header()}%block>
+
+
+ <%block name="pagecontent">
+ %if confirmed:
+ ${Text(_("You have been unsubscribed from emails for {course}.")).format(
+ course=course.display_name_with_default
+ )}
+ %elif cancelled:
+ ${Text(_("You have not been unsubscribed from emails for {course}.")).format(
+ course=course.display_name_with_default
+ )}
+ %else:
+ ${Text(_("Do you want to unsubscribe from emails for {course}?")).format(
+ course=course.display_name_with_default
+ )}
+
+
+ %endif
+ %block>
+
+
+
diff --git a/lms/urls.py b/lms/urls.py
index 840956af5b..bbf60f4565 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -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[^/]+)/$'.format(
diff --git a/openedx/core/djangoapps/ace_common/templates/ace_common/edx_ace/common/base_body.html b/openedx/core/djangoapps/ace_common/templates/ace_common/edx_ace/common/base_body.html
index 044514b40d..7130554dcf 100644
--- a/openedx/core/djangoapps/ace_common/templates/ace_common/edx_ace/common/base_body.html
+++ b/openedx/core/djangoapps/ace_common/templates/ace_common/edx_ace/common/base_body.html
@@ -177,6 +177,13 @@
{{ contact_mailing_address }}
+ {% if unsubscribe_url %}
+
+
+ {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }}
+
+
+ {% endif %}
diff --git a/openedx/core/djangoapps/schedules/config.py b/openedx/core/djangoapps/schedules/config.py
index ae110465d8..b9e4e73a32 100644
--- a/openedx/core/djangoapps/schedules/config.py
+++ b/openedx/core/djangoapps/schedules/config.py
@@ -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')
diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py
index 9011382635..51d2a139de 100644
--- a/openedx/core/djangoapps/schedules/resolvers.py
+++ b/openedx/core/djangoapps/schedules/resolvers.py
@@ -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))
diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
index ed0687347f..89f9a0b7b4 100644
--- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py
+++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py
@@ -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'])
diff --git a/openedx/core/djangolib/testing/utils.py b/openedx/core/djangolib/testing/utils.py
index 81d38ad7a9..e194768225 100644
--- a/openedx/core/djangolib/testing/utils.py
+++ b/openedx/core/djangolib/testing/utils.py
@@ -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):
From 2b80fdbf665eb4fc18807ef7fd1a31ceba733f27 Mon Sep 17 00:00:00 2001
From: Adeel Khan
Date: Tue, 19 Nov 2019 08:42:15 +0500
Subject: [PATCH 06/20] Automate retry_failed_photo_verification mgt command
This patch would enable a user to run management
command via jenkins job. Verification ids
are injected via a configuration model.
PROD-1005
---
lms/djangoapps/verify_student/admin.py | 12 ++++-
.../retry_failed_photo_verifications.py | 51 ++++++++++++++++---
.../commands/tests/test_verify_student.py | 45 +++++++++++++++-
.../0012_sspverificationretryconfig.py | 31 +++++++++++
lms/djangoapps/verify_student/models.py | 24 ++++++++-
5 files changed, 153 insertions(+), 10 deletions(-)
create mode 100644 lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py
diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py
index 81b0ec64cc..971d2d689c 100644
--- a/lms/djangoapps/verify_student/admin.py
+++ b/lms/djangoapps/verify_student/admin.py
@@ -7,7 +7,9 @@ from __future__ import absolute_import
from django.contrib import admin
-from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification
+from lms.djangoapps.verify_student.models import (
+ ManualVerification, SoftwareSecurePhotoVerification, SSOVerification,
+ SSPVerificationRetryConfig)
@admin.register(SoftwareSecurePhotoVerification)
@@ -39,3 +41,11 @@ class ManualVerificationAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'status', 'reason', 'created_at', 'updated_at',)
raw_id_fields = ('user',)
search_fields = ('user__username', 'reason',)
+
+
+@admin.register(SSPVerificationRetryConfig)
+class SSPVerificationRetryAdmin(admin.ModelAdmin):
+ """
+ Admin for the SSPVerificationRetryConfig table.
+ """
+ pass
diff --git a/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py b/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py
index fd4c3cfbe0..3979e5d850 100644
--- a/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py
+++ b/lms/djangoapps/verify_student/management/commands/retry_failed_photo_verifications.py
@@ -3,9 +3,13 @@ Django admin commands related to verify_student
"""
from __future__ import absolute_import, print_function
+import logging
from django.core.management.base import BaseCommand
+from django.core.management.base import CommandError
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig
+
+log = logging.getLogger('retry_photo_verification')
class Command(BaseCommand):
@@ -20,24 +24,59 @@ class Command(BaseCommand):
"are in a state of 'must_retry'"
)
+ def add_arguments(self, parser):
+
+ parser.add_argument(
+ '--verification-ids',
+ dest='verification_ids',
+ action='store',
+ nargs='+',
+ type=int,
+ help='verifications id used to retry verification'
+ )
+
+ parser.add_argument(
+ '--args-from-database',
+ action='store_true',
+ help='Use arguments from the SSPVerificationRetryConfig model instead of the command line.',
+ )
+
+ def get_args_from_database(self):
+ """ Returns an options dictionary from the current SSPVerificationRetryConfig model. """
+
+ sspv_retry_config = SSPVerificationRetryConfig.current()
+ if not sspv_retry_config.enabled:
+ raise CommandError('SSPVerificationRetryConfig is disabled, but --args-from-database was requested.')
+
+ # We don't need fancy shell-style whitespace/quote handling - none of our arguments are complicated
+ argv = sspv_retry_config.arguments.split()
+
+ parser = self.create_parser('manage.py', 'sspv_retry')
+ return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object
+
def handle(self, *args, **options):
+
+ options = self.get_args_from_database() if options['args_from_database'] else options
+ args = options.get('verification_ids', None)
+
if args:
attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(
- receipt_id__in=args
+ receipt_id__in=options['verification_ids']
)
+ log.info(u"Fetching retry verification ids from config model")
force_must_retry = True
else:
attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry')
force_must_retry = False
- print(u"Attempting to retry {0} failed PhotoVerification submissions".format(len(attempts_to_retry)))
+ log.info(u"Attempting to retry {0} failed PhotoVerification submissions".format(len(attempts_to_retry)))
for index, attempt in enumerate(attempts_to_retry):
- print(u"Retrying submission #{0} (ID: {1}, User: {2})".format(index, attempt.id, attempt.user))
+ log.info(u"Retrying submission #{0} (ID: {1}, User: {2})".format(index, attempt.id, attempt.user))
# Set the attempts status to 'must_retry' so that we can re-submit it
if force_must_retry:
attempt.status = 'must_retry'
attempt.submit(copy_id_photo_from=attempt.copy_id_photo_from)
- print(u"Retry result: {0}".format(attempt.status))
- print("Done resubmitting failed photo verifications")
+ log.info(u"Retry result: {0}".format(attempt.status))
+ log.info("Done resubmitting failed photo verifications")
diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
index 5f8fbd9979..986aa2ffe2 100644
--- a/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
+++ b/lms/djangoapps/verify_student/management/commands/tests/test_verify_student.py
@@ -8,17 +8,21 @@ from __future__ import absolute_import
import boto
from django.conf import settings
from django.core.management import call_command
+from django.core.management.base import CommandError
from django.test import TestCase
from mock import patch
+from testfixtures import LogCapture
from common.test.utils import MockS3Mixin
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSPVerificationRetryConfig
from lms.djangoapps.verify_student.tests.test_models import (
FAKE_SETTINGS,
mock_software_secure_post,
mock_software_secure_post_error
)
-from student.tests.factories import UserFactory
+from student.tests.factories import UserFactory # pylint: disable=import-error, useless-suppression
+
+LOGGER_NAME = 'retry_photo_verification'
# Lots of patching to stub in our own settings, and HTTP posting
@@ -64,3 +68,40 @@ class TestVerifyStudentCommand(MockS3Mixin, TestCase):
call_command('retry_failed_photo_verifications')
attempts_to_retry = SoftwareSecurePhotoVerification.objects.filter(status='must_retry')
assert not attempts_to_retry
+
+ def test_args_from_database(self):
+ """Test management command arguments injected from config model."""
+ # Nothing in the database, should default to disabled
+
+ # pylint: disable=deprecated-method, useless-suppression
+ with self.assertRaisesRegex(CommandError, 'SSPVerificationRetryConfig is disabled*'):
+ call_command('retry_failed_photo_verifications', '--args-from-database')
+
+ # Add a config
+ config = SSPVerificationRetryConfig.current()
+ config.arguments = '--verification-ids 1 2 3'
+ config.enabled = True
+ config.save()
+
+ with patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post_error):
+ self.create_and_submit("RetryRoger")
+
+ with LogCapture(LOGGER_NAME) as log:
+ call_command('retry_failed_photo_verifications')
+
+ log.check_present(
+ (
+ LOGGER_NAME, 'INFO',
+ u"Attempting to retry {0} failed PhotoVerification submissions".format(1)
+ ),
+ )
+
+ with LogCapture(LOGGER_NAME) as log:
+ call_command('retry_failed_photo_verifications', '--args-from-database')
+
+ log.check_present(
+ (
+ LOGGER_NAME, 'INFO',
+ u"Fetching retry verification ids from config model"
+ ),
+ )
diff --git a/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py b/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py
new file mode 100644
index 0000000000..2f150fdd4e
--- /dev/null
+++ b/lms/djangoapps/verify_student/migrations/0012_sspverificationretryconfig.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.26 on 2019-12-10 11:19
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('verify_student', '0011_add_fields_to_sspv'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SSPVerificationRetryConfig',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
+ ('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
+ ('arguments', models.TextField(blank=True, default='', help_text='Useful for manually running a Jenkins job. Specify like --verification-ids 1 2 3')),
+ ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')),
+ ],
+ options={
+ 'verbose_name': 'sspv retry student argument',
+ },
+ ),
+ ]
diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py
index 3f6e3b8ca0..73f2e6b201 100644
--- a/lms/djangoapps/verify_student/models.py
+++ b/lms/djangoapps/verify_student/models.py
@@ -16,13 +16,14 @@ import functools
import json
import logging
import os.path
-import simplejson
import uuid
from datetime import timedelta
from email.utils import formatdate
import requests
+import simplejson
import six
+from config_models.models import ConfigurationModel
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
@@ -44,6 +45,7 @@ from lms.djangoapps.verify_student.ssencrypt import (
)
from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED
from openedx.core.storage import get_storage
+
from .utils import earliest_allowed_verification_date
log = logging.getLogger(__name__)
@@ -1108,3 +1110,23 @@ class VerificationDeadline(TimeStampedModel):
return deadline.deadline
except cls.DoesNotExist:
return None
+
+
+class SSPVerificationRetryConfig(ConfigurationModel): # pylint: disable=model-missing-unicode, useless-suppression
+ """
+ SSPVerificationRetryConfig used to inject arguments
+ to retry_failed_photo_verifications management command
+ """
+
+ class Meta(object):
+ app_label = 'verify_student'
+ verbose_name = 'sspv retry student argument'
+
+ arguments = models.TextField(
+ blank=True,
+ help_text='Useful for manually running a Jenkins job. Specify like --verification-ids 1 2 3',
+ default=''
+ )
+
+ def __str__(self):
+ return six.text_type(self.arguments)
From c926a13f4124603539f5dc666a601e1e89036043 Mon Sep 17 00:00:00 2001
From: "hasnain.naveed"
Date: Fri, 6 Dec 2019 12:59:49 +0500
Subject: [PATCH 07/20] ENT-1961 | Making the manual enrollment reason field
optional via configuration flag `ENABLE_MANUAL_ENROLLMENT_REASON_FIELD`.
---
.../tests/views/test_instructor_dashboard.py | 25 +++++++++++++++++++
.../instructor/views/instructor_dashboard.py | 3 ++-
.../js/instructor_dashboard/membership.js | 2 +-
.../instructor_dashboard_2/membership.html | 14 ++++++-----
4 files changed, 36 insertions(+), 8 deletions(-)
diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
index 6a14f60290..e3c159cfe7 100644
--- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
+++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
@@ -158,6 +158,31 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
content('#field-course-organization b').contents()[0].strip()
)
+ @ddt.data(True, False)
+ def test_membership_reason_field_visibility(self, enbale_reason_field):
+ """
+ Verify that reason field is enabled by site configuration flag 'ENABLE_MANUAL_ENROLLMENT_REASON_FIELD'
+ """
+
+ configuration_values = {
+ "ENABLE_MANUAL_ENROLLMENT_REASON_FIELD": enbale_reason_field
+ }
+ site = Site.objects.first()
+ SiteConfiguration.objects.create(site=site, values=configuration_values, enabled=True)
+
+ url = reverse(
+ 'instructor_dashboard',
+ kwargs={
+ 'course_id': six.text_type(self.course_info.id)
+ }
+ )
+ response = self.client.get(url)
+ reason_field = '' # pylint: disable=line-too-long
+ if enbale_reason_field:
+ self.assertContains(response, reason_field)
+ else:
+ self.assertNotContains(response, reason_field)
+
def test_membership_site_configuration_role(self):
"""
Verify that the role choices set via site configuration are loaded in the membership tab
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index 37b3df7d9c..cbe1a38d3e 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -550,7 +550,8 @@ def _section_membership(course, access):
'update_forum_role_membership',
kwargs={'course_id': six.text_type(course_key)}
),
- 'enrollment_role_choices': enrollment_role_choices
+ 'enrollment_role_choices': enrollment_role_choices,
+ 'is_reason_field_enabled': configuration_helpers.get_value('ENABLE_MANUAL_ENROLLMENT_REASON_FIELD', False)
}
return section_data
diff --git a/lms/static/js/instructor_dashboard/membership.js b/lms/static/js/instructor_dashboard/membership.js
index 1f3164dd4b..a9e5bf73a2 100644
--- a/lms/static/js/instructor_dashboard/membership.js
+++ b/lms/static/js/instructor_dashboard/membership.js
@@ -603,7 +603,7 @@ such that the value can be defined later than this assignment (file load order).
this.$request_response_error = this.$container.find('.request-response-error');
this.$enrollment_button.click(function(event) {
var sendData;
- if (!batchEnroll.$reason_field.val()) {
+ if (batchEnroll.$reason_field.length && !batchEnroll.$reason_field.val()) {
batchEnroll.fail_with_error(gettext('Reason field should not be left blank.'));
return false;
}
diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html
index 2662ad958e..84ac8b7b1f 100644
--- a/lms/templates/instructor/instructor_dashboard_2/membership.html
+++ b/lms/templates/instructor/instructor_dashboard_2/membership.html
@@ -24,12 +24,14 @@ from openedx.core.djangolib.markup import HTML, Text
-
- ${_("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.")}
-
-
+ % if section_data['is_reason_field_enabled']:
+
+ ${_("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.")}
+
+
+ %endif
From 4a828d749a9089d124d97cb05d75f7742eef284f Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Tue, 10 Dec 2019 13:21:03 -0500
Subject: [PATCH 08/20] Remove items from a different dictionary than the one
we're iterating over.
---
lms/djangoapps/course_api/blocks/serializers.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py
index 9a40f64817..dd39d57832 100644
--- a/lms/djangoapps/course_api/blocks/serializers.py
+++ b/lms/djangoapps/course_api/blocks/serializers.py
@@ -104,9 +104,11 @@ class BlockSerializer(serializers.Serializer): # pylint: disable=abstract-metho
if authorization_denial_reason and authorization_denial_message:
data['authorization_denial_reason'] = authorization_denial_reason
data['authorization_denial_message'] = authorization_denial_message
+ cleaned_data = data.copy()
for field in data.keys(): # pylint: disable=consider-iterating-dictionary
if field not in FIELDS_ALLOWED_IN_AUTH_DENIED_CONTENT:
- del data[field]
+ del cleaned_data[field]
+ data = cleaned_data
return data
From 90be24986ae26120af1527ba672d9dcbbf348123 Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri
Date: Sun, 8 Dec 2019 15:48:39 -0500
Subject: [PATCH 09/20] student: Remove unused change_setting endpoint
---
common/djangoapps/student/urls.py | 1 -
common/djangoapps/student/views/management.py | 18 ------------------
2 files changed, 19 deletions(-)
diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py
index 1ba569308f..f124535554 100644
--- a/common/djangoapps/student/urls.py
+++ b/common/djangoapps/student/urls.py
@@ -18,7 +18,6 @@ urlpatterns = [
url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"),
url(r'^accounts/manage_user_standing', views.manage_user_standing, name='manage_user_standing'),
- url(r'^change_setting$', views.change_setting, name='change_setting'),
url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'),
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),
diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py
index 131a614b1b..bd94854b20 100644
--- a/common/djangoapps/student/views/management.py
+++ b/common/djangoapps/student/views/management.py
@@ -468,24 +468,6 @@ def disable_account_ajax(request):
return JsonResponse(context)
-@login_required
-@ensure_csrf_cookie
-def change_setting(request):
- """
- JSON call to change a profile setting: Right now, location
- """
- # TODO (vshnayder): location is no longer used
- u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache
- if 'location' in request.POST:
- u_prof.location = request.POST['location']
- u_prof.save()
-
- return JsonResponse({
- "success": True,
- "location": u_prof.location,
- })
-
-
@receiver(post_save, sender=User)
def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
"""
From 5e3df7aed4869e44ea98745c8ba38eb5e83dd3a7 Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri
Date: Sun, 8 Dec 2019 16:49:52 -0500
Subject: [PATCH 10/20] user_api: Remove unneeded test-only activate_account
---
.../student/tests/test_activate_account.py | 11 +++-
.../core/djangoapps/user_api/accounts/api.py | 29 ---------
.../user_api/accounts/tests/test_api.py | 62 -------------------
.../user_authn/views/tests/test_password.py | 11 ----
.../user_authn/views/tests/test_views.py | 27 +++-----
5 files changed, 17 insertions(+), 123 deletions(-)
diff --git a/common/djangoapps/student/tests/test_activate_account.py b/common/djangoapps/student/tests/test_activate_account.py
index 7db4e674ac..53953b5cfb 100644
--- a/common/djangoapps/student/tests/test_activate_account.py
+++ b/common/djangoapps/student/tests/test_activate_account.py
@@ -5,6 +5,7 @@ import unittest
from uuid import uuid4
from django.conf import settings
+from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from mock import patch
@@ -103,6 +104,10 @@ class TestActivateAccount(TestCase):
response = self.client.get(reverse('dashboard'))
self.assertNotContains(response, expected_message)
+ def _assert_user_active_state(self, expected_active_state):
+ user = User.objects.get(username=self.user.username)
+ self.assertEqual(user.is_active, expected_active_state)
+
def test_account_activation_notification_on_logistration(self):
"""
Verify that logistration page displays success/error/info messages
@@ -112,15 +117,19 @@ class TestActivateAccount(TestCase):
login_url=reverse('signin_user'),
redirect_url=reverse('dashboard'),
)
+ self._assert_user_active_state(expected_active_state=False)
+
# Access activation link, message should say that account has been activated.
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, 'Success! You have activated your account.')
+ self._assert_user_active_state(expected_active_state=True)
# Access activation link again, message should say that account is already active.
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, 'This account has already been activated.')
+ self._assert_user_active_state(expected_active_state=True)
# Open account activation page with an invalid activation link,
# there should be an error message displayed.
@@ -137,4 +146,4 @@ class TestActivateAccount(TestCase):
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, SYSTEM_MAINTENANCE_MSG)
- assert not self.user.is_active
+ self._assert_user_active_state(expected_active_state=False)
diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py
index 4518233ed9..8568e0e781 100644
--- a/openedx/core/djangoapps/user_api/accounts/api.py
+++ b/openedx/core/djangoapps/user_api/accounts/api.py
@@ -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).
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
index eeb45b0372..b9b48fbe3c 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
@@ -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
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
index 34f5ad2180..b1cb6a2df6 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
@@ -12,9 +12,6 @@ from django.test.client import RequestFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
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_authn.views.password_reset import request_password_change
from openedx.core.djangolib.testing.utils import skip_unless_lms
@@ -33,20 +30,12 @@ class TestRequestPasswordChange(CreateAccountMixin, TestCase):
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()
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_views.py b/openedx/core/djangoapps/user_authn/views/tests/test_views.py
index d116eef6b7..6649243d8c 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_views.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_views.py
@@ -38,7 +38,6 @@ 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
@@ -61,7 +60,7 @@ FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']
@skip_unless_lms
@ddt.ddt
class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
- """ Tests for views that update the user's account information. """
+ """ Tests for views that change the user's password. """
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
@@ -91,22 +90,10 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
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)
+ mail.outbox = []
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
def test_password_change(self):
@@ -223,7 +210,7 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
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)
+ self._assert_access_token_destroyed(user)
def test_access_token_invalidation_logged_in(self):
user = User.objects.get(email=self.OLD_EMAIL)
@@ -231,7 +218,7 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
self._create_dot_tokens(user)
response = self._change_password()
self.assertEqual(response.status_code, 200)
- self.assert_access_token_destroyed(user)
+ self._assert_access_token_destroyed(user)
def test_password_change_inactive_user(self):
# Log out the user created during test setup
@@ -309,7 +296,7 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
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."""
+ """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)
@@ -317,7 +304,7 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
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):
+ 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())
@@ -328,7 +315,7 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
@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"
From f539a51901876fbc4370e90206da7d9dc887ede1 Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri
Date: Sun, 8 Dec 2019 17:28:38 -0500
Subject: [PATCH 11/20] user_authn: Move password-related tests to
test_password.py
---
.../{test_views.py => test_logistration.py} | 284 +-----------------
.../user_authn/views/tests/test_password.py | 264 +++++++++++++++-
2 files changed, 262 insertions(+), 286 deletions(-)
rename openedx/core/djangoapps/user_authn/views/tests/{test_views.py => test_logistration.py} (67%)
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_views.py b/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py
similarity index 67%
rename from openedx/core/djangoapps/user_authn/views/tests/test_views.py
rename to openedx/core/djangoapps/user_authn/views/tests/test_logistration.py
index 6649243d8c..63b17a822a 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_views.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_logistration.py
@@ -1,316 +1,36 @@
# -*- 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.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 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"
-
- 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()
-
- 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 = []
-
- @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'(?Phttps?://[^\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 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())
-
@skip_unless_lms
@ddt.ddt
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
index b1cb6a2df6..2b17b4b7d8 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
@@ -2,28 +2,42 @@
"""
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.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'
@@ -76,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'(?Phttps?://[^\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())
From 28d5458a1cfb4d424b8014f3cebe5fbe446000d2 Mon Sep 17 00:00:00 2001
From: hunytalk
Date: Wed, 11 Dec 2019 20:14:01 +0500
Subject: [PATCH 12/20] Custom management command for data migration in
Schedule
---
.../commands/schedules_data_migration.py | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 openedx/core/djangoapps/schedules/management/commands/schedules_data_migration.py
diff --git a/openedx/core/djangoapps/schedules/management/commands/schedules_data_migration.py b/openedx/core/djangoapps/schedules/management/commands/schedules_data_migration.py
new file mode 100644
index 0000000000..84dd5cd100
--- /dev/null
+++ b/openedx/core/djangoapps/schedules/management/commands/schedules_data_migration.py
@@ -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()
From 67cf593e0f277f3e713d2fd4f21ed6a5252e9f10 Mon Sep 17 00:00:00 2001
From: edX Transifex Bot
Date: Wed, 11 Dec 2019 20:33:17 +0000
Subject: [PATCH 13/20] geoip2: update maxmind geolite country database
---
.../static/data/geoip/GeoLite2-Country.mmdb | Bin 4011247 -> 4022770 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb
index 60b7ffbade91e81ad99752eecb83da2d2bf1bdcd..aa6f9941e1d0b245db38b8dc192abb9f41b36f8c 100644
GIT binary patch
literal 4022770
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π%yMbmpEMSwF~$7sESmi+nR}QqPJ%41ShTx@G-pgGbwSfk)+tVP3Y
zu$U9LHjQ=ESeM3@YUsqcvA)_H&@fv-#hKrT#>O3c6ZLLNW3vvkxiVWwalF|OGm*EV
zv9%i8C~hk*;mt0P!R-}yP%LhkooJZ-pF*>X>>-G3HyVeiu{(`D)G+%$g=Q}rdn>b#
z;=UbzKP~Q$e(P230~O6KkO_Ej5m{|r&NmLDaX99Cp^g73X|{lh_(#$>N|~b-g;b+h
z1(<7e9F5bIIbKngdo)g@agrKl3#ibaqQz6C*#7_;r)$yd{}kRci@6PrvuW-@;~bji
zoS#eMLK^4MF#A6;=b>?d7-n@QOQjaw9N?Z|G|;vF>ZR^v{^
zy9{;&_b6k2|7AkmN8^5RnNPcw2Wglee;JqgAy^Ur5gLyw^O)k}!negw(s+l)Q#4+o
z@wD(#(ft0);IlNIqw&0uqyM7u0*#lHf3c{mw)y>6H`J>%UX!BKeO=M~{>#{J(s;`l
zYQHVUc=KqO6aF=g_h{%Bdm8Vn_X8Tb){t5sDf6+SdH>sO{(tIiR9
zLqopjL*rY;?`V8)47JVs-{M$)qG3|~&r1HHs7rwMIr&|gKNNL4s3DiH6aOEYCjb0P
z(R
zO_~3j56!gD)0Fvt$CjBtX0xI>1I-rA0b1;**uNcLnjY9wWf097iz3azZH)w(ndT6h
zv(g-@)rZlXMUCOzx}8Qx&>Tr~G|f?67tJv}_E?(p(Huu}GR@gF$awKeD-&qSD`1*)
zD9Yv!%}EC3qbG#sTr}raUN(Pd&fE3U)c3#51(cVUziKR`xNxWXqBNJGDXSsn7o)kj
z8fKAeqRA^@noAl~V<|;>n{2AnT$bjl$}C57c{Ns`xf0D4MIaTYiP7$-ZjUrqDHxio
z(OjG6>K)}8G}r9dYjvvVD`1JUu5r;^kLLP?OIzQN<~B4plFZXITR^78sWdlH<)(_8
zDQ-?vUjC{rw-8Nv1x#~mA;<4db6c9b(bVMM++IfZ<_rnw_c^Ua^5WQjfgFoQIA
zp}A{ecbMIy@h8bdLIUn7`zp=7XdX#(Z<>eE+=u4Avd7YHeLt1$PgCFjHue2))BFUW
z=-a_G4^hFP1tYtB&BJM$S4dN2-$oAe2y=`wXf}1-EymH(LuvgOn%}GISenPtJYMz+
z#v1YjnitBVwRs}VlW3kr^WuKJg{Edn?Dc&4S_*RWuXqwG`6ZSTP
z9r6ynf_KuqOa7BuJUp5pf0T^oJv85-c`wapY2HWkQJVK_-3JuS7LbYakmADz)p(@+
zzu20OY0>Qe8_g5qooLZ~lIBxl_cO=%G|gwkHZ$IdRy46n^EsN&tNsPW7ZqQk`8v&)
zMLDq_%~xo?s{CuhbiUH8jJfn5$ydLcZ_#{PnRgV;#WH;``#%QXH>k!3G(QwOF+YPc
zNBXgnpD2E+Xl@>(|6I|$2rxg{FxwP{`HJS(a(C%XCEJV>O-}n(@1pNy-@{CF<(B?|
z=8tAVPV*<4f6@Gz=ASfw(c8w%|9_|Xn@rx!_K}$%Hvf=2YT|XqD|eS7&A(-6EO&UTN-L#R
zqm{^ul8OIFXUvV-%4p@%0kiEthfH#4HE2zdjh$ALR*RO|jQWsPKU)21&21hrS_5be
z)Naf`Yl0esXw67#1g*iehAJ}?t(j>JiRL^}x~fQP7_C`o4eul8JdxI{9k1!_Y{sTF
ziq>daWBO#199rfu&Gwo(3FC@@w8o3k7knbEIaDyG;v`yTjnHknt)yi(six4Hm)3mI
zq(fx#Kj{QP^Zd3JptT^ah2(6QA-=V6G)W#_T8k(yDv$7Fv$;K$)?&04r?s@&rnm&H
zrJ~6z&{{H@e21J^lWnBpGI~CjrL~-_M9h5N?6n${%VEST(wZhKIJ0ToT8RNK(ps7R
zrq(L-yO7qZw7#dc8m+Twtxjt%T5HhSNV0cpOQ`t(|CX
zM@y1_A)vJbtsSLkK9e8QP`U(=io4RO>AwDxRAHd{&3pS@|BQ?U;%
zbF=NM)lKr3)@kie%jEyUKCoz!*1@!npmhi>^MxOyF++gC!#dvo75ZrMtF+9Qe~P*$
z?Hi5R9ye&F;=1qJI+oVSw2o8qcoQu`(Yl=0RqDM$
z@k(89eu&9?666|M*O~(~wtVElyuPq$nIS+AP-Hi$_-0zSbiB8A+PaDQv4N52~Vd=&)r3%f@|zevfLe$}F)78Cl_
z>6g+k>)uQB%X?}yRA#1RQze7_A5~`oK1KEQ@eg8-ioH9vyR*A9vtb7)C@7$k0)mL3
zfFg*2f`wfu*reUv-QC@R-G95g>pkbpEzQ0D*Qe3#sCa{H1K{@>2@+Zc`{C-eWzev}Hr;knV|jI?uO
z$ZbzfU-gpPft>O`xpA4j(@knw`JdcQKayFV3FI8+$?hYlRHv0+;(zh77IppTH7%6A`ug3Gua)IF~L7Q9J~gOt7P<3GIGfx#YN^U|vp7vY-|zx*7iPXz%NLWE-gF5$X~~z8yBC=o=WZi+
zIk{`i{t9wR{w7~V&W3~BHKxhh+jZn_B6oe;<_2;%W^%hmZYFn2+t-Ew&F}4P%^l?K
zY|D39(cMk%9@R+UXcq1x_q+-BlY4;N)8rl`_c*zSO!+XmN69^+_Qs{;9#dK87!p><
zUr^@%TH55EQZK7Va?hCNS;Oc4)B9c^XXLN;FPZPl`FH`P-FM$9JZeA>apcKb!m`xu4XaJ(5~VekH#ox!=flCMVVLk6Hdf
z?oSi`GW>f{D02U*EM=0{A%J`*?I8KZIt=oQn|_H_@#dEzpC`XG`ISw-40*?dWy!B-
z!g7YolkY-)1(CJmXMI1vk~;Nzm%Ng{T;x|ZT+J}2%2j5QcgcsQ@eF{BR|eGqYdp5Aiq8N9mHvxu~~aC
zc|3XJe|h55s3wq~NPg$mjkGyU(ooq $&mzaRPC$WJA|yM@|AZ8Z24r`r(n
zdy?NP(^y?kGq1hLw`czOebraWM>r?HKlz#Dr;}HbCx3vUjsoNlB7d-2b{lKXLy=bo
zsy~eU;i{L*YJ~ieF$p|2$XsbS5Z8RBYkZ(`^Rev`5JIJ3y{wn0v
zRsM4F=aE;!Cx5=-1sbrGmy1jzL%<~_Txxik8nkNR3Y9gi>;jVY3Q6iW)deHD8!-re5=tWN)`NzmVjysC{6Syh)C&_31$SxOPPme}jD2G?oXOVPQ@;Rd+NPHpU|6W7h`agG07Kb!U8hPY+i0&ZkMiiRaM
zmY2wa-8!45Vpzph{x>;MLHdzf!`&FSj=K?V19v^#Chj_BvpVh?Cah_=7Vg@umb|FX
zy6a{w2X}q5*}$-?pb6b@H_T)!vF@hq(bC9_+T8?q7;aD8{HNRd#@qq8LvROLz6P}|ZCr452r&In6p8Rk3^_bBtVCkyvz+~aVM
zF|V11voc>?SzG>}X^vM-wq~mlo@h$x1%MhUDgF6@ZO5Az4=i|=DJ;Qt#7@lHy
zs^MvdvIQv1(V4jC;hts6vklJ?G~wKgIol<#L8?F8J4ds}9Md%Fm@ci`Tc
zX{-e9#ue(m2iF+hy%+aBGrQmL0kssK&xR-WAyYn#`-lmT;y$JZIyrgQ+Q}2R&*DC5
zO1allxKEqqGpfmIT+^UK0PgdKFBrb)bhAl<`!eopvNsraA?_=k#|*U{{`-sT8!Pq<}2K