diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fc52c5156e43..3631612f9b39 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -193,6 +193,8 @@ it pauses on the end time.
Blades: Disallow users to enter video url's in http.
+Studio/LMS: Ability to cap the max number of active enrollments in a course
+
LMS: Improve the acessibility of the forum follow post buttons.
Blades: Latex problems are now enabled via use_latex_compiler
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 62d9dc1aa851..ee9ec4ec8821 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -424,6 +424,28 @@ def get_or_create_enrollment(cls, user, course_id):
return enrollment
+ @classmethod
+ def num_enrolled_in(cls, course_id):
+ """
+ Returns the count of active enrollments in a course.
+
+ 'course_id' is the course_id to return enrollments
+ """
+ enrollment_number = CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count()
+
+ return enrollment_number
+
+ @classmethod
+ def is_course_full(cls, course):
+ """
+ Returns a boolean value regarding whether a course has already reached it's max enrollment
+ capacity
+ """
+ is_course_full = False
+ if course.max_student_enrollments_allowed is not None:
+ is_course_full = cls.num_enrolled_in(course.location.course_id) >= course.max_student_enrollments_allowed
+ return is_course_full
+
def update_enrollment(self, mode=None, is_active=None):
"""
Updates an enrollment for a user in a class. This includes options
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 147c58774640..d82cb8cd63e6 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -561,6 +561,12 @@ def change_enrollment(request):
if not has_access(user, course, 'enroll'):
return HttpResponseBadRequest(_("Enrollment is closed"))
+ # see if we have already filled up all allowed enrollments
+ is_course_full = CourseEnrollment.is_course_full(course)
+
+ if is_course_full:
+ return HttpResponseBadRequest(_("Course is full"))
+
# If this course is available in multiple modes, redirect them to a page
# where they can choose which mode they want.
available_modes = CourseMode.modes_for_course(course_id)
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index a7519902c994..d378c2ad245c 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -13,7 +13,7 @@
from xmodule.graders import grader_from_conf
import json
-from xblock.fields import Scope, List, String, Dict, Boolean
+from xblock.fields import Scope, List, String, Dict, Boolean, Integer
from .fields import Date
from xmodule.modulestore.locator import CourseLocator
from django.utils.timezone import UTC
@@ -384,6 +384,9 @@ class CourseFields(object):
display_coursenumber = String(help="An optional display string for the course number that will get rendered in the LMS",
scope=Scope.settings)
+ max_student_enrollments_allowed = Integer(help="Limit the number of students allowed to enroll in this course.",
+ scope=Scope.settings)
+
class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule
diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py
index 9e6354dd3f8a..3154b39002a0 100644
--- a/lms/djangoapps/courseware/tests/test_about.py
+++ b/lms/djangoapps/courseware/tests/test_about.py
@@ -51,3 +51,54 @@ def test_anonymous_user_xml(self):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn(self.xml_data, resp.content)
+
+
+@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
+class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
+ """
+ This test case will check the About page when a course has a capped enrollment
+ """
+ def setUp(self):
+ """
+ Set up the tests
+ """
+ self.course = CourseFactory.create(metadata={"max_student_enrollments_allowed": 1})
+
+ self.about = ItemFactory.create(
+ category="about", parent_location=self.course.location,
+ data="OOGIE BLOOGIE", display_name="overview"
+ )
+ # The following XML course is closed; we're testing that
+ # an about page still appears when the course is already closed
+ self.xml_course_id = 'edX/detached_pages/2014'
+ self.xml_data = "about page 463139"
+
+ def test_enrollment_cap(self):
+ """
+ This test will make sure that enrollment caps are enforced
+ """
+ self.setup_user()
+ url = reverse('about_course', args=[self.course.id])
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn('', resp.content)
+
+ self.enroll(self.course, verify=True)
+
+ # create a new account since the first account is already registered for the course
+ self.email = 'foo_second@test.com'
+ self.password = 'bar'
+ self.username = 'test_second'
+ self.create_account(self.username,
+ self.email, self.password)
+ self.activate_user(self.email)
+ self.login(self.email, self.password)
+
+ # Get the about page again and make sure that the page says that the course is full
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Course is full", resp.content)
+
+ # Try to enroll as well
+ result = self.enroll(self.course)
+ self.assertFalse(result)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index d87a3830e2b6..a47ebac0178c 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -562,6 +562,9 @@ def course_about(request, course_id):
reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format(
reg_url=reverse('register_user'), course_id=course.id)
+ # see if we have already filled up all allowed enrollments
+ is_course_full = CourseEnrollment.is_course_full(course)
+
return render_to_response('courseware/course_about.html',
{'course': course,
'registered': registered,
@@ -569,7 +572,8 @@ def course_about(request, course_id):
'registration_price': registration_price,
'in_cart': in_cart,
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
- 'show_courseware_link': show_courseware_link})
+ 'show_courseware_link': show_courseware_link,
+ 'is_course_full': is_course_full})
@ensure_csrf_cookie
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index 2000c8390057..c22a774fdf0a 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -115,7 +115,7 @@ def _section_course_info(course_id, access):
'course_num': course_num,
'course_name': course_name,
'course_display_name': course.display_name,
- 'enrollment_count': CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count(),
+ 'enrollment_count': CourseEnrollment.num_enrolled_in(course_id),
'has_started': course.has_started(),
'has_ended': course.has_ended(),
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py
index c671b2892251..6e8e39a37415 100644
--- a/lms/djangoapps/instructor/views/legacy.py
+++ b/lms/djangoapps/instructor/views/legacy.py
@@ -110,7 +110,7 @@ def instructor_dashboard(request, course_id):
else:
idash_mode = request.session.get('idash_mode', 'Grades')
- enrollment_number = CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count()
+ enrollment_number = CourseEnrollment.num_enrolled_in(course_id)
# assemble some course statistics for output to instructor
def get_course_stats_table():
diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html
index 3437e57483fb..8eecd634ada1 100644
--- a/lms/templates/courseware/course_about.html
+++ b/lms/templates/courseware/course_about.html
@@ -166,6 +166,10 @@
cost=registration_price)}