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

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

View File

@@ -15,16 +15,17 @@
from __future__ import absolute_import
import logging
import six
from six.moves.urllib.parse import urljoin
import six
from django.conf import settings
from django.urls import reverse
from django.http import HttpResponse
from django.template import engines
from django.urls import reverse
from six.moves.urllib.parse import urljoin
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
from xmodule.util.xmodule_django import get_current_request_hostname
from . import Engines
@@ -70,9 +71,13 @@ def marketing_link(name):
elif not enable_mktg_site and name in link_map:
# don't try to reverse disabled marketing links
if link_map[name] is not None:
return reverse(link_map[name])
host_name = get_current_request_hostname()
if all([host_name and 'edge' in host_name, 'http' in link_map[name]]):
return link_map[name]
else:
return reverse(link_map[name])
else:
log.debug("Cannot find corresponding link for name: %s", name)
log.debug(u"Cannot find corresponding link for name: %s", name)
return '#'

View File

@@ -0,0 +1,134 @@
from __future__ import absolute_import, print_function
import csv
import logging
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from django.conf import settings
from django.core.mail.message import EmailMultiAlternatives
from django.template.loader import get_template
from pytz import utc
from os import remove
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseAccessRole
logger = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py lms export_staff_users -d 7 --settings=devstack_docker
$ ./manage.py lms export_staff_users --days 7 --settings=devstack_docker
$ ./manage.py lms export_staff_users --days 7 --dry true --settings=devstack_docker
"""
help = """
This command will export a csv of all users who have logged in within the given days and
have staff access role in active courses (Courses with end date in the future).
"""
def add_arguments(self, parser):
parser.add_argument(
'-d',
'--days',
type=int,
default=7,
help='Indicate the login time period in days starting from today'
)
parser.add_argument(
'-r',
'--dry',
type=str,
help='Indicate that the email should not be sent to author-support'
)
subject = 'Staff users CSV'
to_addresses = ['author-support@edx.org']
from_address = settings.DEFAULT_FROM_EMAIL
txt_template_path = 'email/export_staff_users.txt'
html_template_path = 'email/export_staff_users.html'
csv_filename = 'staff_users.csv'
def write_csv(self, query_set, filename):
"""
Writes the queryset into a csv file with the given filename
Arguments:
query_set: query_set to be converted
filename: filename for the csv
"""
writer = csv.DictWriter(
filename,
fieldnames=['id', 'user__username', 'user__email', 'role']
)
writer.writeheader()
for data_item in query_set:
writer.writerow(data_item)
def handle(self, *args, **kwargs):
days = kwargs['days']
dry = kwargs.get('dry')
if dry:
self.to_addresses = ['sustaining-mavericks@edx.org']
current_date = datetime.now(tz=utc)
starting_date = current_date - timedelta(days=days)
active_courses = CourseOverview.objects.filter(end__gte=current_date).values_list('id', flat=True)
course_access_roles = CourseAccessRole.objects.filter(
role__in=['staff', 'instructor'],
user__last_login__range=(starting_date, current_date),
course_id__in=active_courses,
user__is_staff=False
).values('id', 'user__username', 'user__email', 'role')
if not course_access_roles:
return
with open(self.csv_filename, 'a+') as csv_file:
self.write_csv(
query_set=course_access_roles,
filename=csv_file
)
context = {'time_period': days}
try:
self.send_email(context)
logger.info(
'Sent staff users email for the period {} to {}. Staff users count:{}'.format(
starting_date,
current_date,
course_access_roles.count()
)
)
except Exception:
logger.exception(
'Failed to send staff users email for the period {}-{}'.format(starting_date, current_date)
)
def send_email(self, context):
"""
Sends an email to admin containing a csv of all users who have logged in within the given days and
have staff access role in active courses (Courses with end date in the future).
Arguments:
context: context for the email template
"""
plain_content = self.render_template(self.txt_template_path, context)
html_content = self.render_template(self.html_template_path, context)
with open(self.csv_filename, 'r') as csv_file:
email_message = EmailMultiAlternatives(self.subject, plain_content, self.from_address, to=self.to_addresses)
email_message.attach_alternative(html_content, 'text/html')
email_message.attach(self.csv_filename, csv_file.read(), 'text/csv')
email_message.send()
remove(self.csv_filename)
def render_template(self, path, context):
"""
Takes a template path and context and returns a rendered template
Arguments:
path: path of the file
context: context for the template
"""
txt_template = get_template(path)
return txt_template.render(context)

View File

@@ -0,0 +1,35 @@
"""
Unit tests for export_staff_users management command.
"""
from datetime import timedelta
from django.core import mail
from django.core.management import call_command
from django.test import TestCase
from django.utils.timezone import now
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.tests.factories import CourseAccessRoleFactory, UserFactory
class TestExportStaffUsers(TestCase):
"""
Tests the `export_staff_users` command.
"""
@staticmethod
def create_users_data():
staff_user = UserFactory(last_login=now() - timedelta(days=5))
instructor_user = UserFactory(last_login=now() - timedelta(days=5))
course = CourseOverviewFactory(end=now() + timedelta(days=30))
archived_course = CourseOverviewFactory(end=now() - timedelta(days=30))
course_ids = [course.id, archived_course.id]
for course_id in course_ids:
CourseAccessRoleFactory.create(course_id=course_id, user=staff_user, role="staff")
CourseAccessRoleFactory.create(course_id=course_id, user=instructor_user, role="instructor")
def test_export_staff_users(self):
self.create_users_data()
self.assertEqual(len(mail.outbox), 0)
call_command('export_staff_users', days=7)
self.assertEqual(len(mail.outbox), 1)

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
Dear Admin,
Please find the attached CSV containing a list of all staff users who have logged in within the last {{ time_period }} days
Thanks,
The edX Team

View File

@@ -5,6 +5,7 @@ import unittest
from uuid import uuid4
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from mock import patch
@@ -103,6 +104,10 @@ class TestActivateAccount(TestCase):
response = self.client.get(reverse('dashboard'))
self.assertNotContains(response, expected_message)
def _assert_user_active_state(self, expected_active_state):
user = User.objects.get(username=self.user.username)
self.assertEqual(user.is_active, expected_active_state)
def test_account_activation_notification_on_logistration(self):
"""
Verify that logistration page displays success/error/info messages
@@ -112,15 +117,19 @@ class TestActivateAccount(TestCase):
login_url=reverse('signin_user'),
redirect_url=reverse('dashboard'),
)
self._assert_user_active_state(expected_active_state=False)
# Access activation link, message should say that account has been activated.
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, 'Success! You have activated your account.')
self._assert_user_active_state(expected_active_state=True)
# Access activation link again, message should say that account is already active.
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, 'This account has already been activated.')
self._assert_user_active_state(expected_active_state=True)
# Open account activation page with an invalid activation link,
# there should be an error message displayed.
@@ -137,4 +146,4 @@ class TestActivateAccount(TestCase):
response = self.client.get(reverse('activate', args=[self.registration.activation_key]), follow=True)
self.assertRedirects(response, login_page_url)
self.assertContains(response, SYSTEM_MAINTENANCE_MSG)
assert not self.user.is_active
self._assert_user_active_state(expected_active_state=False)

