Merge pull request #8927 from edx/renzo/enrollment-api-sans-modulestore
Remove modulestore dependency from Enrollment API
This commit is contained in:
@@ -1,22 +1,24 @@
|
||||
"""
|
||||
Data Aggregation Layer of the Enrollment API. Collects all enrollment specific data into a single
|
||||
source to be used throughout the API.
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from enrollment.errors import (
|
||||
CourseNotFoundError, CourseEnrollmentClosedError, CourseEnrollmentFullError,
|
||||
CourseEnrollmentExistsError, UserNotFoundError, InvalidEnrollmentAttribute
|
||||
)
|
||||
from enrollment.serializers import CourseEnrollmentSerializer, CourseField
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import (
|
||||
CourseEnrollment, NonExistentCourseError, EnrollmentClosedError,
|
||||
CourseFullError, AlreadyEnrolledError, CourseEnrollmentAttribute
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -245,7 +247,7 @@ def _invalid_attribute(attributes):
|
||||
def get_course_enrollment_info(course_id, include_expired=False):
|
||||
"""Returns all course enrollment information for the given course.
|
||||
|
||||
Based on the course id, return all related course information..
|
||||
Based on the course id, return all related course information.
|
||||
|
||||
Args:
|
||||
course_id (str): The course to retrieve enrollment information for.
|
||||
@@ -261,9 +263,12 @@ def get_course_enrollment_info(course_id, include_expired=False):
|
||||
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = modulestore().get_course(course_key)
|
||||
if course is None:
|
||||
|
||||
try:
|
||||
course = CourseOverview.get_from_id(course_key)
|
||||
except CourseOverview.DoesNotExist:
|
||||
msg = u"Requested enrollment information for unknown course {course}".format(course=course_id)
|
||||
log.warning(msg)
|
||||
raise CourseNotFoundError(msg)
|
||||
return CourseField().to_native(course, include_expired=include_expired)
|
||||
else:
|
||||
return CourseField().to_native(course, include_expired=include_expired)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Serializers for all Course Enrollment related return objects.
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
from rest_framework import serializers
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -39,19 +39,18 @@ class CourseField(serializers.RelatedField):
|
||||
"""
|
||||
|
||||
def to_native(self, course, **kwargs):
|
||||
course_id = unicode(course.id)
|
||||
course_modes = ModeSerializer(
|
||||
CourseMode.modes_for_course(course.id, kwargs.get('include_expired', False), only_selectable=False)
|
||||
).data # pylint: disable=no-member
|
||||
|
||||
return {
|
||||
"course_id": course_id,
|
||||
"enrollment_start": course.enrollment_start,
|
||||
"enrollment_end": course.enrollment_end,
|
||||
"course_start": course.start,
|
||||
"course_end": course.end,
|
||||
"invite_only": course.invitation_only,
|
||||
"course_modes": course_modes,
|
||||
'course_id': unicode(course.id),
|
||||
'enrollment_start': course.enrollment_start,
|
||||
'enrollment_end': course.enrollment_end,
|
||||
'course_start': course.start,
|
||||
'course_end': course.end,
|
||||
'invite_only': course.invitation_only,
|
||||
'course_modes': course_modes,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
@@ -26,8 +26,9 @@ from util.models import RateLimitConfiguration
|
||||
from util.testing import UrlResetMixin
|
||||
from enrollment import api
|
||||
from enrollment.errors import CourseEnrollmentError
|
||||
from openedx.core.lib.django_test_client_utils import get_absolute_url
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.user_api.models import UserOrgTag
|
||||
from openedx.core.lib.django_test_client_utils import get_absolute_url
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.models import CourseEnrollment
|
||||
from embargo.test_utils import restrict_course
|
||||
@@ -47,6 +48,8 @@ class EnrollmentTestMixin(object):
|
||||
mode=CourseMode.HONOR,
|
||||
is_active=None,
|
||||
enrollment_attributes=None,
|
||||
min_mongo_calls=0,
|
||||
max_mongo_calls=0,
|
||||
):
|
||||
"""
|
||||
Enroll in the course and verify the response's status code. If the expected status is 200, also validates
|
||||
@@ -77,31 +80,33 @@ class EnrollmentTestMixin(object):
|
||||
if as_server:
|
||||
extra['HTTP_X_EDX_API_KEY'] = self.API_KEY
|
||||
|
||||
with patch('enrollment.views.audit_log') as mock_audit_log:
|
||||
url = reverse('courseenrollments')
|
||||
response = self.client.post(url, json.dumps(data), content_type='application/json', **extra)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
# Verify that the modulestore is queried as expected.
|
||||
with check_mongo_calls_range(min_finds=min_mongo_calls, max_finds=max_mongo_calls):
|
||||
with patch('enrollment.views.audit_log') as mock_audit_log:
|
||||
url = reverse('courseenrollments')
|
||||
response = self.client.post(url, json.dumps(data), content_type='application/json', **extra)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
if expected_status == status.HTTP_200_OK:
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(course_id, data['course_details']['course_id'])
|
||||
if expected_status == status.HTTP_200_OK:
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(course_id, data['course_details']['course_id'])
|
||||
|
||||
if mode is not None:
|
||||
self.assertEqual(mode, data['mode'])
|
||||
if mode is not None:
|
||||
self.assertEqual(mode, data['mode'])
|
||||
|
||||
if is_active is not None:
|
||||
self.assertEqual(is_active, data['is_active'])
|
||||
else:
|
||||
self.assertTrue(data['is_active'])
|
||||
if is_active is not None:
|
||||
self.assertEqual(is_active, data['is_active'])
|
||||
else:
|
||||
self.assertTrue(data['is_active'])
|
||||
|
||||
if as_server:
|
||||
# Verify that an audit message was logged.
|
||||
self.assertTrue(mock_audit_log.called)
|
||||
if as_server:
|
||||
# Verify that an audit message was logged.
|
||||
self.assertTrue(mock_audit_log.called)
|
||||
|
||||
# If multiple enrollment calls are made in the scope of a
|
||||
# single test, we want to validate that audit messages are
|
||||
# logged for each call.
|
||||
mock_audit_log.reset_mock()
|
||||
# If multiple enrollment calls are made in the scope of a
|
||||
# single test, we want to validate that audit messages are
|
||||
# logged for each call.
|
||||
mock_audit_log.reset_mock()
|
||||
|
||||
return response
|
||||
|
||||
@@ -141,6 +146,10 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
self.rate_limit, rate_duration = throttle.parse_rate(throttle.rate)
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
# Load a CourseOverview. This initial load should result in a cache
|
||||
# miss; the modulestore is queried and course metadata is cached.
|
||||
__ = CourseOverview.get_from_id(self.course.id)
|
||||
|
||||
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
|
||||
self.other_user = UserFactory.create()
|
||||
self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
@@ -382,6 +391,10 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
@ddt.unpack
|
||||
def test_get_course_details_course_dates(self, start_datetime, end_datetime, expected_start, expected_end):
|
||||
course = CourseFactory.create(start=start_datetime, end=end_datetime)
|
||||
# Load a CourseOverview. This initial load should result in a cache
|
||||
# miss; the modulestore is queried and course metadata is cached.
|
||||
__ = CourseOverview.get_from_id(course.id)
|
||||
|
||||
self.assert_enrollment_status(course_id=unicode(course.id))
|
||||
|
||||
# Check course details
|
||||
@@ -411,7 +424,12 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
self.assertEqual(data[0]['course_details']['course_end'], expected_end)
|
||||
|
||||
def test_with_invalid_course_id(self):
|
||||
self.assert_enrollment_status(course_id='entirely/fake/course', expected_status=status.HTTP_400_BAD_REQUEST)
|
||||
self.assert_enrollment_status(
|
||||
course_id='entirely/fake/course',
|
||||
expected_status=status.HTTP_400_BAD_REQUEST,
|
||||
min_mongo_calls=3,
|
||||
max_mongo_calls=4
|
||||
)
|
||||
|
||||
def test_get_enrollment_details_bad_course(self):
|
||||
resp = self.client.get(
|
||||
@@ -797,7 +815,12 @@ class EnrollmentEmbargoTest(EnrollmentTestMixin, UrlResetMixin, ModuleStoreTestC
|
||||
def setUp(self):
|
||||
""" Create a course and user, then log in. """
|
||||
super(EnrollmentEmbargoTest, self).setUp('embargo')
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
# Load a CourseOverview. This initial load should result in a cache
|
||||
# miss; the modulestore is queried and course metadata is cached.
|
||||
__ = CourseOverview.get_from_id(self.course.id)
|
||||
|
||||
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
|
||||
self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
self.url = reverse('courseenrollments')
|
||||
|
||||
@@ -10,16 +10,19 @@ file and check it in at the same time as your model changes. To do that,
|
||||
2. ./manage.py lms schemamigration student --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from collections import defaultdict, OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from functools import total_ordering
|
||||
import hashlib
|
||||
from importlib import import_module
|
||||
import json
|
||||
import logging
|
||||
from pytz import UTC
|
||||
import uuid
|
||||
from collections import defaultdict, OrderedDict
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
from urllib import urlencode
|
||||
import uuid
|
||||
|
||||
import analytics
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -33,28 +36,21 @@ from django.dispatch import receiver, Signal
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import ugettext_noop
|
||||
from django_countries.fields import CountryField
|
||||
from config_models.models import ConfigurationModel
|
||||
from track import contexts
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
from eventtracking import tracker
|
||||
from importlib import import_module
|
||||
|
||||
from south.modelsinspector import add_introspection_rules
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
import lms.lib.comment_client as cc
|
||||
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
|
||||
from util.query import use_read_replica_if_available
|
||||
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from functools import total_ordering
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from south.modelsinspector import add_introspection_rules
|
||||
from track import contexts
|
||||
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
|
||||
|
||||
from certificates.models import GeneratedCertificate
|
||||
from course_modes.models import CourseMode
|
||||
import lms.lib.comment_client as cc
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
|
||||
from util.query import use_read_replica_if_available
|
||||
|
||||
import analytics
|
||||
|
||||
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -1064,18 +1060,15 @@ class CourseEnrollment(models.Model):
|
||||
"""
|
||||
# All the server-side checks for whether a user is allowed to enroll.
|
||||
try:
|
||||
course = modulestore().get_course(course_key)
|
||||
except ItemNotFoundError:
|
||||
log.warning(
|
||||
u"User %s failed to enroll in non-existent course %s",
|
||||
user.username,
|
||||
course_key.to_deprecated_string(),
|
||||
)
|
||||
raise NonExistentCourseError
|
||||
course = CourseOverview.get_from_id(course_key)
|
||||
except CourseOverview.DoesNotExist:
|
||||
# This is here to preserve legacy behavior which allowed enrollment in courses
|
||||
# announced before the start of content creation.
|
||||
if check_access:
|
||||
log.warning(u"User %s failed to enroll in non-existent course %s", user.username, unicode(course_key))
|
||||
raise NonExistentCourseError
|
||||
|
||||
if check_access:
|
||||
if course is None:
|
||||
raise NonExistentCourseError
|
||||
if CourseEnrollment.is_enrollment_closed(user, course):
|
||||
log.warning(
|
||||
u"User %s failed to enroll in course %s because enrollment is closed",
|
||||
@@ -1320,7 +1313,8 @@ class CourseEnrollment(models.Model):
|
||||
|
||||
@property
|
||||
def course(self):
|
||||
return modulestore().get_course(self.course_id)
|
||||
# Deprecated. Please use the `course_overview` property instead.
|
||||
return self.course_overview
|
||||
|
||||
@property
|
||||
def course_overview(self):
|
||||
@@ -1334,7 +1328,6 @@ class CourseEnrollment(models.Model):
|
||||
become stale.
|
||||
"""
|
||||
if not self._course_overview:
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
try:
|
||||
self._course_overview = CourseOverview.get_from_id(self.course_id)
|
||||
except (CourseOverview.DoesNotExist, IOError):
|
||||
|
||||
@@ -2,26 +2,27 @@
|
||||
Unit tests for getting the list of courses for a user through iterating all courses and
|
||||
by reversing group name formats.
|
||||
"""
|
||||
import mock
|
||||
from mock import patch, Mock
|
||||
import unittest
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from django.conf import settings
|
||||
from django.test.client import Client
|
||||
import mock
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import GlobalStaff
|
||||
from student.tests.factories import UserFactory
|
||||
from student.views import get_course_enrollments
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from django.test.client import Client
|
||||
from student.models import CourseEnrollment
|
||||
from student.views import get_course_enrollments
|
||||
from util.milestones_helpers import (
|
||||
get_pre_requisite_courses_not_completed,
|
||||
set_prerequisite_courses,
|
||||
seed_milestone_relationship_types
|
||||
)
|
||||
import unittest
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class TestCourseListing(ModuleStoreTestCase):
|
||||
@@ -91,10 +92,12 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
course_key = mongo_store.make_course_key('Org1', 'Course1', 'Run1')
|
||||
self._create_course_with_access_groups(course_key, default_store=ModuleStoreEnum.Type.mongo)
|
||||
|
||||
with patch('xmodule.modulestore.mongo.base.MongoKeyValueStore', Mock(side_effect=Exception)):
|
||||
with mock.patch('xmodule.modulestore.mongo.base.MongoKeyValueStore', mock.Mock(side_effect=Exception)):
|
||||
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
|
||||
|
||||
# get courses through iterating all courses
|
||||
# Invalidate (e.g., delete) the corresponding CourseOverview, forcing get_course to be called.
|
||||
CourseOverview.objects.filter(id=course_key).delete()
|
||||
|
||||
courses_list = list(get_course_enrollments(self.student, None, []))
|
||||
self.assertEqual(courses_list, [])
|
||||
|
||||
|
||||
@@ -112,10 +112,10 @@ class TestRecentEnrollments(ModuleStoreTestCase):
|
||||
recent_course_list = _get_recently_enrolled_courses(courses_list)
|
||||
self.assertEqual(len(recent_course_list), 5)
|
||||
|
||||
self.assertEqual(recent_course_list[1].course, courses[0])
|
||||
self.assertEqual(recent_course_list[2].course, courses[1])
|
||||
self.assertEqual(recent_course_list[3].course, courses[2])
|
||||
self.assertEqual(recent_course_list[4].course, courses[3])
|
||||
self.assertEqual(recent_course_list[1].course.id, courses[0].id)
|
||||
self.assertEqual(recent_course_list[2].course.id, courses[1].id)
|
||||
self.assertEqual(recent_course_list[3].course.id, courses[2].id)
|
||||
self.assertEqual(recent_course_list[4].course.id, courses[3].id)
|
||||
|
||||
def test_dashboard_rendering(self):
|
||||
"""
|
||||
|
||||
@@ -494,9 +494,9 @@ class DashboardTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Check that the student dashboard makes use of course metadata caching.
|
||||
|
||||
The first time the student dashboard displays a specific course, it will
|
||||
make a call to the module store. After that first request, though, the
|
||||
course's metadata should be cached as a CourseOverview.
|
||||
After enrolling a student in a course, that course's metadata should be
|
||||
cached as a CourseOverview. The student dashboard should never have to make
|
||||
calls to the modulestore.
|
||||
|
||||
Arguments:
|
||||
modulestore_type (ModuleStoreEnum.Type): Type of modulestore to create
|
||||
@@ -511,23 +511,21 @@ class DashboardTest(ModuleStoreTestCase):
|
||||
involve adding fields to CourseOverview so that loading a full
|
||||
CourseDescriptor isn't necessary.
|
||||
"""
|
||||
# Create a course, log in the user, and enroll them in the course.
|
||||
# Create a course and log in the user.
|
||||
test_course = CourseFactory.create(default_store=modulestore_type)
|
||||
self.client.login(username="jack", password="test")
|
||||
CourseEnrollment.enroll(self.user, test_course.id)
|
||||
|
||||
# The first request will result in a modulestore query.
|
||||
# Enrolling the user in the course will result in a modulestore query.
|
||||
with check_mongo_calls(expected_mongo_calls):
|
||||
response_1 = self.client.get(reverse('dashboard'))
|
||||
self.assertEquals(response_1.status_code, 200)
|
||||
CourseEnrollment.enroll(self.user, test_course.id)
|
||||
|
||||
# Subsequent requests will only result in SQL queries to load the
|
||||
# CourseOverview object that has been created.
|
||||
with check_mongo_calls(0):
|
||||
response_1 = self.client.get(reverse('dashboard'))
|
||||
self.assertEquals(response_1.status_code, 200)
|
||||
response_2 = self.client.get(reverse('dashboard'))
|
||||
self.assertEquals(response_2.status_code, 200)
|
||||
response_3 = self.client.get(reverse('dashboard'))
|
||||
self.assertEquals(response_3.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@patch.dict(settings.FEATURES, {"IS_EDX_DOMAIN": True})
|
||||
|
||||
@@ -1353,7 +1353,7 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
otherwise a publish will be signalled at the end of the bulk operation
|
||||
|
||||
Arguments:
|
||||
library_updated - library_updated to which the signal applies
|
||||
library_key - library_key to which the signal applies
|
||||
"""
|
||||
signal_handler = getattr(self, 'signal_handler', None)
|
||||
if signal_handler:
|
||||
@@ -1363,6 +1363,14 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
else:
|
||||
signal_handler.send("library_updated", library_key=library_key)
|
||||
|
||||
def _emit_course_deleted_signal(self, course_key):
|
||||
"""
|
||||
Helper method used to emit the course_deleted signal.
|
||||
"""
|
||||
signal_handler = getattr(self, 'signal_handler', None)
|
||||
if signal_handler:
|
||||
signal_handler.send("course_deleted", course_key=course_key)
|
||||
|
||||
|
||||
def only_xmodules(identifier, entry_points):
|
||||
"""Only use entry_points that are supplied by the xmodule package"""
|
||||
|
||||
@@ -87,11 +87,13 @@ class SignalHandler(object):
|
||||
do the actual work.
|
||||
"""
|
||||
course_published = django.dispatch.Signal(providing_args=["course_key"])
|
||||
course_deleted = django.dispatch.Signal(providing_args=["course_key"])
|
||||
library_updated = django.dispatch.Signal(providing_args=["library_key"])
|
||||
|
||||
_mapping = {
|
||||
"course_published": course_published,
|
||||
"library_updated": library_updated
|
||||
"course_deleted": course_deleted,
|
||||
"library_updated": library_updated,
|
||||
}
|
||||
|
||||
def __init__(self, modulestore_class):
|
||||
|
||||
@@ -167,6 +167,8 @@ class DraftModuleStore(MongoModuleStore):
|
||||
self.collection.remove(course_query, multi=True)
|
||||
self.delete_all_asset_metadata(course_key, user_id)
|
||||
|
||||
self._emit_course_deleted_signal(course_key)
|
||||
|
||||
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs):
|
||||
"""
|
||||
Only called if cloning within this store or if env doesn't set up mixed.
|
||||
|
||||
@@ -2443,6 +2443,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
# in case the course is later restored.
|
||||
# super(SplitMongoModuleStore, self).delete_course(course_key, user_id)
|
||||
|
||||
self._emit_course_deleted_signal(course_key)
|
||||
|
||||
@contract(block_map="dict(BlockKey: dict)", block_key=BlockKey)
|
||||
def inherit_settings(
|
||||
self, block_map, block_key, inherited_settings_map, inheriting_settings=None, inherited_from=None
|
||||
|
||||
@@ -2414,6 +2414,34 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
self.assertEqual(receiver.call_count, 0)
|
||||
self.assertEqual(receiver.call_count, 0)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_deleted_signal(self, default):
|
||||
with MongoContentstoreBuilder().build() as contentstore:
|
||||
self.store = MixedModuleStore(
|
||||
contentstore=contentstore,
|
||||
create_modulestore_instance=create_modulestore_instance,
|
||||
mappings={},
|
||||
signal_handler=SignalHandler(MixedModuleStore),
|
||||
**self.OPTIONS
|
||||
)
|
||||
self.addCleanup(self.store.close_all_connections)
|
||||
|
||||
with self.store.default_store(default):
|
||||
self.assertIsNotNone(self.store.thread_cache.default_store.signal_handler)
|
||||
|
||||
with mock_signal_receiver(SignalHandler.course_deleted) as receiver:
|
||||
self.assertEqual(receiver.call_count, 0)
|
||||
|
||||
# Create a course
|
||||
course = self.store.create_course('org_x', 'course_y', 'run_z', self.user_id)
|
||||
course_key = course.id
|
||||
|
||||
# Delete the course
|
||||
course = self.store.delete_course(course_key, self.user_id)
|
||||
|
||||
# Verify that the signal was emitted
|
||||
self.assertEqual(receiver.call_count, 1)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr('mongo')
|
||||
|
||||
@@ -10,8 +10,8 @@ Note: The access control logic in this file does NOT check for enrollment in
|
||||
If enrollment is to be checked, use get_course_with_access in courseware.courses.
|
||||
It is a wrapper around has_access that additionally checks for enrollment.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
from django.conf import settings
|
||||
@@ -245,6 +245,58 @@ def _can_load_course_on_mobile(user, course):
|
||||
)
|
||||
|
||||
|
||||
def _can_enroll_courselike(user, courselike):
|
||||
"""
|
||||
Ascertain if the user can enroll in the given courselike object.
|
||||
|
||||
Arguments:
|
||||
user (User): The user attempting to enroll.
|
||||
courselike (CourseDescriptor or CourseOverview): The object representing the
|
||||
course in which the user is trying to enroll.
|
||||
|
||||
Returns:
|
||||
AccessResponse, indicating whether the user can enroll.
|
||||
"""
|
||||
enrollment_domain = courselike.enrollment_domain
|
||||
# Courselike objects (e.g., course descriptors and CourseOverviews) have an attribute named `id`
|
||||
# which actually points to a CourseKey. Sigh.
|
||||
course_key = courselike.id
|
||||
|
||||
# If using a registration method to restrict enrollment (e.g., Shibboleth)
|
||||
if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and enrollment_domain:
|
||||
if user is not None and user.is_authenticated() and \
|
||||
ExternalAuthMap.objects.filter(user=user, external_domain=enrollment_domain):
|
||||
debug("Allow: external_auth of " + enrollment_domain)
|
||||
reg_method_ok = True
|
||||
else:
|
||||
reg_method_ok = False
|
||||
else:
|
||||
reg_method_ok = True
|
||||
|
||||
# If the user appears in CourseEnrollmentAllowed paired with the given course key,
|
||||
# they may enroll. Note that as dictated by the legacy database schema, the filter
|
||||
# call includes a `course_id` kwarg which requires a CourseKey.
|
||||
if user is not None and user.is_authenticated():
|
||||
if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course_key):
|
||||
return ACCESS_GRANTED
|
||||
|
||||
if _has_staff_access_to_descriptor(user, courselike, course_key):
|
||||
return ACCESS_GRANTED
|
||||
|
||||
if courselike.invitation_only:
|
||||
debug("Deny: invitation only")
|
||||
return ACCESS_DENIED
|
||||
|
||||
now = datetime.now(UTC())
|
||||
enrollment_start = courselike.enrollment_start or datetime.min.replace(tzinfo=pytz.UTC)
|
||||
enrollment_end = courselike.enrollment_end or datetime.max.replace(tzinfo=pytz.UTC)
|
||||
if reg_method_ok and enrollment_start < now < enrollment_end:
|
||||
debug("Allow: in enrollment period")
|
||||
return ACCESS_GRANTED
|
||||
|
||||
return ACCESS_DENIED
|
||||
|
||||
|
||||
def _has_access_course_desc(user, action, course):
|
||||
"""
|
||||
Check if user has access to a course descriptor.
|
||||
@@ -271,54 +323,7 @@ def _has_access_course_desc(user, action, course):
|
||||
return _has_access_descriptor(user, 'load', course, course.id)
|
||||
|
||||
def can_enroll():
|
||||
"""
|
||||
First check if restriction of enrollment by login method is enabled, both
|
||||
globally and by the course.
|
||||
If it is, then the user must pass the criterion set by the course, e.g. that ExternalAuthMap
|
||||
was set by 'shib:https://idp.stanford.edu/", in addition to requirements below.
|
||||
Rest of requirements:
|
||||
(CourseEnrollmentAllowed always overrides)
|
||||
or
|
||||
(staff can always enroll)
|
||||
or
|
||||
Enrollment can only happen in the course enrollment period, if one exists, and
|
||||
course is not invitation only.
|
||||
"""
|
||||
|
||||
# if using registration method to restrict (say shibboleth)
|
||||
if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain:
|
||||
if user is not None and user.is_authenticated() and \
|
||||
ExternalAuthMap.objects.filter(user=user, external_domain=course.enrollment_domain):
|
||||
debug("Allow: external_auth of " + course.enrollment_domain)
|
||||
reg_method_ok = True
|
||||
else:
|
||||
reg_method_ok = False
|
||||
else:
|
||||
reg_method_ok = True # if not using this access check, it's always OK.
|
||||
|
||||
now = datetime.now(UTC())
|
||||
start = course.enrollment_start or datetime.min.replace(tzinfo=pytz.UTC)
|
||||
end = course.enrollment_end or datetime.max.replace(tzinfo=pytz.UTC)
|
||||
|
||||
# if user is in CourseEnrollmentAllowed with right course key then can also enroll
|
||||
# (note that course.id actually points to a CourseKey)
|
||||
# (the filter call uses course_id= since that's the legacy database schema)
|
||||
# (sorry that it's confusing :( )
|
||||
if user is not None and user.is_authenticated() and CourseEnrollmentAllowed:
|
||||
if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course.id):
|
||||
return ACCESS_GRANTED
|
||||
|
||||
if _has_staff_access_to_descriptor(user, course, course.id):
|
||||
return ACCESS_GRANTED
|
||||
|
||||
# Invitation_only doesn't apply to CourseEnrollmentAllowed or has_staff_access_access
|
||||
if course.invitation_only:
|
||||
debug("Deny: invitation only")
|
||||
return ACCESS_DENIED
|
||||
|
||||
if reg_method_ok and start < now < end:
|
||||
debug("Allow: in enrollment period")
|
||||
return ACCESS_GRANTED
|
||||
return _can_enroll_courselike(user, course)
|
||||
|
||||
def see_exists():
|
||||
"""
|
||||
@@ -412,6 +417,7 @@ def _can_load_course_overview(user, course_overview):
|
||||
)
|
||||
|
||||
_COURSE_OVERVIEW_CHECKERS = {
|
||||
'enroll': _can_enroll_courselike,
|
||||
'load': _can_load_course_overview,
|
||||
'load_mobile': lambda user, course_overview: (
|
||||
_can_load_course_overview(user, course_overview)
|
||||
|
||||
@@ -515,6 +515,12 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
|
||||
self.user_staff = UserFactory.create(is_staff=True)
|
||||
self.user_anonymous = AnonymousUserFactory.create()
|
||||
|
||||
ENROLL_TEST_DATA = list(itertools.product(
|
||||
['user_normal', 'user_staff', 'user_anonymous'],
|
||||
['enroll'],
|
||||
['course_default', 'course_started', 'course_not_started', 'course_staff_only'],
|
||||
))
|
||||
|
||||
LOAD_TEST_DATA = list(itertools.product(
|
||||
['user_normal', 'user_beta_tester', 'user_staff'],
|
||||
['load'],
|
||||
@@ -533,7 +539,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
|
||||
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
|
||||
))
|
||||
|
||||
@ddt.data(*(LOAD_TEST_DATA + LOAD_MOBILE_TEST_DATA + PREREQUISITES_TEST_DATA))
|
||||
@ddt.data(*(ENROLL_TEST_DATA + LOAD_TEST_DATA + LOAD_MOBILE_TEST_DATA + PREREQUISITES_TEST_DATA))
|
||||
@ddt.unpack
|
||||
def test_course_overview_access(self, user_attr_name, action, course_attr_name):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# The default values for these new columns may not match the actual
|
||||
# values of courses already present in the table. To ensure that the
|
||||
# cached values are correct, we must clear the table before adding any
|
||||
# new columns.
|
||||
db.clear_table('course_overviews_courseoverview')
|
||||
|
||||
# Adding field 'CourseOverview.enrollment_start'
|
||||
db.add_column('course_overviews_courseoverview', 'enrollment_start',
|
||||
self.gf('django.db.models.fields.DateTimeField')(null=True),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'CourseOverview.enrollment_end'
|
||||
db.add_column('course_overviews_courseoverview', 'enrollment_end',
|
||||
self.gf('django.db.models.fields.DateTimeField')(null=True),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'CourseOverview.enrollment_domain'
|
||||
db.add_column('course_overviews_courseoverview', 'enrollment_domain',
|
||||
self.gf('django.db.models.fields.TextField')(null=True),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'CourseOverview.invitation_only'
|
||||
db.add_column('course_overviews_courseoverview', 'invitation_only',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'CourseOverview.max_student_enrollments_allowed'
|
||||
db.add_column('course_overviews_courseoverview', 'max_student_enrollments_allowed',
|
||||
self.gf('django.db.models.fields.IntegerField')(null=True),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'CourseOverview.enrollment_start'
|
||||
db.delete_column('course_overviews_courseoverview', 'enrollment_start')
|
||||
|
||||
# Deleting field 'CourseOverview.enrollment_end'
|
||||
db.delete_column('course_overviews_courseoverview', 'enrollment_end')
|
||||
|
||||
# Deleting field 'CourseOverview.enrollment_domain'
|
||||
db.delete_column('course_overviews_courseoverview', 'enrollment_domain')
|
||||
|
||||
# Deleting field 'CourseOverview.invitation_only'
|
||||
db.delete_column('course_overviews_courseoverview', 'invitation_only')
|
||||
|
||||
# Deleting field 'CourseOverview.max_student_enrollments_allowed'
|
||||
db.delete_column('course_overviews_courseoverview', 'max_student_enrollments_allowed')
|
||||
|
||||
|
||||
models = {
|
||||
'course_overviews.courseoverview': {
|
||||
'Meta': {'object_name': 'CourseOverview'},
|
||||
'_location': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255'}),
|
||||
'_pre_requisite_courses_json': ('django.db.models.fields.TextField', [], {}),
|
||||
'advertised_start': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'cert_html_view_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'cert_name_long': ('django.db.models.fields.TextField', [], {}),
|
||||
'cert_name_short': ('django.db.models.fields.TextField', [], {}),
|
||||
'certificates_display_behavior': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'certificates_show_before_end': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_image_url': ('django.db.models.fields.TextField', [], {}),
|
||||
'days_early_for_beta': ('django.db.models.fields.FloatField', [], {'null': 'True'}),
|
||||
'display_name': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'display_number_with_default': ('django.db.models.fields.TextField', [], {}),
|
||||
'display_org_with_default': ('django.db.models.fields.TextField', [], {}),
|
||||
'end': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'end_of_course_survey_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'enrollment_domain': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'enrollment_end': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'enrollment_start': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'facebook_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'has_any_active_web_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'primary_key': 'True', 'db_index': 'True'}),
|
||||
'invitation_only': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'lowest_passing_grade': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
|
||||
'max_student_enrollments_allowed': ('django.db.models.fields.IntegerField', [], {'null': 'True'}),
|
||||
'mobile_available': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'social_sharing_url': ('django.db.models.fields.TextField', [], {'null': 'True'}),
|
||||
'start': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'visible_to_staff_only': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_overviews']
|
||||
@@ -5,7 +5,7 @@ Declaration of CourseOverview model
|
||||
import json
|
||||
|
||||
import django.db.models
|
||||
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField
|
||||
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField, IntegerField
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
from util.date_utils import strftime_localized
|
||||
@@ -60,6 +60,13 @@ class CourseOverview(django.db.models.Model):
|
||||
visible_to_staff_only = BooleanField()
|
||||
_pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings
|
||||
|
||||
# Enrollment details
|
||||
enrollment_start = DateTimeField(null=True)
|
||||
enrollment_end = DateTimeField(null=True)
|
||||
enrollment_domain = TextField(null=True)
|
||||
invitation_only = BooleanField(default=False)
|
||||
max_student_enrollments_allowed = IntegerField(null=True)
|
||||
|
||||
@staticmethod
|
||||
def _create_from_course(course):
|
||||
"""
|
||||
@@ -114,7 +121,13 @@ class CourseOverview(django.db.models.Model):
|
||||
days_early_for_beta=course.days_early_for_beta,
|
||||
mobile_available=course.mobile_available,
|
||||
visible_to_staff_only=course.visible_to_staff_only,
|
||||
_pre_requisite_courses_json=json.dumps(course.pre_requisite_courses)
|
||||
_pre_requisite_courses_json=json.dumps(course.pre_requisite_courses),
|
||||
|
||||
enrollment_start=course.enrollment_start,
|
||||
enrollment_end=course.enrollment_end,
|
||||
enrollment_domain=course.enrollment_domain,
|
||||
invitation_only=course.invitation_only,
|
||||
max_student_enrollments_allowed=course.max_student_enrollments_allowed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -3,9 +3,8 @@ Signal handler for invalidating cached course overviews
|
||||
"""
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
|
||||
from .models import CourseOverview
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_published)
|
||||
@@ -15,3 +14,12 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable
|
||||
invalidates the corresponding CourseOverview cache entry if one exists.
|
||||
"""
|
||||
CourseOverview.objects.filter(id=course_key).delete()
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_deleted)
|
||||
def _listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Catches the signal that a course has been deleted from Studio and
|
||||
invalidates the corresponding CourseOverview cache entry if one exists.
|
||||
"""
|
||||
CourseOverview.objects.filter(id=course_key).delete()
|
||||
|
||||
@@ -91,6 +91,9 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
'display_name_with_default',
|
||||
'start_date_is_still_default',
|
||||
'pre_requisite_courses',
|
||||
'enrollment_domain',
|
||||
'invitation_only',
|
||||
'max_student_enrollments_allowed',
|
||||
]
|
||||
for attribute_name in fields_to_test:
|
||||
course_value = getattr(course, attribute_name)
|
||||
@@ -125,24 +128,38 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
# resulting values are often off by fractions of a second. So, as a
|
||||
# workaround, we simply test if the start and end times are the same
|
||||
# number of seconds from the Unix epoch.
|
||||
others_to_test = [(
|
||||
course_image_url(course),
|
||||
course_overview_cache_miss.course_image_url,
|
||||
course_overview_cache_hit.course_image_url
|
||||
), (
|
||||
get_active_web_certificate(course) is not None,
|
||||
course_overview_cache_miss.has_any_active_web_certificate,
|
||||
course_overview_cache_hit.has_any_active_web_certificate
|
||||
|
||||
), (
|
||||
get_seconds_since_epoch(course.start),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.start),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.start),
|
||||
), (
|
||||
get_seconds_since_epoch(course.end),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.end),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.end),
|
||||
)]
|
||||
others_to_test = [
|
||||
(
|
||||
course_image_url(course),
|
||||
course_overview_cache_miss.course_image_url,
|
||||
course_overview_cache_hit.course_image_url
|
||||
),
|
||||
(
|
||||
get_active_web_certificate(course) is not None,
|
||||
course_overview_cache_miss.has_any_active_web_certificate,
|
||||
course_overview_cache_hit.has_any_active_web_certificate
|
||||
),
|
||||
(
|
||||
get_seconds_since_epoch(course.start),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.start),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.start),
|
||||
),
|
||||
(
|
||||
get_seconds_since_epoch(course.end),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.end),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.end),
|
||||
),
|
||||
(
|
||||
get_seconds_since_epoch(course.enrollment_start),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.enrollment_start),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.enrollment_start),
|
||||
),
|
||||
(
|
||||
get_seconds_since_epoch(course.enrollment_end),
|
||||
get_seconds_since_epoch(course_overview_cache_miss.enrollment_end),
|
||||
get_seconds_since_epoch(course_overview_cache_hit.enrollment_end),
|
||||
),
|
||||
]
|
||||
for (course_value, cache_miss_value, cache_hit_value) in others_to_test:
|
||||
self.assertEqual(course_value, cache_miss_value)
|
||||
self.assertEqual(cache_miss_value, cache_hit_value)
|
||||
@@ -211,7 +228,7 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_overview_cache_invalidation(self, modulestore_type):
|
||||
"""
|
||||
Tests that when a course is published, the corresponding
|
||||
Tests that when a course is published or deleted, the corresponding
|
||||
course_overview is removed from the cache.
|
||||
|
||||
Arguments:
|
||||
@@ -236,6 +253,11 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
course_overview_2 = CourseOverview.get_from_id(course.id)
|
||||
self.assertFalse(course_overview_2.mobile_available)
|
||||
|
||||
# Verify that when the course is deleted, the corresponding CourseOverview is deleted as well.
|
||||
with self.assertRaises(CourseOverview.DoesNotExist):
|
||||
self.store.delete_course(course.id, ModuleStoreEnum.UserID.test)
|
||||
CourseOverview.get_from_id(course.id)
|
||||
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 1, 1), (ModuleStoreEnum.Type.split, 3, 4))
|
||||
@ddt.unpack
|
||||
def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls):
|
||||
|
||||
Reference in New Issue
Block a user