refactor: switch to use openedx_content app (#37924)

The openedx-learning repo was recently refactored to consolidate its
authoring apps into a single openedx_content app:

  https://github.com/openedx/openedx-platform/pull/37924

This commit makes the following changes to accommodate this:

- Bumps the openedx-learning version to 0.31.0 to get the changes.
- Creates new migrations in content_libraries, contentstore, and
  modulestore_migrator to foreign key references to authoring apps
  to point to the new openedx_content app. This is done without
  actually making database changes, since the openedx_content app
  models are taking over the existing tables that the authoring apps
  once pointed to.
- Creates new squashed migrations in these apps that create these
  foreign keys to reference openedx_content app models from the start.

The full rationale for how and why this was done is in the following
openedx-learning ADR:

  https://github.com/openedx/openedx-learning/blob/main/docs/decisions/0020-merge-authoring-apps-into-openedx-content.rst

These migrations should run fine from either a from-scratch scenario
(i.e. a new install or CI), or when upgrading from an Ulmo-or-later
database state. If you have a database state that comes from the middle
of the Ulmo development cycle (e.g. October 2025), you may encounter
migration errors in content_libraries, contentstore, or
modulestore_migrator, with an error message complaining about missing
tables. If you receive this message, run the following command:

  python manage.py lms migrate openedx_content 0001

Then try to run the migrations for the app that failed. Repeat if
necessary for multiple apps.
This commit is contained in:
David Ormsbee
2026-02-04 12:41:13 -05:00
committed by GitHub
parent b6904be3a4
commit 9bf7a72a3a
13 changed files with 541 additions and 21 deletions

View File

@@ -0,0 +1,183 @@
# Generated by Django 5.2.10 on 2026-01-30 01:23
import django.db.migrations.operations.special
import django.db.models.deletion
import opaque_keys.edx.django.models
import openedx_learning.lib.fields
import openedx_learning.lib.validators
import uuid
from django.conf import settings
from django.db import migrations, models
from cms.djangoapps.contentstore.config.waffle import ENABLE_CHECKLISTS_QUALITY
from cms.djangoapps.contentstore.toggles import ENABLE_REACT_MARKDOWN_EDITOR
def create_checklists_quality_waffle_flag(apps, schema_editor):
Flag = apps.get_model('waffle', 'Flag')
# Replacement for flag_undefined_default=True on flag definition
Flag.objects.get_or_create(name=ENABLE_CHECKLISTS_QUALITY.name, defaults={'everyone': True})
def create_markdown_editor_waffle_flag(apps, schema_editor):
Flag = apps.get_model('waffle', 'Flag')
Flag.objects.get_or_create(
name=ENABLE_REACT_MARKDOWN_EDITOR.name, defaults={'everyone': True}
)
class Migration(migrations.Migration):
replaces = [('contentstore', '0001_initial'), ('contentstore', '0002_add_assets_page_flag'), ('contentstore', '0003_remove_assets_page_flag'), ('contentstore', '0004_remove_push_notification_configmodel_table'), ('contentstore', '0005_add_enable_checklists_quality_waffle_flag'), ('contentstore', '0006_courseoutlineregenerate'), ('contentstore', '0007_backfillcoursetabsconfig'), ('contentstore', '0008_cleanstalecertificateavailabilitydatesconfig'), ('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'), ('contentstore', '0010_container_link_models'), ('contentstore', '0011_enable_markdown_editor_flag_by_default'), ('contentstore', '0012_componentlink_top_level_parent_and_more'), ('contentstore', '0013_componentlink_downstream_is_modified_and_more'), ('contentstore', '0014_remove_componentlink_downstream_is_modified_and_more'), ('contentstore', '0015_switch_to_openedx_content')]
initial = True
dependencies = [
('course_overviews', '0024_overview_adds_has_highlights'),
('oel_components', '0003_remove_componentversioncontent_learner_downloadable'),
('oel_publishing', '0002_alter_learningpackage_key_and_more'),
('oel_publishing', '0003_containers'),
('openedx_content', '0002_rename_tables_to_openedx_content'),
('waffle', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VideoUploadConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('profile_whitelist', models.TextField(blank=True, help_text='A comma-separated list of names of profiles to include in video encoding downloads.')),
('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
migrations.RunPython(
code=create_checklists_quality_waffle_flag,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.CreateModel(
name='CourseOutlineRegenerate',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('course_overviews.courseoverview',),
),
migrations.CreateModel(
name='BackfillCourseTabsConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('start_index', models.IntegerField(default=0, help_text='Index of first course to start backfilling (in an alphabetically sorted list of courses)')),
('count', models.IntegerField(default=0, help_text='How many courses to backfill in this run (or zero for all courses)')),
('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')),
],
options={
'verbose_name': 'Arguments for backfill_course_tabs',
'verbose_name_plural': 'Arguments for backfill_course_tabs',
},
),
migrations.CreateModel(
name='CleanStaleCertificateAvailabilityDatesConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('arguments', models.TextField(blank=True, help_text="A space seperated collection of arguments to be used when running the `clean_stale_certificate_available_dates` management command.' See the management command for options.")),
('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')),
],
options={
'verbose_name': "Arguments for 'clean_stale_certificate_availability_dates'",
'verbose_name_plural': "Arguments for 'clean_stale_certificate_availability_dates'",
},
),
migrations.CreateModel(
name='LearningContextLinksStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context_key', opaque_keys.edx.django.models.CourseKeyField(help_text='Linking status for course context key', max_length=255, unique=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('failed', 'Failed'), ('completed', 'Completed')], help_text='Status of links in given learning context/course.', max_length=20)),
('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
],
options={
'verbose_name': 'Learning Context Links status',
'verbose_name_plural': 'Learning Context Links status',
},
),
migrations.CreateModel(
name='ComponentLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
('upstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='Upstream block usage key, this value cannot be null and useful to track upstream library blocks that do not exist yet', max_length=255)),
('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)),
('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)),
('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('version_synced', models.IntegerField()),
('version_declined', models.IntegerField(blank=True, null=True)),
('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
('upstream_block', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.component')),
],
options={
'verbose_name': 'Component Link',
'verbose_name_plural': 'Component Links',
},
),
migrations.CreateModel(
name='ContainerLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)),
('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)),
('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('version_synced', models.IntegerField()),
('version_declined', models.IntegerField(blank=True, null=True)),
('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
('upstream_container_key', opaque_keys.edx.django.models.ContainerKeyField(help_text='Upstream block key (e.g. lct:...), this value cannot be null and is useful to track upstream library blocks that do not exist yet or were deleted.', max_length=255)),
('upstream_container', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.container')),
],
options={
'abstract': False,
'verbose_name': 'Container Link',
'verbose_name_plural': 'Container Links',
},
),
migrations.RunPython(
code=create_markdown_editor_waffle_flag,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AddField(
model_name='componentlink',
name='top_level_parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentstore.containerlink'),
),
migrations.AddField(
model_name='containerlink',
name='top_level_parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentstore.containerlink'),
),
migrations.AddField(
model_name='componentlink',
name='downstream_customized',
field=models.JSONField(default=list, help_text='Names of the fields which have values set on the upstream block yet have been explicitly overridden on this downstream block'),
),
migrations.AddField(
model_name='containerlink',
name='downstream_customized',
field=models.JSONField(default=list, help_text='Names of the fields which have values set on the upstream block yet have been explicitly overridden on this downstream block'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.10 on 2026-01-25 21:52
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.operations.special import SeparateDatabaseAndState
class Migration(migrations.Migration):
dependencies = [
('contentstore', '0014_remove_componentlink_downstream_is_modified_and_more'),
('openedx_content', '0002_rename_tables_to_openedx_content'),
]
operations = [
SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AlterField(
model_name='componentlink',
name='upstream_block',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.component'),
),
migrations.AlterField(
model_name='containerlink',
name='upstream_container',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='openedx_content.container'),
),
]
),
]

View File

@@ -0,0 +1,146 @@
# Generated by Django 5.2.10 on 2026-01-30 01:29
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import opaque_keys.edx.django.models
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [('modulestore_migrator', '0001_initial'), ('modulestore_migrator', '0002_alter_modulestoremigration_task_status'), ('modulestore_migrator', '0003_modulestoremigration_is_failed'), ('modulestore_migrator', '0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason'), ('modulestore_migrator', '0006_alter_modulestoreblocksource_forwarded_and_more'), ('modulestore_migrator', '0007_switch_to_openedx_content')]
initial = True
dependencies = [
('content_staging', '0006_alter_userclipboard_source_usage_key'),
('oel_collections', '0005_alter_collection_options_alter_collection_enabled'),
('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'),
('openedx_content', '0002_rename_tables_to_openedx_content'),
('user_tasks', '0004_url_textfield'),
]
operations = [
migrations.CreateModel(
name='ModulestoreBlockMigration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('change_log_record', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.draftchangelogrecord')),
],
),
migrations.CreateModel(
name='ModulestoreMigration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_version', models.CharField(blank=True, help_text='Migrated content version, the hash of published content version', max_length=255, null=True)),
('composition_level', models.CharField(choices=[('component', 'Component'), ('unit', 'Unit'), ('subsection', 'Subsection'), ('section', 'Section')], default='component', help_text='Maximum hierachy level at which content should be aggregated in target library', max_length=255)),
('repeat_handling_strategy', models.CharField(choices=[('skip', 'Skip'), ('fork', 'Fork'), ('update', 'Update')], default='skip', help_text='If a piece of content already exists in the content library, choose how to handle it.', max_length=24)),
('preserve_url_slugs', models.BooleanField(default=False, help_text='Should the migration preserve the location IDs of the existing blocks?If not, then new, unique human-readable IDs will be generated based on the block titles.')),
('change_log', models.ForeignKey(help_text='Changelog entry in the target learning package which records this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.draftchangelog')),
],
),
migrations.CreateModel(
name='ModulestoreSource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', opaque_keys.edx.django.models.LearningContextKeyField(help_text='Key of the content source (a course or a legacy library)', max_length=255, unique=True)),
('forwarded', models.OneToOneField(blank=True, help_text='If set, the system will forward references of this source over to the target of this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwards', to='modulestore_migrator.modulestoremigration')),
],
),
migrations.AddField(
model_name='modulestoremigration',
name='source',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='migrations', to='modulestore_migrator.modulestoresource'),
),
migrations.AddField(
model_name='modulestoremigration',
name='staged_content',
field=models.OneToOneField(help_text='Modulestore content is processed and staged before importing it to a learning packge. We temporarily save the staged content to allow for troubleshooting of failed migrations.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='content_staging.stagedcontent'),
),
migrations.AddField(
model_name='modulestoremigration',
name='target',
field=models.ForeignKey(help_text='Content will be imported into this library', on_delete=django.db.models.deletion.CASCADE, to='openedx_content.learningpackage'),
),
migrations.AddField(
model_name='modulestoremigration',
name='target_collection',
field=models.ForeignKey(blank=True, help_text='Optional - Collection (within the target library) into which imported content will be grouped', null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.collection'),
),
migrations.AddField(
model_name='modulestoremigration',
name='task_status',
field=models.ForeignKey(help_text='Tracks the status of the task which is executing this migration. In a bulk migration, the same task can be multiple migrations', on_delete=django.db.models.deletion.RESTRICT, related_name='migrations', to='user_tasks.usertaskstatus'),
),
migrations.CreateModel(
name='ModulestoreBlockSource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('key', opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key of the XBlock that has been imported.', max_length=255)),
('forwarded', models.OneToOneField(help_text='If set, the system will forward references of this block source over to the target of this block migration', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forwards', to='modulestore_migrator.modulestoreblockmigration')),
('overall_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='modulestore_migrator.modulestoresource')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='modulestoreblockmigration',
name='overall_migration',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='block_migrations', to='modulestore_migrator.modulestoremigration'),
),
migrations.AddField(
model_name='modulestoreblockmigration',
name='source',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='modulestore_migrator.modulestoreblocksource'),
),
migrations.AddField(
model_name='modulestoreblockmigration',
name='target',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openedx_content.publishableentity'),
),
migrations.AlterUniqueTogether(
name='modulestoreblockmigration',
unique_together={('overall_migration', 'source'), ('overall_migration', 'target')},
),
migrations.AddField(
model_name='modulestoremigration',
name='is_failed',
field=models.BooleanField(default=False, help_text='is the migration failed?'),
),
migrations.AlterField(
model_name='modulestoreblockmigration',
name='target',
field=models.ForeignKey(blank=True, help_text='The target entity of this block migration, set to null if it fails to migrate', null=True, on_delete=django.db.models.deletion.CASCADE, to='openedx_content.publishableentity'),
),
migrations.AddField(
model_name='modulestoreblockmigration',
name='unsupported_reason',
field=models.TextField(blank=True, help_text='Reason if the block is unsupported and target is set to null', null=True),
),
migrations.AlterField(
model_name='modulestoreblocksource',
name='forwarded',
field=models.OneToOneField(help_text='If set, the system will forward references of this block source over to the target of this block migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='modulestore_migrator.modulestoreblockmigration'),
),
migrations.AlterField(
model_name='modulestoreblocksource',
name='key',
field=opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key of the XBlock that has been imported.', max_length=255, unique=True),
),
migrations.AlterField(
model_name='modulestoresource',
name='forwarded',
field=models.OneToOneField(blank=True, help_text='If set, the system will forward references of this source over to the target of this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='modulestore_migrator.modulestoremigration'),
),
migrations.AlterField(
model_name='modulestoremigration',
name='change_log',
field=models.ForeignKey(help_text='Changelog entry in the target learning package which records this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.draftchangelog'),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.2.10 on 2026-01-25 21:52
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.operations.special import SeparateDatabaseAndState
class Migration(migrations.Migration):
dependencies = [
('modulestore_migrator', '0006_alter_modulestoreblocksource_forwarded_and_more'),
('openedx_content', '0002_rename_tables_to_openedx_content'),
]
operations = [
SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AlterField(
model_name='modulestoreblockmigration',
name='change_log_record',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.draftchangelogrecord'),
),
migrations.AlterField(
model_name='modulestoreblockmigration',
name='target',
field=models.ForeignKey(blank=True, help_text='The target entity of this block migration, set to null if it fails to migrate', null=True, on_delete=django.db.models.deletion.CASCADE, to='openedx_content.publishableentity'),
),
migrations.AlterField(
model_name='modulestoremigration',
name='change_log',
field=models.ForeignKey(help_text='Changelog entry in the target learning package which records this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.draftchangelog'),
),
migrations.AlterField(
model_name='modulestoremigration',
name='target',
field=models.ForeignKey(help_text='Content will be imported into this library', on_delete=django.db.models.deletion.CASCADE, to='openedx_content.learningpackage'),
),
migrations.AlterField(
model_name='modulestoremigration',
name='target_collection',
field=models.ForeignKey(blank=True, help_text='Optional - Collection (within the target library) into which imported content will be grouped', null=True, on_delete=django.db.models.deletion.SET_NULL, to='openedx_content.collection'),
),
]
),
]

View File

@@ -45,6 +45,7 @@ from corsheaders.defaults import default_headers as corsheaders_default_headers
from datetime import timedelta
from django.utils.translation import gettext_lazy as _
from openedx_learning.api.django import openedx_learning_apps_to_install
from openedx.envs.common import * # pylint: disable=wildcard-import
@@ -897,14 +898,7 @@ INSTALLED_APPS = [
'openedx_events',
# Learning Core Apps, used by v2 content libraries (content_libraries app)
"openedx_learning.apps.authoring.collections",
"openedx_learning.apps.authoring.components",
"openedx_learning.apps.authoring.contents",
"openedx_learning.apps.authoring.publishing",
"openedx_learning.apps.authoring.units",
"openedx_learning.apps.authoring.subsections",
"openedx_learning.apps.authoring.sections",
*openedx_learning_apps_to_install(),
]
### Apps only installed in some instances