This commit is contained in:
atesker
2019-06-12 11:04:32 -04:00
36 changed files with 246 additions and 270 deletions

View File

@@ -1,3 +1,9 @@
"""
Configuration for Studio API Django application
"""
from __future__ import absolute_import
from django.apps import AppConfig

View File

@@ -1,3 +1,9 @@
"""
URLs for the Studio API app
"""
from __future__ import absolute_import
from django.conf.urls import include, url
urlpatterns = [

View File

@@ -1,3 +1,9 @@
"""
URLs for the Studio API [Course Run]
"""
from __future__ import absolute_import
from rest_framework.routers import DefaultRouter
from .views.course_runs import CourseRunViewSet

View File

@@ -1,3 +1,7 @@
"""HTTP endpoints for the Course Run API."""
from __future__ import absolute_import
from django.conf import settings
from django.http import Http404
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
@@ -8,6 +12,7 @@ from rest_framework.decorators import detail_route
from rest_framework.response import Response
from contentstore.views.course import _accessible_courses_iter, get_course_and_check_access
from ..serializers.course_runs import (
CourseRunCreateSerializer,
CourseRunImageSerializer,

View File

@@ -2,6 +2,8 @@
Utilities for returning XModule JS (used by requirejs)
"""
from __future__ import absolute_import
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage

View File

@@ -14,7 +14,6 @@ from django.conf import settings
from mako.exceptions import TopLevelLookupException
from mako.lookup import TemplateLookup
from openedx.core.djangoapps.theming.helpers import get_template as themed_template
from openedx.core.djangoapps.theming.helpers import get_template_path_with_theme, strip_site_theme_templates_path
from openedx.core.lib.cache_utils import request_cached
@@ -63,7 +62,7 @@ class DynamicTemplateLookup(TemplateLookup):
self._collection.clear()
self._uri_cache.clear()
def adjust_uri(self, uri, calling_uri):
def adjust_uri(self, uri, relativeto):
"""
This method is called by mako when including a template in another template or when inheriting an existing mako
template. The method adjusts the `uri` to make it relative to the calling template's location.
@@ -76,12 +75,12 @@ class DynamicTemplateLookup(TemplateLookup):
that template lookup skips the current theme and looks up the built-in template in standard locations.
"""
# Make requested uri relative to the calling uri.
relative_uri = super(DynamicTemplateLookup, self).adjust_uri(uri, calling_uri)
# Is the calling template (calling_uri) which is including or inheriting current template (uri)
relative_uri = super(DynamicTemplateLookup, self).adjust_uri(uri, relativeto)
# Is the calling template (relativeto) which is including or inheriting current template (uri)
# located inside a theme?
if calling_uri != strip_site_theme_templates_path(calling_uri):
if relativeto != strip_site_theme_templates_path(relativeto):
# Is the calling template trying to include/inherit itself?
if calling_uri == get_template_path_with_theme(relative_uri):
if relativeto == get_template_path_with_theme(relative_uri):
return TopLevelTemplateURI(relative_uri)
return relative_uri
@@ -96,20 +95,14 @@ class DynamicTemplateLookup(TemplateLookup):
If still unable to find a template, it will fallback to the default template directories after stripping off
the prefix path to theme.
"""
# try to get template for the given file from microsite
template = themed_template(uri)
# if microsite template is not present or request is not in microsite then
# let mako find and serve a template
if not template:
if isinstance(uri, TopLevelTemplateURI):
if isinstance(uri, TopLevelTemplateURI):
template = self._get_toplevel_template(uri)
else:
try:
# Try to find themed template, i.e. see if current theme overrides the template
template = super(DynamicTemplateLookup, self).get_template(get_template_path_with_theme(uri))
except TopLevelLookupException:
template = self._get_toplevel_template(uri)
else:
try:
# Try to find themed template, i.e. see if current theme overrides the template
template = super(DynamicTemplateLookup, self).get_template(get_template_path_with_theme(uri))
except TopLevelLookupException:
template = self._get_toplevel_template(uri)
return template

View File

@@ -85,7 +85,7 @@ from student.models import (
from student.signals import REFUND_ORDER
from student.tasks import send_activation_email
from student.text_me_the_app import TextMeTheAppFragmentView
from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.request_rate_limiter import BadRequestRateLimiter, PasswordResetEmailRateLimiter
from util.db import outer_atomic
from util.json_request import JsonResponse
from util.password_policy_validators import normalize_password, validate_password
@@ -664,10 +664,14 @@ def password_change_request_handler(request):
"""
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
password_reset_email_limiter = PasswordResetEmailRateLimiter()
if password_reset_email_limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Password reset rate limit exceeded")
return HttpResponseForbidden()
return HttpResponse(
_("Your previous request is in progress, please try again in a few moments."),
status=403
)
user = request.user
# Prefer logged-in user's email
@@ -681,9 +685,6 @@ def password_change_request_handler(request):
destroy_oauth_tokens(user)
except UserNotFound:
AUDIT_LOG.info("Invalid password reset attempt")
# Increment the rate limit counter
limiter.tick_bad_request_counter(request)
# If enabled, send an email saying that a password reset was attempted, but that there is
# no user associated with the email
if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL',
@@ -703,13 +704,13 @@ def password_change_request_handler(request):
language=settings.LANGUAGE_CODE,
user_context=message_context,
)
ace.send(msg)
except UserAPIInternalError as err:
log.exception('Error occured during password change for user {email}: {error}'
.format(email=email, error=err))
return HttpResponse(_("Some error occured during password change. Please try again"), status=500)
password_reset_email_limiter.tick_request_counter(request)
return HttpResponse(status=200)
else:
return HttpResponseBadRequest(_("No email address provided."))
@@ -770,7 +771,7 @@ def password_reset(request):
else:
# bad user? tick the rate limiter counter
AUDIT_LOG.info("Bad password_reset user passed in.")
limiter.tick_bad_request_counter(request)
limiter.tick_request_counter(request)
return JsonResponse({
'success': True,

View File

@@ -1,26 +0,0 @@
"""
A utility class which wraps the RateLimitMixin 3rd party class to do bad request counting
which can be used for rate limiting
"""
from __future__ import absolute_import
from ratelimitbackend.backends import RateLimitMixin
class BadRequestRateLimiter(RateLimitMixin):
"""
Use the 3rd party RateLimitMixin to help do rate limiting on the Password Reset flows
"""
def is_rate_limit_exceeded(self, request):
"""
Returns if the client has been rated limited
"""
counts = self.get_counters(request)
return sum(counts.values()) >= self.requests
def tick_bad_request_counter(self, request):
"""
Ticks any counters used to compute when rate limt has been reached
"""
self.cache_incr(self.get_cache_key(request))

View File

@@ -0,0 +1,59 @@
"""
A utility class which wraps the RateLimitMixin 3rd party class to do bad request counting
which can be used for rate limiting
"""
from __future__ import absolute_import
from django.conf import settings
from ratelimitbackend.backends import RateLimitMixin
class RequestRateLimiter(RateLimitMixin):
"""
Use the 3rd party RateLimitMixin to help do rate limiting.
"""
def is_rate_limit_exceeded(self, request):
"""
Returns if the client has been rated limited
"""
counts = self.get_counters(request)
return sum(counts.values()) >= self.requests
def tick_request_counter(self, request):
"""
Ticks any counters used to compute when rate limt has been reached
"""
self.cache_incr(self.get_cache_key(request))
class BadRequestRateLimiter(RequestRateLimiter):
"""
Default rate limit is 30 requests for every 5 minutes.
"""
pass
class PasswordResetEmailRateLimiter(RequestRateLimiter):
"""
Rate limiting requests to send password reset emails.
"""
email_rate_limit = getattr(settings, 'PASSWORD_RESET_EMAIL_RATE_LIMIT', {})
requests = email_rate_limit.get('no_of_emails', 1)
cache_timeout_seconds = email_rate_limit.get('per_seconds', 60)
reset_email_cache_prefix = 'resetemail'
def key(self, request, dt):
"""
Returns cache key.
"""
return '%s-%s-%s' % (
self.reset_email_cache_prefix,
self.get_ip(request),
dt.strftime('%Y%m%d%H%M'),
)
def expire_after(self):
"""
Returns timeout for cache keys.
"""
return self.cache_timeout_seconds

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
036aaf0d4bf285266fc31c536191f5afc5b81d60
d808f200f0f77beb2f243e204446a48bdb056ee1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -366,7 +366,7 @@ CREATE TABLE `auth_permission` (
PRIMARY KEY (`id`),
UNIQUE KEY `auth_permission_content_type_id_codename_01ab375a_uniq` (`content_type_id`,`codename`),
CONSTRAINT `auth_permission_content_type_id_2f476e4b_fk_django_co` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2318 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=2321 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `auth_registration`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -2277,7 +2277,7 @@ CREATE TABLE `django_content_type` (
`model` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `django_content_type_app_label_model_76bd3d3b_uniq` (`app_label`,`model`)
) ENGINE=InnoDB AUTO_INCREMENT=770 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=771 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `django_migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -2288,7 +2288,7 @@ CREATE TABLE `django_migrations` (
`name` varchar(255) NOT NULL,
`applied` datetime(6) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=554 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=555 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `django_openid_auth_association`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -3351,6 +3351,32 @@ CREATE TABLE `grades_coursepersistentgradesflag` (
CONSTRAINT `grades_coursepersist_changed_by_id_c8c392d6_fk_auth_user` FOREIGN KEY (`changed_by_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `grades_historicalpersistentsubsectiongradeoverride`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `grades_historicalpersistentsubsectiongradeoverride` (
`id` int(11) NOT NULL,
`created` datetime(6) NOT NULL,
`modified` datetime(6) NOT NULL,
`earned_all_override` double DEFAULT NULL,
`possible_all_override` double DEFAULT NULL,
`earned_graded_override` double DEFAULT NULL,
`possible_graded_override` double DEFAULT NULL,
`history_id` int(11) NOT NULL AUTO_INCREMENT,
`history_date` datetime(6) NOT NULL,
`history_change_reason` varchar(100) DEFAULT NULL,
`history_type` varchar(1) NOT NULL,
`grade_id` bigint(20) unsigned DEFAULT NULL,
`history_user_id` int(11) DEFAULT NULL,
PRIMARY KEY (`history_id`),
KEY `grades_historicalper_history_user_id_05000562_fk_auth_user` (`history_user_id`),
KEY `grades_historicalpersistentsubsectiongradeoverride_id_e30d8953` (`id`),
KEY `grades_historicalpersistent_created_e5fb4d96` (`created`),
KEY `grades_historicalpersistent_modified_7355e846` (`modified`),
KEY `grades_historicalpersistent_grade_id_ecfb45cc` (`grade_id`),
CONSTRAINT `grades_historicalper_history_user_id_05000562_fk_auth_user` FOREIGN KEY (`history_user_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `grades_persistentcoursegrade`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;

View File

@@ -36,7 +36,7 @@ CREATE TABLE `django_migrations` (
`name` varchar(255) NOT NULL,
`applied` datetime(6) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=554 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=555 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

View File

@@ -21,7 +21,7 @@ from lms.djangoapps.certificates.models import (
GeneratedCertificate,
certificate_status_for_student
)
from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.request_rate_limiter import BadRequestRateLimiter
from util.json_request import JsonResponse, JsonResponseBadRequest
from xmodule.modulestore.django import modulestore
@@ -171,12 +171,12 @@ def update_example_certificate(request):
if 'xqueue_body' not in request.POST:
log.info(u"Missing parameter 'xqueue_body' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_body' is required.")
if 'xqueue_header' not in request.POST:
log.info(u"Missing parameter 'xqueue_header' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_header' is required.")
try:
@@ -184,7 +184,7 @@ def update_example_certificate(request):
xqueue_header = json.loads(request.POST['xqueue_header'])
except (ValueError, TypeError):
log.info(u"Could not decode params to example certificate end-point as JSON.")
rate_limiter.tick_bad_request_counter(request)
rate_limiter.tick_request_counter(request)
return JsonResponseBadRequest("Parameters must be JSON-serialized.")
# Attempt to retrieve the example certificate record
@@ -199,7 +199,7 @@ def update_example_certificate(request):
# from the XQueue. Return a 404 and increase the bad request counter
# to protect against a DDOS attack.
log.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key)
rate_limiter.tick_bad_request_counter(request)
rate_limiter.tick_request_counter(request)
raise Http404
if 'error' in xqueue_body:
@@ -217,7 +217,7 @@ def update_example_certificate(request):
# so we can display the example certificate.
download_url = xqueue_body.get('url')
if download_url is None:
rate_limiter.tick_bad_request_counter(request)
rate_limiter.tick_request_counter(request)
log.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid)
return JsonResponseBadRequest(
"Parameter 'download_url' is required for successfully generated certificates."

View File

@@ -253,7 +253,7 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
if course_visibility == COURSE_VISIBILITY_PUBLIC or course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE:
self.assertIn("View Course", resp.content)
else:
self.assertIn("Enroll in", resp.content)
self.assertIn("Enroll Now", resp.content)
class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
@@ -388,7 +388,7 @@ class AboutWithInvitationOnly(SharedModuleStoreTestCase):
url = reverse('about_course', args=[text_type(self.course.id)])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn(u"Enroll in {}".format(self.course.id.course), resp.content.decode('utf-8'))
self.assertIn(u"Enroll Now", resp.content.decode('utf-8'))
# Check that registration button is present
self.assertIn(REG_STR, resp.content)

View File

@@ -316,7 +316,7 @@ class TestSites(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
url = reverse('about_course', args=[text_type(self.course_with_visibility.id)])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn(u"Enroll in {}".format(self.course_with_visibility.id.course), resp.content.decode(resp.charset))
self.assertIn(u"Enroll Now", resp.content.decode(resp.charset))
self.assertNotIn(u"Add {} to Cart ($10)".format(
self.course_with_visibility.id.course),
resp.content.decode(resp.charset)
@@ -326,10 +326,7 @@ class TestSites(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
url = reverse('about_course', args=[text_type(self.course_with_visibility.id)])
resp = self.client.get(url, HTTP_HOST=settings.MICROSITE_TEST_HOSTNAME)
self.assertEqual(resp.status_code, 200)
self.assertNotIn(u"Enroll in {}".format(
self.course_with_visibility.id.course),
resp.content.decode(resp.charset)
)
self.assertNotIn(u"Enroll Now", resp.content.decode(resp.charset))
self.assertIn(u"Add {} to Cart <span>($10 USD)</span>".format(
self.course_with_visibility.id.course
), resp.content.decode(resp.charset))

View File

@@ -65,40 +65,6 @@ DASHBOARD_INFO_FLAG = WaffleFlag(experiments_namespace,
flag_undefined_default=True)
# TODO END: clean up as part of REVEM-199 (End)
# .. toggle_name: experiments.add_audit_deadline
# .. toggle_type: feature_flag
# .. toggle_default: True
# .. toggle_description: Toggle for adding the current course's audit deadline
# .. toggle_category: experiments
# .. toggle_use_cases: monitored_rollout
# .. toggle_creation_date: 2019-5-7
# .. toggle_expiration_date: None
# .. toggle_warnings: None
# .. toggle_tickets: REVEM-329
# .. toggle_status: supported
AUDIT_DEADLINE_FLAG = WaffleFlag(
waffle_namespace=experiments_namespace,
flag_name=u'add_audit_deadline',
flag_undefined_default=True
)
# .. toggle_name: experiments.deprecated_metadata
# .. toggle_type: feature_flag
# .. toggle_default: True
# .. toggle_description: Toggle for using the deprecated method for calculating user metadata
# .. toggle_category: experiments
# .. toggle_use_cases: monitored_rollout
# .. toggle_creation_date: 2019-5-8
# .. toggle_expiration_date: None
# .. toggle_warnings: None
# .. toggle_tickets: REVEM-350
# .. toggle_status: supported
DEPRECATED_METADATA = WaffleFlag(
waffle_namespace=experiments_namespace,
flag_name=u'deprecated_metadata',
flag_undefined_default=False
)
def check_and_get_upgrade_link_and_date(user, enrollment=None, course=None):
"""
@@ -288,9 +254,6 @@ def get_experiment_user_metadata_context(course, user):
"""
Return a context dictionary with the keys used by the user_metadata.html.
"""
if DEPRECATED_METADATA.is_enabled():
return get_deprecated_experiment_user_metadata_context(course, user)
enrollment = None
# TODO: clean up as part of REVO-28 (START)
user_enrollments = None
@@ -329,122 +292,6 @@ def get_experiment_user_metadata_context(course, user):
return context
# pylint: disable=too-many-statements
def get_deprecated_experiment_user_metadata_context(course, user):
"""
Return a context dictionary with the keys used by the user_metadata.html. This is deprecated and will be removed
once we have confirmed that its replacement functions as intended.
"""
enrollment_mode = None
enrollment_time = None
enrollment = None
# TODO: clean up as part of REVO-28 (START)
has_non_audit_enrollments = None
# TODO: clean up as part of REVO-28 (END)
# TODO: clean up as part of REVEM-199 (START)
program_key = None
# TODO: clean up as part of REVEM-199 (END)
try:
# TODO: clean up as part of REVO-28 (START)
user_enrollments = CourseEnrollment.objects.select_related('course').filter(user_id=user.id)
audit_enrollments = user_enrollments.filter(mode='audit')
has_non_audit_enrollments = (len(audit_enrollments) != len(user_enrollments))
# TODO: clean up as part of REVO-28 (END)
# TODO: clean up as part of REVEM-199 (START)
if PROGRAM_INFO_FLAG.is_enabled():
programs = get_programs(course=course.id)
if programs:
# A course can be in multiple programs, but we're just grabbing the first one
program = programs[0]
complete_enrollment = False
has_courses_left_to_purchase = False
total_courses = None
courses = program.get('courses')
courses_left_to_purchase_price = None
courses_left_to_purchase_url = None
program_uuid = program.get('uuid')
status = None
is_eligible_for_one_click_purchase = None
if courses is not None:
total_courses = len(courses)
complete_enrollment = is_enrolled_in_all_courses(courses, user_enrollments)
status = program.get('status')
is_eligible_for_one_click_purchase = program.get('is_program_eligible_for_one_click_purchase')
# Get the price and purchase URL of the program courses the user has yet to purchase. Say a
# program has 3 courses (A, B and C), and the user previously purchased a certificate for A.
# The user is enrolled in audit mode for B. The "left to purchase price" should be the price of
# B+C.
non_audit_enrollments = [en for en in user_enrollments if en not in
audit_enrollments]
courses_left_to_purchase = get_unenrolled_courses(courses, non_audit_enrollments)
if courses_left_to_purchase:
has_courses_left_to_purchase = True
if is_eligible_for_one_click_purchase:
courses_left_to_purchase_price, courses_left_to_purchase_skus = \
get_program_price_and_skus(courses_left_to_purchase)
if courses_left_to_purchase_skus:
courses_left_to_purchase_url = EcommerceService().get_checkout_page_url(
*courses_left_to_purchase_skus, program_uuid=program_uuid)
program_key = {
'uuid': program_uuid,
'title': program.get('title'),
'marketing_url': program.get('marketing_url'),
'status': status,
'is_eligible_for_one_click_purchase': is_eligible_for_one_click_purchase,
'total_courses': total_courses,
'complete_enrollment': complete_enrollment,
'has_courses_left_to_purchase': has_courses_left_to_purchase,
'courses_left_to_purchase_price': courses_left_to_purchase_price,
'courses_left_to_purchase_url': courses_left_to_purchase_url,
}
# TODO: clean up as part of REVEM-199 (END)
enrollment = CourseEnrollment.objects.select_related(
'course'
).get(user_id=user.id, course_id=course.id)
if enrollment.is_active:
enrollment_mode = enrollment.mode
enrollment_time = enrollment.created
except CourseEnrollment.DoesNotExist:
pass # Not enrolled, used the default None values
# upgrade_link and upgrade_date should be None if user has passed their dynamic pacing deadline.
upgrade_link, upgrade_date = check_and_get_upgrade_link_and_date(user, enrollment, course)
has_staff_access = has_staff_access_to_preview_mode(user, course.id)
forum_roles = []
if user.is_authenticated:
forum_roles = list(Role.objects.filter(users=user, course_id=course.id).values_list('name').distinct())
# get user partition data
if user.is_authenticated():
partition_groups = get_all_partitions_for_course(course)
user_partitions = get_user_partition_groups(course.id, partition_groups, user, 'name')
else:
user_partitions = {}
return {
'upgrade_link': upgrade_link,
'upgrade_price': six.text_type(get_cosmetic_verified_display_price(course)),
'enrollment_mode': enrollment_mode,
'enrollment_time': enrollment_time,
'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced',
'upgrade_deadline': upgrade_date,
'audit_access_deadline': get_audit_access_expiration(user, course),
'course_key': course.id,
'course_start': course.start,
'course_end': course.end,
'has_staff_access': has_staff_access,
'forum_roles': forum_roles,
'partition_groups': user_partitions,
# TODO: clean up as part of REVO-28 (START)
'has_non_audit_enrollments': has_non_audit_enrollments,
# TODO: clean up as part of REVO-28 (END)
# TODO: clean up as part of REVEM-199 (START)
'program_key_fields': program_key,
# TODO: clean up as part of REVEM-199 (END)
}
def get_base_experiment_metadata_context(course, user, enrollment, user_enrollments, audit_enrollments):
"""
Return a context dictionary with the keys used by dashboard_metadata.html and user_metadata.html
@@ -482,12 +329,10 @@ def get_audit_access_expiration(user, course):
"""
Return the expiration date for the user's audit access to this course.
"""
if AUDIT_DEADLINE_FLAG.is_enabled():
if not CourseDurationLimitConfig.enabled_for_enrollment(user=user, course_key=course.id):
return None
if not CourseDurationLimitConfig.enabled_for_enrollment(user=user, course_key=course.id):
return None
return get_user_course_expiration_date(user, course)
return None
return get_user_course_expiration_date(user, course)
# TODO: clean up as part of REVEM-199 (START)

View File

@@ -39,7 +39,7 @@ from shoppingcart.reports import (
UniversityRevenueShareReport
)
from student.models import AlreadyEnrolledError, CourseEnrollment, CourseFullError, EnrollmentClosedError
from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.request_rate_limiter import BadRequestRateLimiter
from util.date_utils import get_default_time_display
from util.json_request import JsonResponse
@@ -319,7 +319,7 @@ def get_reg_code_validity(registration_code, request, limiter):
if not reg_code_is_valid:
# tick the rate limiter counter
AUDIT_LOG.info(u"Redemption of a invalid RegistrationCode %s", registration_code)
limiter.tick_bad_request_counter(request)
limiter.tick_request_counter(request)
raise Http404()
return reg_code_is_valid, reg_code_already_redeemed, course_registration

View File

@@ -60,6 +60,12 @@ CAPTURE_CONSOLE_LOG = True
PLATFORM_NAME = ugettext_lazy(u"édX")
PLATFORM_DESCRIPTION = ugettext_lazy(u"Open édX Platform")
# We need to test different scenarios, following setting effectively disbale rate limiting
PASSWORD_RESET_EMAIL_RATE_LIMIT = {
'no_of_emails': 1,
'per_seconds': 1
}
############################ STATIC FILES #############################
# Enable debug so that static assets are served by Django

View File

@@ -445,7 +445,10 @@ XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds
# Used with Email sending
RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5
RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5
PASSWORD_RESET_EMAIL_RATE_LIMIT = {
'no_of_emails': 1,
'per_seconds': 60
}
# Deadline message configurations
COURSE_MESSAGE_ALERT_DURATION_IN_DAYS = 14

View File

@@ -145,7 +145,7 @@
successTitle = gettext('Check Your Email'),
successMessageHtml = HtmlUtils.interpolateHtml(
gettext('{paragraphStart}You entered {boldStart}{email}{boldEnd}. If this email address is associated with your {platform_name} account, we will send a message with password recovery instructions to this email address.{paragraphEnd}' + // eslint-disable-line max-len
'{paragraphStart}If you do not receive a password reset message, verify that you entered the correct email address, or check your spam folder.{paragraphEnd}' + // eslint-disable-line max-len
'{paragraphStart}If you do not receive a password reset message after 1 minute, verify that you entered the correct email address, or check your spam folder.{paragraphEnd}' + // eslint-disable-line max-len
'{paragraphStart}If you need further assistance, {anchorStart}contact technical support{anchorEnd}.{paragraphEnd}'), { // eslint-disable-line max-len
boldStart: HtmlUtils.HTML('<b>'),
boldEnd: HtmlUtils.HTML('</b>'),

View File

@@ -170,7 +170,7 @@ from six import string_types
href_class = "register"
%>
<a href="${reg_href}" class="${href_class}">
${_("Enroll in {course_name}").format(course_name=course.display_number_with_default)}
${_("Enroll Now")}
</a>
<div id="register_error"></div>
%endif

View File

@@ -8,7 +8,9 @@ if and only if the service is deployed in the Open edX installation.
To ensure maximum separation of concerns, and a minimum of interdependencies,
this package should be kept small, thin, and stateless.
"""
from openedx.core.djangoapps.waffle_utils import (WaffleSwitch, WaffleSwitchNamespace)
from __future__ import absolute_import
from openedx.core.djangoapps.waffle_utils import WaffleSwitch, WaffleSwitchNamespace
PROGRAMS_WAFFLE_SWITCH_NAMESPACE = WaffleSwitchNamespace(name='programs')

View File

@@ -1,6 +1,8 @@
"""
django admin pages for program support models
"""
from __future__ import absolute_import
from config_models.admin import ConfigurationModelAdmin
from django.contrib import admin

View File

@@ -1,6 +1,8 @@
"""
Programs Configuration
"""
from __future__ import absolute_import
from django.apps import AppConfig

View File

@@ -1,5 +1,7 @@
"""Models providing Programs support for the LMS and Studio."""
from __future__ import absolute_import
from config_models.models import ConfigurationModel
from django.db import models
from django.utils.translation import ugettext_lazy as _

View File

@@ -1,6 +1,8 @@
"""
This module contains signals / handlers related to programs.
"""
from __future__ import absolute_import
import logging
from django.dispatch import receiver

View File

@@ -1,12 +1,15 @@
# -*- coding: utf-8 -*-
"""Helper functions for working with Programs."""
from __future__ import absolute_import
import datetime
import logging
from collections import defaultdict
from copy import deepcopy
from itertools import chain
from urlparse import urljoin, urlparse, urlunparse
import six
from six.moves.urllib.parse import urljoin, urlparse, urlunparse # pylint: disable=import-error
from dateutil.parser import parse
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -25,7 +28,7 @@ from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.grades.api import CourseGradeFactory
from openedx.core.djangoapps.catalog.utils import get_programs, get_fulfillable_course_runs_for_entitlement
from openedx.core.djangoapps.catalog.utils import get_fulfillable_course_runs_for_entitlement, get_programs
from openedx.core.djangoapps.certificates.api import available_date_for_certificate
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
@@ -95,7 +98,7 @@ class ProgramProgressMeter(object):
self.course_run_ids = []
for enrollment in self.enrollments:
# enrollment.course_id is really a CourseKey (╯ಠ_ಠ╯︵ ┻━┻
enrollment_id = unicode(enrollment.course_id)
enrollment_id = six.text_type(enrollment.course_id)
mode = enrollment.mode
if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
mode = CourseMode.PROFESSIONAL
@@ -140,7 +143,7 @@ class ProgramProgressMeter(object):
program_list.append(program)
# Sort programs by title for consistent presentation.
for program_list in inverted_programs.itervalues():
for program_list in six.itervalues(inverted_programs):
program_list.sort(key=lambda p: p['title'])
return inverted_programs
@@ -326,14 +329,16 @@ class ProgramProgressMeter(object):
if modes_match and certificate_api.is_passing_status(certificate.status):
course_overview = CourseOverview.get_from_id(key)
available_date = available_date_for_certificate(course_overview, certificate)
earliest_course_run_date = min(filter(None, [available_date, earliest_course_run_date]))
earliest_course_run_date = min(
[date for date in [available_date, earliest_course_run_date] if date]
)
# If we're missing a cert for a course, the program isn't completed and we should just bail now
if earliest_course_run_date is None:
return None
# Keep the catalog course date if it's the latest one
program_available_date = max(filter(None, [earliest_course_run_date, program_available_date]))
program_available_date = max([date for date in [earliest_course_run_date, program_available_date] if date])
return program_available_date
@@ -427,7 +432,7 @@ class ProgramProgressMeter(object):
completed_runs, failed_runs = [], []
for certificate in course_run_certificates:
course_data = {
'course_run_id': unicode(certificate['course_key']),
'course_run_id': six.text_type(certificate['course_key']),
'type': self._certificate_mode_translation(certificate['type']),
}
@@ -592,7 +597,7 @@ class ProgramDataExtender(object):
# Here we check the entitlements' expired_at_datetime property rather than filter by the expired_at attribute
# to ensure that the expiration status is as up to date as possible
entitlements = [e for e in entitlements if not e.expired_at_datetime]
courses_with_entitlements = set(unicode(entitlement.course_uuid) for entitlement in entitlements)
courses_with_entitlements = set(six.text_type(entitlement.course_uuid) for entitlement in entitlements)
return [course for course in courses if course['uuid'] not in courses_with_entitlements]
def _filter_out_courses_with_enrollments(self, courses):
@@ -610,10 +615,10 @@ class ProgramDataExtender(object):
is_active=True,
mode__in=self.data['applicable_seat_types']
)
course_runs_with_enrollments = set(unicode(enrollment.course_id) for enrollment in enrollments)
course_runs_with_enrollments = set(six.text_type(enrollment.course_id) for enrollment in enrollments)
courses_without_enrollments = []
for course in courses:
if all(unicode(run['key']) not in course_runs_with_enrollments for run in course['course_runs']):
if all(six.text_type(run['key']) not in course_runs_with_enrollments for run in course['course_runs']):
courses_without_enrollments.append(course)
return courses_without_enrollments
@@ -805,7 +810,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
self.data['instructor_ordering'] = []
sorted_instructor_names = [
' '.join(filter(None, (instructor['given_name'], instructor['family_name'])))
' '.join([name for name in (instructor['given_name'], instructor['family_name']) if name])
for instructor in self.data['instructor_ordering']
]
instructors_to_be_sorted = [

View File

@@ -12,6 +12,8 @@ import attr
import ddt
import pytz
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Max
from edx_ace.channel import ChannelMap, ChannelType
from edx_ace.test_utils import StubPolicy, patch_policies
from edx_ace.utils.date import serialize
@@ -105,6 +107,18 @@ class ScheduleSendEmailTestMixin(FilteredQueryCountMixin):
def _calculate_bin_for_user(self, user):
return user.id % self.task.num_bins
def _next_user_id(self):
"""
Get the next user ID which is a multiple of the bin count and greater
than the current largest user ID. Avoids intermittent ID collisions
with the user created in ModuleStoreTestCase.setUp().
"""
max_user_id = User.objects.aggregate(Max('id'))['id__max']
if max_user_id is None:
max_user_id = 0
num_bins = self.task.num_bins
return max_user_id + num_bins - (max_user_id % num_bins)
def _get_dates(self, offset=None):
current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC))
offset = offset or self.expected_offsets[0]
@@ -296,8 +310,8 @@ class ScheduleSendEmailTestMixin(FilteredQueryCountMixin):
for config in (this_config, other_config):
ScheduleConfigFactory.create(site=config.site)
user1 = UserFactory.create(id=self.task.num_bins)
user2 = UserFactory.create(id=self.task.num_bins * 2)
user1 = UserFactory.create(id=self._next_user_id())
user2 = UserFactory.create(id=user1.id + self.task.num_bins)
current_day, offset, target_day, upgrade_deadline = self._get_dates()
self._schedule_factory(
@@ -323,7 +337,7 @@ class ScheduleSendEmailTestMixin(FilteredQueryCountMixin):
@ddt.data(True, False)
def test_course_end(self, has_course_ended):
user1 = UserFactory.create(id=self.task.num_bins)
user1 = UserFactory.create(id=self._next_user_id())
current_day, offset, target_day, upgrade_deadline = self._get_dates()
end_date_offset = -2 if has_course_ended else 2

View File

@@ -68,7 +68,6 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
OLD_EMAIL = u"walter@graymattertech.com"
NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_ATTEMPTS = 100
INVALID_KEY = u"123abc"
URLCONF_MODULES = ['student_accounts.urls']
@@ -238,17 +237,23 @@ class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
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)
# Make many consecutive bad requests in an attempt to trigger the rate limiter
for __ in range(self.INVALID_ATTEMPTS):
self._change_password(email=self.NEW_EMAIL)
response = self._change_password(email=self.NEW_EMAIL)
self.assertEqual(response.status_code, 403)
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', []),

View File

@@ -96,6 +96,7 @@ docopt==0.6.2
docutils==0.14 # via botocore
edx-ace==0.1.10
edx-analytics-data-api-client==0.15.3
git+https://github.com/edx/edx-bulk-grades@f58f9fa01d95c01211fc94ebb92f0fb1a04bf32b#egg=edx-bulk-grades==0.1.1
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7
edx-completion==2.0.0
@@ -104,7 +105,7 @@ edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1
edx-django-utils==1.0.3
edx-drf-extensions==2.3.1
edx-enterprise==1.6.5
edx-enterprise==1.6.7
edx-i18n-tools==0.4.8
edx-milestones==0.2.2
edx-oauth2-provider==1.2.2
@@ -130,7 +131,7 @@ fs==2.0.18
future==0.17.1 # via pyjwkest
futures==3.2.0 ; python_version == "2.7" # via django-pipeline, python-swiftclient, s3transfer, xblock-utils
geoip2==2.9.0
glob2==0.6
glob2==0.7
gunicorn==19.5.0
help-tokens==1.0.3
html5lib==1.0.1
@@ -227,7 +228,9 @@ sorl-thumbnail==12.3
sortedcontainers==2.1.0
soupsieve==1.9.1 # via beautifulsoup4
sqlparse==0.3.0
git+https://github.com/edx/staff_graded-xblock.git@a1ebad3e379555617bcbd3ceaee65294e2c93c4c#egg=staff_graded-xblock==0.1
stevedore==1.30.1
git+https://github.com/edx/super-csv@1a9dcc89eb65c3ed434b509b84062f8fcabd901b#egg=super-csv==0.2
sympy==1.4
tincan==0.0.5 # via edx-enterprise
unicodecsv==0.14.1

View File

@@ -116,6 +116,7 @@ docopt==0.6.2
docutils==0.14
edx-ace==0.1.10
edx-analytics-data-api-client==0.15.3
git+https://github.com/edx/edx-bulk-grades@f58f9fa01d95c01211fc94ebb92f0fb1a04bf32b#egg=edx-bulk-grades==0.1.1
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7
edx-completion==2.0.0
@@ -124,7 +125,7 @@ edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1
edx-django-utils==1.0.3
edx-drf-extensions==2.3.1
edx-enterprise==1.6.5
edx-enterprise==1.6.7
edx-i18n-tools==0.4.8
edx-lint==1.3.0
edx-milestones==0.2.2
@@ -163,7 +164,7 @@ functools32==3.2.3.post2 ; python_version == "2.7"
future==0.17.1
futures==3.2.0 ; python_version == "2.7"
geoip2==2.9.0
glob2==0.6
glob2==0.7
gunicorn==19.5.0
help-tokens==1.0.3
html5lib==1.0.1
@@ -302,9 +303,11 @@ soupsieve==1.9.1
sphinx==1.8.5
sphinxcontrib-websupport==1.1.2 # via sphinx
sqlparse==0.3.0
git+https://github.com/edx/staff_graded-xblock.git@a1ebad3e379555617bcbd3ceaee65294e2c93c4c#egg=staff_graded-xblock==0.1
stevedore==1.30.1
git+https://github.com/edx/super-csv@1a9dcc89eb65c3ed434b509b84062f8fcabd901b#egg=super-csv==0.2
sympy==1.4
testfixtures==6.8.2
testfixtures==6.9.0
text-unidecode==1.2
tincan==0.0.5
toml==0.10.0

View File

@@ -87,6 +87,10 @@ git+https://github.com/edx/crowdsourcehinter.git@518605f0a95190949fe77bd39158450
-e git+https://github.com/edx/DoneXBlock.git@01a14f3bd80ae47dd08cdbbe2f88f3eb88d00fba#egg=done-xblock
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
git+https://github.com/edx/xblock-lti-consumer.git@v1.1.8#egg=lti_consumer-xblock==1.1.8
git+https://github.com/edx/super-csv@1a9dcc89eb65c3ed434b509b84062f8fcabd901b#egg=super-csv==0.2
git+https://github.com/edx/edx-bulk-grades@f58f9fa01d95c01211fc94ebb92f0fb1a04bf32b#egg=edx-bulk-grades==0.1.1
git+https://github.com/edx/staff_graded-xblock.git@a1ebad3e379555617bcbd3ceaee65294e2c93c4c#egg=staff_graded-xblock==0.1
# Third Party XBlocks

View File

@@ -112,6 +112,7 @@ docopt==0.6.2
docutils==0.14
edx-ace==0.1.10
edx-analytics-data-api-client==0.15.3
git+https://github.com/edx/edx-bulk-grades@f58f9fa01d95c01211fc94ebb92f0fb1a04bf32b#egg=edx-bulk-grades==0.1.1
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7
edx-completion==2.0.0
@@ -120,7 +121,7 @@ edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1
edx-django-utils==1.0.3
edx-drf-extensions==2.3.1
edx-enterprise==1.6.5
edx-enterprise==1.6.7
edx-i18n-tools==0.4.8
edx-lint==1.3.0
edx-milestones==0.2.2
@@ -158,7 +159,7 @@ functools32==3.2.3.post2 ; python_version == "2.7" # via flake8
future==0.17.1
futures==3.2.0 ; python_version == "2.7"
geoip2==2.9.0
glob2==0.6
glob2==0.7
gunicorn==19.5.0
help-tokens==1.0.3
html5lib==1.0.1
@@ -289,9 +290,11 @@ sorl-thumbnail==12.3
sortedcontainers==2.1.0
soupsieve==1.9.1
sqlparse==0.3.0
git+https://github.com/edx/staff_graded-xblock.git@a1ebad3e379555617bcbd3ceaee65294e2c93c4c#egg=staff_graded-xblock==0.1
stevedore==1.30.1
git+https://github.com/edx/super-csv@1a9dcc89eb65c3ed434b509b84062f8fcabd901b#egg=super-csv==0.2
sympy==1.4
testfixtures==6.8.2
testfixtures==6.9.0
text-unidecode==1.2 # via faker
tincan==0.0.5
toml==0.10.0 # via tox