feat: Add backfill course tabs management command
Previously, course tabs would only be created once and never try to update the default tabs again. This leads to an issue if you ever want to add a new tab. With this command, you can now update the default tabs for all existing courses and new courses will pick it up upon creation when CourseTabList.initialize_default is called.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Management command to backfill default course tabs for all courses. This command is essentially for
|
||||
when a new default tab is added and we need to update all existing courses. Any new courses will pick
|
||||
up the new tab automatically via course creation and the CourseTabList.initialize_default method.
|
||||
People updating to Nutmeg release should run this command as part of the upgrade process.
|
||||
|
||||
This should be invoked from the Studio process.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from xmodule.tabs import CourseTabList
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Invoke with:
|
||||
python manage.py cms backfill_course_tabs
|
||||
"""
|
||||
help = (
|
||||
'Backfill default course tabs for all courses. This command is essentially for when a new default '
|
||||
'tab is added and we need to update all existing courses. Any new courses will pick up the new '
|
||||
'tab automatically via course creation and the CourseTabList.initialize_default method.'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Gathers all course keys in the modulestore and updates the course tabs
|
||||
if there are any new default course tabs. Else, makes no updates.
|
||||
"""
|
||||
store = modulestore()
|
||||
course_keys = sorted(
|
||||
(course.id for course in store.get_course_summaries()),
|
||||
key=str # Different types of CourseKeys can't be compared without this.
|
||||
)
|
||||
logger.info(f'{len(course_keys)} courses read from modulestore.')
|
||||
|
||||
for course_key in course_keys:
|
||||
course = store.get_course(course_key, depth=1)
|
||||
existing_tabs = {tab.type for tab in course.tabs}
|
||||
CourseTabList.initialize_default(course)
|
||||
new_tabs = {tab.type for tab in course.tabs}
|
||||
|
||||
if existing_tabs != new_tabs:
|
||||
# This will trigger the Course Published Signal which is necessary to update
|
||||
# the corresponding Course Overview
|
||||
logger.info(f'Updating tabs for {course_key}.')
|
||||
store.update_item(course, ModuleStoreEnum.UserID.mgmt_command)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Tests for `backfill_course_outlines` Studio (cms) management command.
|
||||
"""
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.tabs import InvalidTabsException
|
||||
|
||||
|
||||
class BackfillCourseTabsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test `backfill_course_tabs`
|
||||
"""
|
||||
@mock.patch('cms.djangoapps.contentstore.management.commands.backfill_course_tabs.logger')
|
||||
def test_no_tabs_to_add(self, mock_logger):
|
||||
""" Calls command with a course already having all default tabs. """
|
||||
course = CourseFactory()
|
||||
tabs_before = course.tabs
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
|
||||
course = self.store.get_course(course.id)
|
||||
tabs_after = course.tabs
|
||||
assert tabs_before == tabs_after
|
||||
# Ensure update_item was never called since there were no changes necessary
|
||||
# Using logger as a proxy. First time is number of courses read.
|
||||
assert mock_logger.info.call_count == 1
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.management.commands.backfill_course_tabs.logger')
|
||||
def test_add_one_tab(self, mock_logger):
|
||||
"""
|
||||
Calls command on a course with existing tabs, but not all default ones.
|
||||
"""
|
||||
course = CourseFactory()
|
||||
course.tabs = [tab for tab in course.tabs if tab.type != 'dates']
|
||||
self.update_course(course, ModuleStoreEnum.UserID.test)
|
||||
assert len(course.tabs) == 6
|
||||
assert 'dates' not in {tab.type for tab in course.tabs}
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
|
||||
course = self.store.get_course(course.id)
|
||||
assert len(course.tabs) == 7
|
||||
assert 'dates' in {tab.type for tab in course.tabs}
|
||||
mock_logger.info.assert_called_with(f'Updating tabs for {course.id}.')
|
||||
assert mock_logger.info.call_count == 2
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
# Ensure rerunning the command does not require another update on the course.
|
||||
# Goes up by one from the courses read log.
|
||||
assert mock_logger.info.call_count == 3
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.management.commands.backfill_course_tabs.logger')
|
||||
def test_multiple_courses_one_update(self, mock_logger):
|
||||
"""
|
||||
Calls command on multiple courses, some that already have all their tabs, and some that need updates.
|
||||
"""
|
||||
CourseFactory()
|
||||
CourseFactory()
|
||||
CourseFactory()
|
||||
course = CourseFactory()
|
||||
course.tabs = [tab for tab in course.tabs if tab.type in ('course_info', 'courseware')]
|
||||
self.update_course(course, ModuleStoreEnum.UserID.test)
|
||||
assert len(course.tabs) == 2
|
||||
assert 'dates' not in {tab.type for tab in course.tabs}
|
||||
assert 'progress' not in {tab.type for tab in course.tabs}
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
|
||||
course = self.store.get_course(course.id)
|
||||
assert len(course.tabs) == 7
|
||||
assert 'dates' in {tab.type for tab in course.tabs}
|
||||
assert 'progress' in {tab.type for tab in course.tabs}
|
||||
mock_logger.info.assert_any_call('4 courses read from modulestore.')
|
||||
mock_logger.info.assert_called_with(f'Updating tabs for {course.id}.')
|
||||
assert mock_logger.info.call_count == 2
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
# Ensure rerunning the command does not require another update on the course.
|
||||
# Goes up by one from the courses read log.
|
||||
assert mock_logger.info.call_count == 3
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.management.commands.backfill_course_tabs.logger')
|
||||
def test_multiple_courses_all_updated(self, mock_logger):
|
||||
"""
|
||||
Calls command on multiple courses where all of them need updates.
|
||||
"""
|
||||
course_1 = CourseFactory()
|
||||
course_1.tabs = [tab for tab in course_1.tabs if tab.type != 'dates']
|
||||
self.update_course(course_1, ModuleStoreEnum.UserID.test)
|
||||
course_2 = CourseFactory()
|
||||
course_2.tabs = [tab for tab in course_2.tabs if tab.type != 'progress']
|
||||
self.update_course(course_2, ModuleStoreEnum.UserID.test)
|
||||
assert len(course_1.tabs) == 6
|
||||
assert len(course_2.tabs) == 6
|
||||
assert 'dates' not in {tab.type for tab in course_1.tabs}
|
||||
assert 'progress' not in {tab.type for tab in course_2.tabs}
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
|
||||
course_1 = self.store.get_course(course_1.id)
|
||||
course_2 = self.store.get_course(course_2.id)
|
||||
assert len(course_1.tabs) == 7
|
||||
assert len(course_2.tabs) == 7
|
||||
assert 'dates' in {tab.type for tab in course_1.tabs}
|
||||
assert 'progress' in {tab.type for tab in course_2.tabs}
|
||||
mock_logger.info.assert_any_call('2 courses read from modulestore.')
|
||||
mock_logger.info.assert_any_call(f'Updating tabs for {course_1.id}.')
|
||||
mock_logger.info.assert_any_call(f'Updating tabs for {course_2.id}.')
|
||||
assert mock_logger.info.call_count == 3
|
||||
|
||||
call_command('backfill_course_tabs')
|
||||
# Ensure rerunning the command does not require another update on any courses.
|
||||
# Goes up by one from the courses read log.
|
||||
assert mock_logger.info.call_count == 4
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.management.commands.backfill_course_tabs.logger')
|
||||
def test_command_fails_if_error_raised(self, mock_logger):
|
||||
CourseFactory()
|
||||
with mock.patch(
|
||||
'cms.djangoapps.contentstore.management.commands.backfill_course_tabs.CourseTabList.initialize_default',
|
||||
side_effect=InvalidTabsException
|
||||
):
|
||||
with self.assertRaises(InvalidTabsException):
|
||||
call_command('backfill_course_tabs')
|
||||
# Never calls the update, but does make it through grabbing the courses
|
||||
mock_logger.info.assert_called_once_with('1 courses read from modulestore.')
|
||||
@@ -5,10 +5,10 @@ This file contains celery tasks for contentstore views
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import tarfile # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from datetime import datetime # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from tempfile import NamedTemporaryFile, mkdtemp # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import shutil
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile, mkdtemp
|
||||
|
||||
import olxcleaner
|
||||
import pkg_resources
|
||||
@@ -17,7 +17,6 @@ from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.core.files import File
|
||||
from django.test import RequestFactory
|
||||
|
||||
@@ -193,11 +193,12 @@ class PrimitiveTabEdit(ModuleStoreTestCase):
|
||||
tabs.primitive_delete(course, 1)
|
||||
with self.assertRaises(IndexError):
|
||||
tabs.primitive_delete(course, 7)
|
||||
assert course.tabs[2] != {'type': 'discussion', 'name': 'Discussion'}
|
||||
|
||||
assert course.tabs[2] != {'type': 'dates', 'name': 'Dates'}
|
||||
tabs.primitive_delete(course, 2)
|
||||
assert {'type': 'progress'} not in course.tabs
|
||||
# Check that discussion has shifted up
|
||||
assert course.tabs[2] == {'type': 'discussion', 'name': 'Discussion'}
|
||||
# Check that dates has shifted up
|
||||
assert course.tabs[2] == {'type': 'dates', 'name': 'Dates'}
|
||||
|
||||
def test_insert(self):
|
||||
"""Test primitive tab insertion."""
|
||||
|
||||
Reference in New Issue
Block a user