Commit 5b630a77 by Kyle McCormick

MA-779 Make has_access work on CourseOverview objects

parent 375341b8
......@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore
NAMESPACE_CHOICES = {
......@@ -86,33 +87,46 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys):
add_prerequisite_course(course_key, prerequisite_course_key)
def get_pre_requisite_courses_not_completed(user, enrolled_courses):
def get_pre_requisite_courses_not_completed(user, enrolled_courses): # pylint: disable=invalid-name
"""
It would make dict of prerequisite courses not completed by user among courses
user has enrolled in. It calls the fulfilment api of milestones app and
iterates over all fulfilment milestones not achieved to make dict of
prerequisite courses yet to be completed.
Makes a dict mapping courses to their unfulfilled milestones using the
fulfillment API of the milestones app.
Arguments:
user (User): the user for whom we are checking prerequisites.
enrolled_courses (CourseKey): a list of keys for the courses to be
checked. The given user must be enrolled in all of these courses.
Returns:
dict[CourseKey: dict[
'courses': list[dict['key': CourseKey, 'display': str]]
]]
If a course has no incomplete prerequisites, it will be excluded from the
dictionary.
"""
if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
return {}
from milestones import api as milestones_api
pre_requisite_courses = {}
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
from milestones import api as milestones_api
for course_key in enrolled_courses:
required_courses = []
fulfilment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for milestone_key, milestone_value in fulfilment_paths.items(): # pylint: disable=unused-variable
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_descriptor = modulestore().get_course(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_name(required_course_descriptor)
})
# if there are required courses add to dict
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
for course_key in enrolled_courses:
required_courses = []
fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for __, milestone_value in fulfillment_paths.items():
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_overview = CourseOverview.get_from_id(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_string(required_course_overview)
})
# If there are required courses, add them to the result dict.
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
return pre_requisite_courses
......@@ -129,15 +143,18 @@ def get_prerequisite_courses_display(course_descriptor):
required_course_descriptor = modulestore().get_course(course_key)
prc = {
'key': course_key,
'display': get_course_display_name(required_course_descriptor)
'display': get_course_display_string(required_course_descriptor)
}
pre_requisite_courses.append(prc)
return pre_requisite_courses
def get_course_display_name(descriptor):
def get_course_display_string(descriptor):
"""
It would return display name from given course descriptor
Returns a string to display for a course or course overview.
Arguments:
descriptor (CourseDescriptor|CourseOverview): a course or course overview.
"""
return ' '.join([
descriptor.display_org_with_default,
......
......@@ -33,6 +33,7 @@ from xmodule.util.django import get_current_request_hostname
from external_auth.models import ExternalAuthMap
from courseware.masquerade import get_masquerade_role, is_masquerading_as_student
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student import auth
from student.models import CourseEnrollmentAllowed
from student.roles import (
......@@ -100,6 +101,9 @@ def has_access(user, action, obj, course_key=None):
if isinstance(obj, CourseDescriptor):
return _has_access_course_desc(user, action, obj)
if isinstance(obj, CourseOverview):
return _has_access_course_overview(user, action, obj)
if isinstance(obj, ErrorDescriptor):
return _has_access_error_desc(user, action, obj, course_key)
......@@ -129,6 +133,87 @@ def has_access(user, action, obj, course_key=None):
# ================ Implementation helpers ================================
def _can_access_descriptor_with_start_date(user, descriptor, course_key): # pylint: disable=invalid-name
"""
Checks if a user has access to a descriptor based on its start date.
If there is no start date specified, grant access.
Else, check if we're past the start date.
Note:
We do NOT check whether the user is staff or if the descriptor
is detached... it is assumed both of these are checked by the caller.
Arguments:
user (User): the user whose descriptor access we are checking.
descriptor (AType): the descriptor for which we are checking access.
where AType is any descriptor that has the attributes .location and
.days_early_for_beta
"""
start_dates_disabled = settings.FEATURES['DISABLE_START_DATES']
if start_dates_disabled and not is_masquerading_as_student(user, course_key):
return True
else:
now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(
user,
descriptor,
course_key=course_key
)
return (
descriptor.start is None
or now > effective_start
or in_preview_mode()
)
def _can_view_courseware_with_prerequisites(user, course): # pylint: disable=invalid-name
"""
Checks if a user has access to a course based on its prerequisites.
If the user is staff or anonymous, immediately grant access.
Else, return whether or not the prerequisite courses have been passed.
Arguments:
user (User): the user whose course access we are checking.
course (AType): the course for which we are checking access.
where AType is CourseDescriptor, CourseOverview, or any other class that
represents a course and has the attributes .location and .id.
"""
return (
not settings.FEATURES['ENABLE_PREREQUISITE_COURSES']
or _has_staff_access_to_descriptor(user, course, course.id)
or not course.pre_requisite_courses
or user.is_anonymous()
or not get_pre_requisite_courses_not_completed(user, [course.id])
)
def _can_load_course_on_mobile(user, course):
"""
Checks if a user can view the given course on a mobile device.
This function only checks mobile-specific access restrictions. Other access
restrictions such as start date and the .visible_to_staff_only flag must
be checked by callers in *addition* to the return value of this function.
Arguments:
user (User): the user whose course access we are checking.
course (CourseDescriptor|CourseOverview): the course for which we are
checking access.
Returns:
bool: whether the course can be accessed on mobile.
"""
return (
is_mobile_available_for_user(user, course) and
(
_has_staff_access_to_descriptor(user, course, course.id) or
not any_unfulfilled_milestones(course.id, user.id)
)
)
def _has_access_course_desc(user, action, course):
"""
Check if user has access to a course descriptor.
......@@ -154,23 +239,6 @@ def _has_access_course_desc(user, action, course):
# delegate to generic descriptor check to check start dates
return _has_access_descriptor(user, 'load', course, course.id)
def can_load_mobile():
"""
Can this user access this course from a mobile device?
"""
return (
# check start date
can_load() and
# check mobile_available flag
is_mobile_available_for_user(user, course) and
(
# either is a staff user or
_has_staff_access_to_descriptor(user, course, course.id) or
# check for unfulfilled milestones
not any_unfulfilled_milestones(course.id, user.id)
)
)
def can_enroll():
"""
First check if restriction of enrollment by login method is enabled, both
......@@ -274,25 +342,11 @@ def _has_access_course_desc(user, action, course):
_has_staff_access_to_descriptor(user, course, course.id)
)
def can_view_courseware_with_prerequisites(): # pylint: disable=invalid-name
"""
Checks if prerequisite courses feature is enabled and course has prerequisites
and user is neither staff nor anonymous then it returns False if user has not
passed prerequisite courses otherwise return True.
"""
if settings.FEATURES['ENABLE_PREREQUISITE_COURSES'] \
and not _has_staff_access_to_descriptor(user, course, course.id) \
and course.pre_requisite_courses \
and not user.is_anonymous() \
and get_pre_requisite_courses_not_completed(user, [course.id]):
return False
else:
return True
checkers = {
'load': can_load,
'view_courseware_with_prerequisites': can_view_courseware_with_prerequisites,
'load_mobile': can_load_mobile,
'view_courseware_with_prerequisites':
lambda: _can_view_courseware_with_prerequisites(user, course),
'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, course),
'enroll': can_enroll,
'see_exists': see_exists,
'staff': lambda: _has_staff_access_to_descriptor(user, course, course.id),
......@@ -304,6 +358,51 @@ def _has_access_course_desc(user, action, course):
return _dispatch(checkers, action, user, course)
def _can_load_course_overview(user, course_overview):
"""
Check if a user can load a course overview.
Arguments:
user (User): the user whose course access we are checking.
course_overview (CourseOverview): a course overview.
Note:
The user doesn't have to be enrolled in the course in order to have load
load access.
"""
return (
not course_overview.visible_to_staff_only
and _can_access_descriptor_with_start_date(user, course_overview, course_overview.id)
) or _has_staff_access_to_descriptor(user, course_overview, course_overview.id)
_COURSE_OVERVIEW_CHECKERS = {
'load': _can_load_course_overview,
'load_mobile': lambda user, course_overview: (
_can_load_course_overview(user, course_overview)
and _can_load_course_on_mobile(user, course_overview)
),
'view_courseware_with_prerequisites': _can_view_courseware_with_prerequisites
}
COURSE_OVERVIEW_SUPPORTED_ACTIONS = _COURSE_OVERVIEW_CHECKERS.keys() # pylint: disable=invalid-name
def _has_access_course_overview(user, action, course_overview):
"""
Check if user has access to a course overview.
Arguments:
user (User): the user whose course access we are checking.
action (str): the action the user is trying to perform.
See COURSE_OVERVIEW_SUPPORTED_ACTIONS for valid values.
course_overview (CourseOverview): overview of the course in question.
"""
if action in _COURSE_OVERVIEW_CHECKERS:
return _COURSE_OVERVIEW_CHECKERS[action](user, course_overview)
else:
raise ValueError(u"Unknown action for object type 'CourseOverview': '{}'".format(action))
def _has_access_error_desc(user, action, descriptor, course_key):
"""
Only staff should see error descriptors.
......@@ -408,38 +507,14 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
students to see modules. If not, views should check the course, so we
don't have to hit the enrollments table on every module load.
"""
if descriptor.visible_to_staff_only and not _has_staff_access_to_descriptor(user, descriptor, course_key):
return False
# enforce group access
if not _has_group_access(descriptor, user, course_key):
# if group_access check failed, deny access unless the requestor is staff,
# in which case immediately grant access.
return _has_staff_access_to_descriptor(user, descriptor, course_key)
# If start dates are off, can always load
if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user, course_key):
debug("Allow: DISABLE_START_DATES")
return True
# Check start date
if 'detached' not in descriptor._class_tags and descriptor.start is not None:
now = datetime.now(UTC())
effective_start = _adjust_start_date_for_beta_testers(
user,
descriptor,
course_key=course_key
return (
not descriptor.visible_to_staff_only
and _has_group_access(descriptor, user, course_key)
and (
'detached' in descriptor._class_tags # pylint: disable=protected-access
or _can_access_descriptor_with_start_date(user, descriptor, course_key)
)
if in_preview_mode() or now > effective_start:
# after start date, everyone can see it
debug("Allow: now > effective start date")
return True
# otherwise, need staff access
return _has_staff_access_to_descriptor(user, descriptor, course_key)
# No start date, so can always load.
debug("Allow: no start date")
return True
) or _has_staff_access_to_descriptor(user, descriptor, course_key)
checkers = {
'load': can_load,
......@@ -700,4 +775,4 @@ def in_preview_mode():
Returns whether the user is in preview mode or not.
"""
hostname = get_current_request_hostname()
return hostname and settings.PREVIEW_DOMAIN in hostname.split('.')
return bool(hostname and settings.PREVIEW_DOMAIN in hostname.split('.'))
......@@ -5,6 +5,8 @@ from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import RequestFactory
from courseware.access import has_access, COURSE_OVERVIEW_SUPPORTED_ACTIONS
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import Registration
......@@ -137,3 +139,50 @@ class LoginEnrollmentTestCase(TestCase):
'course_id': course.id.to_deprecated_string(),
}
self.assert_request_status_code(200, url, method="POST", data=request_data)
class CourseAccessTestMixin(TestCase):
"""
Utility mixin for asserting access (or lack thereof) to courses.
If relevant, also checks access for courses' corresponding CourseOverviews.
"""
def assertCanAccessCourse(self, user, action, course):
"""
Assert that a user has access to the given action for a given course.
Test with both the given course and, if the action is supported, with
a CourseOverview of the given course.
Arguments:
user (User): a user.
action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course.
"""
self.assertTrue(has_access(user, action, course))
if action in COURSE_OVERVIEW_SUPPORTED_ACTIONS:
self.assertTrue(has_access(user, action, CourseOverview.get_from_id(course.id)))
def assertCannotAccessCourse(self, user, action, course):
"""
Assert that a user lacks access to the given action the given course.
Test with both the given course and, if the action is supported, with
a CourseOverview of the given course.
Arguments:
user (User): a user.
action (str): type of access to test.
See access.py:COURSE_OVERVIEW_SUPPORTED_ACTIONS.
course (CourseDescriptor): a course.
Note:
It may seem redundant to have one method for testing access
and another method for testing lack thereof (why not just combine
them into one method with a boolean flag?), but it makes reading
stack traces of failed tests easier to understand at a glance.
"""
self.assertFalse(has_access(user, action, course))
if action in COURSE_OVERVIEW_SUPPORTED_ACTIONS:
self.assertFalse(has_access(user, action, CourseOverview.get_from_id(course.id)))
import datetime
import ddt
import itertools
import pytz
from django.test import TestCase
......@@ -9,8 +11,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access
from courseware.masquerade import CourseMasquerade
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory, BetaTesterFactory
from courseware.tests.helpers import LoginEnrollmentTestCase
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory, CourseEnrollmentFactory
from xmodule.course_module import (
CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_ABOUT,
......@@ -18,6 +21,7 @@ from xmodule.course_module import (
)
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.milestones_helpers import fulfill_course_milestone
from util.milestones_helpers import (
set_prerequisite_courses,
......@@ -424,3 +428,91 @@ class UserRoleTestCase(TestCase):
'student',
access.get_user_role(self.anonymous_user, self.course_key)
)
@ddt.ddt
class CourseOverviewAccessTestCase(ModuleStoreTestCase):
"""
Tests confirming that has_access works equally on CourseDescriptors and
CourseOverviews.
"""
def setUp(self):
super(CourseOverviewAccessTestCase, self).setUp()
today = datetime.datetime.now(pytz.UTC)
last_week = today - datetime.timedelta(days=7)
next_week = today + datetime.timedelta(days=7)
self.course_default = CourseFactory.create()
self.course_started = CourseFactory.create(start=last_week)
self.course_not_started = CourseFactory.create(start=next_week, days_early_for_beta=10)
self.course_staff_only = CourseFactory.create(visible_to_staff_only=True)
self.course_mobile_available = CourseFactory.create(mobile_available=True)
self.course_with_pre_requisite = CourseFactory.create(
pre_requisite_courses=[str(self.course_started.id)]
)
self.course_with_pre_requisites = CourseFactory.create(
pre_requisite_courses=[str(self.course_started.id), str(self.course_not_started.id)]
)
self.user_normal = UserFactory.create()
self.user_beta_tester = BetaTesterFactory.create(course_key=self.course_not_started.id)
self.user_completed_pre_requisite = UserFactory.create() # pylint: disable=invalid-name
fulfill_course_milestone(self.user_completed_pre_requisite, self.course_started.id)
self.user_staff = UserFactory.create(is_staff=True)
self.user_anonymous = AnonymousUserFactory.create()
LOAD_TEST_DATA = list(itertools.product(
['user_normal', 'user_beta_tester', 'user_staff'],
['load'],
['course_default', 'course_started', 'course_not_started', 'course_staff_only'],
))
LOAD_MOBILE_TEST_DATA = list(itertools.product(
['user_normal', 'user_staff'],
['load_mobile'],
['course_default', 'course_mobile_available'],
))
PREREQUISITES_TEST_DATA = list(itertools.product(
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'],
['view_courseware_with_prerequisites'],
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
))
@ddt.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):
"""
Check that a user's access to a course is equal to the user's access to
the corresponding course overview.
Instead of taking a user and course directly as arguments, we have to
take their attribute names, as ddt doesn't allow us to reference self.
Arguments:
user_attr_name (str): the name of the attribute on self that is the
User to test with.
action (str): action to test with.
See COURSE_OVERVIEW_SUPPORTED_ACTIONS for valid values.
course_attr_name (str): the name of the attribute on self that is
the CourseDescriptor to test with.
"""
user = getattr(self, user_attr_name)
course = getattr(self, course_attr_name)
course_overview = CourseOverview.get_from_id(course.id)
self.assertEqual(
access.has_access(user, action, course, course_key=course.id),
access.has_access(user, action, course_overview, course_key=course.id)
)
def test_course_overivew_unsupported_action(self):
"""
Check that calling has_access with an unsupported action raises a
ValueError.
"""
overview = CourseOverview.get_from_id(self.course_default.id)
with self.assertRaises(ValueError):
access.has_access(self.user, '_non_existent_action', overview)
......@@ -6,7 +6,7 @@ from mock import patch
from nose.plugins.attrib import attr
from courseware.access import has_access
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.helpers import CourseAccessTestMixin, LoginEnrollmentTestCase
from courseware.tests.factories import (
BetaTesterFactory,
StaffFactory,
......@@ -389,7 +389,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
@attr('shard_1')
class TestBetatesterAccess(ModuleStoreTestCase):
class TestBetatesterAccess(ModuleStoreTestCase, CourseAccessTestMixin):
"""
Tests for the beta tester feature
"""
......@@ -411,12 +411,8 @@ class TestBetatesterAccess(ModuleStoreTestCase):
Check that beta-test access works for courses.
"""
self.assertFalse(self.course.has_started())
# student user shouldn't see it
self.assertFalse(has_access(self.normal_student, 'load', self.course))
# now the student should see it
self.assertTrue(has_access(self.beta_tester, 'load', self.course))
self.assertCannotAccessCourse(self.normal_student, 'load', self.course)
self.assertCanAccessCourse(self.beta_tester, 'load', self.course)
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
def test_content_beta_period(self):
......
# -*- 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):
# Adding field 'CourseOverview.days_early_for_beta'
# The default value for the days_early_for_beta column is null. However,
# for courses already in the table that have a non-null value for
# days_early_for_beta, this would be invalid. So, we must clear the
# table before adding the new column.
db.clear_table('course_overviews_courseoverview')
db.add_column('course_overviews_courseoverview', 'days_early_for_beta',
self.gf('django.db.models.fields.FloatField')(null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'CourseOverview.days_early_for_beta'
db.delete_column('course_overviews_courseoverview', 'days_early_for_beta')
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_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'}),
'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'}),
'lowest_passing_grade': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
'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']
\ No newline at end of file
......@@ -5,11 +5,9 @@ Declaration of CourseOverview model
import json
import django.db.models
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField
from django.db.models.fields import BooleanField, DateTimeField, DecimalField, TextField, FloatField
from django.utils.translation import ugettext
from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url
from util.date_utils import strftime_localized
from xmodule import course_metadata_utils
from xmodule.modulestore.django import modulestore
......@@ -54,6 +52,7 @@ class CourseOverview(django.db.models.Model):
lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2)
# Access parameters
days_early_for_beta = FloatField(null=True)
mobile_available = BooleanField()
visible_to_staff_only = BooleanField()
_pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings
......@@ -72,6 +71,9 @@ class CourseOverview(django.db.models.Model):
Returns:
CourseOverview: overview extracted from the given course
"""
from lms.djangoapps.certificates.api import get_active_web_certificate
from lms.djangoapps.courseware.courses import course_image_url
return CourseOverview(
id=course.id,
_location=course.location,
......@@ -95,6 +97,7 @@ class CourseOverview(django.db.models.Model):
lowest_passing_grade=course.lowest_passing_grade,
end_of_course_survey_url=course.end_of_course_survey_url,
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)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment