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:
Dillon Dumesnil
2022-03-09 12:27:18 -05:00
parent 50f348b395
commit 61b93d953d
13 changed files with 304 additions and 189 deletions

View File

@@ -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)

View File

@@ -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.')

View File

@@ -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

View File

@@ -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."""