View File

@@ -12,30 +12,19 @@ from datetime import datetime, timedelta
import ddt
import six
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
from course_modes.models import CourseMode
from django.conf import settings
from django.test import RequestFactory, TestCase
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.timezone import now
from entitlements.tests.factories import CourseEntitlementFactory
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import patch
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from pyquery import PyQuery as pq
from six.moves import range
from course_modes.models import CourseMode
from entitlements.tests.factories import CourseEntitlementFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from student.models import CourseEnrollment, UserProfile
from student.signals import REFUND_ORDER
@@ -46,6 +35,17 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
PASSWORD = 'test'
@@ -222,6 +222,33 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
response = self.client.get(self.path)
self.assertRedirects(response, reverse('account_settings'))
def test_grade_doesnt_appears_before_course_end_date(self):
"""
Verify that learners are not able to see their final grade before the end
of course in the learner dashboard
"""
self.course = CourseFactory.create(end=self.TOMORROW, emit_signals=True)
self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45)
response = self.client.get(reverse('dashboard'))
# The final grade does not appear before the course has ended
self.assertNotContains(response, 'Your final grade:')
self.assertNotContains(response, '<span class="grade-value">45%</span>')
def test_grade_appears_after_course_has_ended(self):
"""
Verify that learners are able to see their final grade of the course in
the learner dashboard after the course had ended
"""
self.course = CourseFactory.create(end=self.THREE_YEARS_AGO, emit_signals=True)
self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45)
response = self.client.get(reverse('dashboard'))
self.assertContains(response, 'Your final grade:')
self.assertContains(response, '<span class="grade-value">45%</span>')
@patch.multiple('django.conf.settings', **MOCK_SETTINGS)
@ddt.data(
*itertools.product(

View File

@@ -18,7 +18,6 @@ urlpatterns = [
url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"),
url(r'^accounts/manage_user_standing', views.manage_user_standing, name='manage_user_standing'),
url(r'^change_setting$', views.change_setting, name='change_setting'),
url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'),
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),

View File

@@ -468,24 +468,6 @@ def disable_account_ajax(request):
return JsonResponse(context)
@login_required
@ensure_csrf_cookie
def change_setting(request):
"""
JSON call to change a profile setting: Right now, location
"""
# TODO (vshnayder): location is no longer used
u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache
if 'location' in request.POST:
u_prof.location = request.POST['location']
u_prof.save()
return JsonResponse({
"success": True,
"location": u_prof.location,
})
@receiver(post_save, sender=User)
def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
"""

View File

@@ -209,6 +209,11 @@ def get(request):
"""Gets the running pipeline's data from the passed request."""
strategy = social_django.utils.load_strategy(request)
token = strategy.session_get('partial_pipeline_token')
if not token:
strategy.session_set('partial_pipeline_token', strategy.session_get('partial_pipeline_token_'))
token = strategy.session_get('partial_pipeline_token')
partial_object = strategy.partial_load(token)
pipeline_data = None
if partial_object:
@@ -560,6 +565,10 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
return (current_provider and
current_provider.slug in [saml_provider.slug for saml_provider in saml_providers_list])
if current_partial:
strategy.session_set('partial_pipeline_token_', current_partial.token)
strategy.storage.partial.store(current_partial)
if not user:
# Use only email for user existence check in case of saml provider
if is_provider_saml():

View File

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

View File

@@ -4,6 +4,15 @@ from __future__ import absolute_import
from third_party_auth.tests.specs import base
def get_localized_name(name):
"""Returns the localizedName from the name object"""
locale = "{}_{}".format(
name["preferredLocale"]["language"],
name["preferredLocale"]["country"]
)
return name['localized'].get(locale, '')
class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for provider.LinkedInOauth2."""
@@ -21,11 +30,29 @@ class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest):
'expires_in': 'expires_in_value',
}
USER_RESPONSE_DATA = {
'lastName': 'lastName_value',
'lastName': {
"localized": {
"en_US": "Doe"
},
"preferredLocale": {
"country": "US",
"language": "en"
}
},
'id': 'id_value',
'firstName': 'firstName_value',
'firstName': {
"localized": {
"en_US": "Doe"
},
"preferredLocale": {
"country": "US",
"language": "en"
}
},
}
def get_username(self):
response_data = self.get_response_data()
return response_data.get('firstName') + response_data.get('lastName')
first_name = get_localized_name(response_data.get('firstName'))
last_name = get_localized_name(response_data.get('lastName'))
return first_name + last_name

View File

@@ -98,7 +98,7 @@ class ThirdPartyOAuthTestMixinFacebook(object):
class ThirdPartyOAuthTestMixinGoogle(object):
"""Tests oauth with the Google backend"""
BACKEND = "google-oauth2"
USER_URL = "https://www.googleapis.com/plus/v1/people/me"
USER_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
# In google-oauth2 responses, the "email" field is used as the user's identifier
UID_FIELD = "email"

View File

@@ -10,6 +10,7 @@ import six.moves.urllib.error
import six.moves.urllib.parse
import six.moves.urllib.request
from django.conf import settings
from django.utils.timezone import now
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
@@ -73,3 +74,9 @@ def has_certificates_enabled(course):
if not settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
return False
return course.cert_html_view_enabled
def should_display_grade(end_date):
if end_date and end_date < now().replace(hour=0, minute=0, second=0, microsecond=0):
return True
return False