Merge branch 'feature/cale/cms-master' of github.com:MITx/mitx into bug/dhm/dec12
This commit is contained in:
@@ -55,6 +55,22 @@ def create_new_course_group(creator, location, role):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
'''
|
||||||
|
This is to be called only by either a command line code path or through a app which has already
|
||||||
|
asserted permissions
|
||||||
|
'''
|
||||||
|
def _delete_course_group(location):
|
||||||
|
# remove all memberships
|
||||||
|
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
|
||||||
|
for user in instructors.user_set.all():
|
||||||
|
user.groups.remove(instructors)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME))
|
||||||
|
for user in staff.user_set.all():
|
||||||
|
user.groups.remove(staff)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
|
||||||
def add_user_to_course_group(caller, user, location, role):
|
def add_user_to_course_group(caller, user, location, role):
|
||||||
# only admins can add/remove other users
|
# only admins can add/remove other users
|
||||||
|
|||||||
31
cms/djangoapps/contentstore/management/commands/clone.py
Normal file
31
cms/djangoapps/contentstore/management/commands/clone.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
###
|
||||||
|
### Script for cloning a course
|
||||||
|
###
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from xmodule.modulestore.store_utilities import clone_course
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
from xmodule.contentstore.django import contentstore
|
||||||
|
from xmodule.modulestore import Location
|
||||||
|
from xmodule.course_module import CourseDescriptor
|
||||||
|
|
||||||
|
#
|
||||||
|
# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3
|
||||||
|
#
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = \
|
||||||
|
'''Clone a MongoDB backed course to another location'''
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if len(args) != 2:
|
||||||
|
raise CommandError("clone requires two arguments: <source-location> <dest-location>")
|
||||||
|
|
||||||
|
source_location_str = args[0]
|
||||||
|
dest_location_str = args[1]
|
||||||
|
|
||||||
|
print "Cloning course {0} to {1}".format(source_location_str, dest_location_str)
|
||||||
|
|
||||||
|
source_location = CourseDescriptor.id_to_location(source_location_str)
|
||||||
|
dest_location = CourseDescriptor.id_to_location(dest_location_str)
|
||||||
|
|
||||||
|
clone_course(modulestore('direct'), contentstore(), source_location, dest_location)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
###
|
||||||
|
### Script for cloning a course
|
||||||
|
###
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from xmodule.modulestore.store_utilities import delete_course
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
from xmodule.contentstore.django import contentstore
|
||||||
|
from xmodule.modulestore import Location
|
||||||
|
from xmodule.course_module import CourseDescriptor
|
||||||
|
from prompt import query_yes_no
|
||||||
|
|
||||||
|
from auth.authz import _delete_course_group
|
||||||
|
|
||||||
|
#
|
||||||
|
# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1
|
||||||
|
#
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = \
|
||||||
|
'''Delete a MongoDB backed course'''
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if len(args) != 1:
|
||||||
|
raise CommandError("delete_course requires one arguments: <location>")
|
||||||
|
|
||||||
|
loc_str = args[0]
|
||||||
|
|
||||||
|
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
|
||||||
|
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||||
|
loc = CourseDescriptor.id_to_location(loc_str)
|
||||||
|
if delete_course(modulestore('direct'), contentstore(), loc) == True:
|
||||||
|
# in the django layer, we need to remove all the user permissions groups associated with this course
|
||||||
|
_delete_course_group(loc)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
33
cms/djangoapps/contentstore/management/commands/prompt.py
Normal file
33
cms/djangoapps/contentstore/management/commands/prompt.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
def query_yes_no(question, default="yes"):
|
||||||
|
"""Ask a yes/no question via raw_input() and return their answer.
|
||||||
|
|
||||||
|
"question" is a string that is presented to the user.
|
||||||
|
"default" is the presumed answer if the user just hits <Enter>.
|
||||||
|
It must be "yes" (the default), "no" or None (meaning
|
||||||
|
an answer is required of the user).
|
||||||
|
|
||||||
|
The "answer" return value is one of "yes" or "no".
|
||||||
|
"""
|
||||||
|
valid = {"yes":True, "y":True, "ye":True,
|
||||||
|
"no":False, "n":False}
|
||||||
|
if default == None:
|
||||||
|
prompt = " [y/n] "
|
||||||
|
elif default == "yes":
|
||||||
|
prompt = " [Y/n] "
|
||||||
|
elif default == "no":
|
||||||
|
prompt = " [y/N] "
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid default answer: '%s'" % default)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
sys.stdout.write(question + prompt)
|
||||||
|
choice = raw_input().lower()
|
||||||
|
if default is not None and choice == '':
|
||||||
|
return valid[default]
|
||||||
|
elif choice in valid:
|
||||||
|
return valid[choice]
|
||||||
|
else:
|
||||||
|
sys.stdout.write("Please respond with 'yes' or 'no' "\
|
||||||
|
"(or 'y' or 'n').\n")
|
||||||
18
cms/djangoapps/contentstore/tests/test_utils.py
Normal file
18
cms/djangoapps/contentstore/tests/test_utils.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.test.testcases import TestCase
|
||||||
|
from cms.djangoapps.contentstore import utils
|
||||||
|
import mock
|
||||||
|
|
||||||
|
class LMSLinksTestCase(TestCase):
|
||||||
|
def about_page_test(self):
|
||||||
|
location = 'i4x','mitX','101','course', 'test'
|
||||||
|
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||||
|
link = utils.get_lms_link_for_about_page(location)
|
||||||
|
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about")
|
||||||
|
|
||||||
|
def ls_link_test(self):
|
||||||
|
location = 'i4x','mitX','101','vertical', 'contacting_us'
|
||||||
|
utils.get_course_id = mock.Mock(return_value="mitX/101/test")
|
||||||
|
link = utils.get_lms_link_for_item(location, False)
|
||||||
|
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
|
||||||
|
link = utils.get_lms_link_for_item(location, True)
|
||||||
|
self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us")
|
||||||
@@ -73,20 +73,38 @@ def get_course_for_item(location):
|
|||||||
|
|
||||||
|
|
||||||
def get_lms_link_for_item(location, preview=False):
|
def get_lms_link_for_item(location, preview=False):
|
||||||
location = Location(location)
|
|
||||||
if settings.LMS_BASE is not None:
|
if settings.LMS_BASE is not None:
|
||||||
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format(
|
||||||
preview='preview.' if preview else '',
|
preview='preview.' if preview else '',
|
||||||
lms_base=settings.LMS_BASE,
|
lms_base=settings.LMS_BASE,
|
||||||
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
|
course_id=get_course_id(location),
|
||||||
course_id=modulestore().get_containing_courses(location)[0].id,
|
location=Location(location)
|
||||||
location=location,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lms_link = None
|
lms_link = None
|
||||||
|
|
||||||
return lms_link
|
return lms_link
|
||||||
|
|
||||||
|
def get_lms_link_for_about_page(location):
|
||||||
|
"""
|
||||||
|
Returns the url to the course about page from the location tuple.
|
||||||
|
"""
|
||||||
|
if settings.LMS_BASE is not None:
|
||||||
|
lms_link = "//{lms_base}/courses/{course_id}/about".format(
|
||||||
|
lms_base=settings.LMS_BASE,
|
||||||
|
course_id=get_course_id(location)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lms_link = None
|
||||||
|
|
||||||
|
return lms_link
|
||||||
|
|
||||||
|
def get_course_id(location):
|
||||||
|
"""
|
||||||
|
Returns the course_id from a given the location tuple.
|
||||||
|
"""
|
||||||
|
# TODO: These will need to be changed to point to the particular instance of this problem in the particular course
|
||||||
|
return modulestore().get_containing_courses(Location(location))[0].id
|
||||||
|
|
||||||
class UnitState(object):
|
class UnitState(object):
|
||||||
draft = 'draft'
|
draft = 'draft'
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LMS_BASE = "localhost:8000"
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
# This is the cache used for most things. Askbot will not work without a
|
# This is the cache used for most things. Askbot will not work without a
|
||||||
# functioning cache -- it relies on caching to load its settings in places.
|
# functioning cache -- it relies on caching to load its settings in places.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ from contentstore import utils
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="input">
|
<div class="input">
|
||||||
<input type="text" class="long" id="course-name" value="[Course Name]" disabled="disabled">
|
<input type="text" class="long" id="course-name" value="[Course Name]" disabled="disabled">
|
||||||
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
|
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_about_page(context_course.location)}">your course URL</a>, and cannot be changed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@ from contentstore import utils
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="input">
|
<div class="input">
|
||||||
<input type="text" class="long" id="course-organization" value="[Course Organization]" disabled="disabled">
|
<input type="text" class="long" id="course-organization" value="[Course Organization]" disabled="disabled">
|
||||||
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
|
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_about_page(context_course.location)}">your course URL</a>, and cannot be changed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,7 @@ from contentstore import utils
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="input">
|
<div class="input">
|
||||||
<input type="text" class="short" id="course-number" value="[Course No.]" disabled="disabled">
|
<input type="text" class="short" id="course-number" value="[Course No.]" disabled="disabled">
|
||||||
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course URL</a>, and cannot be changed</span>
|
<span class="tip tip-stacked">This is used in <a href="${utils.get_lms_link_for_about_page(context_course.location)}">your course URL</a>, and cannot be changed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@ from contentstore import utils
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="input">
|
<div class="input">
|
||||||
<textarea class="long tall tinymce text-editor" id="course-overview"></textarea>
|
<textarea class="long tall tinymce text-editor" id="course-overview"></textarea>
|
||||||
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_item(context_course.location, True)}">your course summary page</a></span>
|
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_about_page(context_course.location)}">your course summary page</a></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ class MongoContentStore(ContentStore):
|
|||||||
id = content.get_id()
|
id = content.get_id()
|
||||||
|
|
||||||
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
|
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
|
||||||
if self.fs.exists({"_id" : id}):
|
self.delete(id)
|
||||||
self.fs.delete(id)
|
|
||||||
|
|
||||||
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
|
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
|
||||||
displayname=content.name, thumbnail_location=content.thumbnail_location) as fp:
|
displayname=content.name, thumbnail_location=content.thumbnail_location) as fp:
|
||||||
@@ -39,7 +38,10 @@ class MongoContentStore(ContentStore):
|
|||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
if self.fs.exists({"_id" : id}):
|
||||||
|
self.fs.delete(id)
|
||||||
|
|
||||||
def find(self, location):
|
def find(self, location):
|
||||||
id = StaticContent.get_id_from_location(location)
|
id = StaticContent.get_id_from_location(location)
|
||||||
try:
|
try:
|
||||||
|
|||||||
129
common/lib/xmodule/xmodule/modulestore/store_utilities.py
Normal file
129
common/lib/xmodule/xmodule/modulestore/store_utilities.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import logging
|
||||||
|
from xmodule.contentstore.content import StaticContent
|
||||||
|
from xmodule.modulestore import Location
|
||||||
|
from xmodule.modulestore.mongo import MongoModuleStore
|
||||||
|
|
||||||
|
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
|
||||||
|
# first check to see if the modulestore is Mongo backed
|
||||||
|
if not isinstance(modulestore, MongoModuleStore):
|
||||||
|
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||||
|
|
||||||
|
# check to see if the dest_location exists as an empty course
|
||||||
|
# we need an empty course because the app layers manage the permissions and users
|
||||||
|
if not modulestore.has_item(dest_location):
|
||||||
|
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||||
|
|
||||||
|
# verify that the dest_location really is an empty course, which means only one
|
||||||
|
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||||
|
|
||||||
|
if len(dest_modules) != 1:
|
||||||
|
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
|
||||||
|
|
||||||
|
# check to see if the source course is actually there
|
||||||
|
if not modulestore.has_item(source_location):
|
||||||
|
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||||
|
|
||||||
|
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||||
|
|
||||||
|
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
original_loc = Location(module.location)
|
||||||
|
|
||||||
|
if original_loc.category != 'course':
|
||||||
|
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
|
||||||
|
course = dest_location.course)
|
||||||
|
else:
|
||||||
|
# on the course module we also have to update the module name
|
||||||
|
module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org,
|
||||||
|
course = dest_location.course, name=dest_location.name)
|
||||||
|
|
||||||
|
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||||
|
|
||||||
|
if 'data' in module.definition:
|
||||||
|
modulestore.update_item(module.location, module.definition['data'])
|
||||||
|
|
||||||
|
# repoint children
|
||||||
|
if 'children' in module.definition:
|
||||||
|
new_children = []
|
||||||
|
for child_loc_url in module.definition['children']:
|
||||||
|
child_loc = Location(child_loc_url)
|
||||||
|
child_loc = child_loc._replace(tag = dest_location.tag, org = dest_location.org,
|
||||||
|
course = dest_location.course)
|
||||||
|
new_children = new_children + [child_loc.url()]
|
||||||
|
|
||||||
|
modulestore.update_children(module.location, new_children)
|
||||||
|
|
||||||
|
# save metadata
|
||||||
|
modulestore.update_metadata(module.location, module.metadata)
|
||||||
|
|
||||||
|
# now iterate through all of the assets and clone them
|
||||||
|
# first the thumbnails
|
||||||
|
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||||
|
for thumb in thumbs:
|
||||||
|
thumb_loc = Location(thumb["_id"])
|
||||||
|
content = contentstore.find(thumb_loc)
|
||||||
|
content.location = content.location._replace(org = dest_location.org,
|
||||||
|
course = dest_location.course)
|
||||||
|
|
||||||
|
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||||
|
|
||||||
|
contentstore.save(content)
|
||||||
|
|
||||||
|
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||||
|
|
||||||
|
assets = contentstore.get_all_content_for_course(source_location)
|
||||||
|
for asset in assets:
|
||||||
|
asset_loc = Location(asset["_id"])
|
||||||
|
content = contentstore.find(asset_loc)
|
||||||
|
content.location = content.location._replace(org = dest_location.org,
|
||||||
|
course = dest_location.course)
|
||||||
|
|
||||||
|
# be sure to update the pointer to the thumbnail
|
||||||
|
if content.thumbnail_location is not None:
|
||||||
|
content.thumbnail_location._replace(tag = dest_location.tag, org = dest_location.org,
|
||||||
|
course = dest_location.course)
|
||||||
|
|
||||||
|
|
||||||
|
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||||
|
|
||||||
|
contentstore.save(content)
|
||||||
|
|
||||||
|
def delete_course(modulestore, contentstore, source_location):
|
||||||
|
# first check to see if the modulestore is Mongo backed
|
||||||
|
if not isinstance(modulestore, MongoModuleStore):
|
||||||
|
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||||
|
|
||||||
|
# check to see if the source course is actually there
|
||||||
|
if not modulestore.has_item(source_location):
|
||||||
|
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||||
|
|
||||||
|
# first delete all of the thumbnails
|
||||||
|
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||||
|
for thumb in thumbs:
|
||||||
|
thumb_loc = Location(thumb["_id"])
|
||||||
|
id = StaticContent.get_id_from_location(thumb_loc)
|
||||||
|
print "Deleting {0}...".format(id)
|
||||||
|
contentstore.delete(id)
|
||||||
|
|
||||||
|
# then delete all of the assets
|
||||||
|
assets = contentstore.get_all_content_for_course(source_location)
|
||||||
|
for asset in assets:
|
||||||
|
asset_loc = Location(asset["_id"])
|
||||||
|
id = StaticContent.get_id_from_location(asset_loc)
|
||||||
|
print "Deleting {0}...".format(id)
|
||||||
|
contentstore.delete(id)
|
||||||
|
|
||||||
|
# then delete all course modules
|
||||||
|
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
if module.category != 'course': # save deleting the course module for last
|
||||||
|
print "Deleting {0}...".format(module.location)
|
||||||
|
modulestore.delete_item(module.location)
|
||||||
|
|
||||||
|
# finally delete the top-level course module itself
|
||||||
|
print "Deleting {0}...".format(source_location)
|
||||||
|
modulestore.delete_item(source_location)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
metadata:
|
metadata:
|
||||||
display_name: Formula Repsonse
|
display_name: Formula Response
|
||||||
rerandomize: never
|
rerandomize: never
|
||||||
showanswer: always
|
showanswer: always
|
||||||
data: |
|
data: |
|
||||||
|
|||||||
22
rakefile
22
rakefile
@@ -396,6 +396,28 @@ task :publish => :package do
|
|||||||
sh("scp #{BUILD_DIR}/#{NORMALIZED_DEPLOY_NAME}_#{PKG_VERSION}*.deb #{PACKAGE_REPO}")
|
sh("scp #{BUILD_DIR}/#{NORMALIZED_DEPLOY_NAME}_#{PKG_VERSION}*.deb #{PACKAGE_REPO}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :cms do
|
||||||
|
desc "Clone existing MongoDB based course"
|
||||||
|
task :clone do
|
||||||
|
if ENV['SOURCE_LOC'] and ENV['DEST_LOC']
|
||||||
|
sh(django_admin(:cms, :dev, :clone, ENV['SOURCE_LOC'], ENV['DEST_LOC']))
|
||||||
|
else
|
||||||
|
raise "You must pass in a SOURCE_LOC and DEST_LOC parameters"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace :cms do
|
||||||
|
desc "Delete existing MongoDB based course"
|
||||||
|
task :delete_course do
|
||||||
|
if ENV['LOC']
|
||||||
|
sh(django_admin(:cms, :dev, :delete_course, ENV['LOC']))
|
||||||
|
else
|
||||||
|
raise "You must pass in a LOC parameter"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
namespace :cms do
|
namespace :cms do
|
||||||
desc "Import course data within the given DATA_DIR variable"
|
desc "Import course data within the given DATA_DIR variable"
|
||||||
task :import do
|
task :import do
|
||||||
|
|||||||
Reference in New Issue
Block a user