feat: bump opaque-keys to get case-sensitivity support + default max_length (#38044)

refactor: remove some 'max_length=255' to be more DRY

feat: example of making an OpaqueKeyField case_sensitive (modulestore_migrator)

test: update test now that we're using case-insensitive collation on SQLite
This commit is contained in:
Braden MacDonald
2026-02-23 15:22:52 -08:00
committed by Braden MacDonald
parent ef783a1bca
commit 3e522d5272
19 changed files with 65 additions and 36 deletions

View File

@@ -97,9 +97,9 @@ class EntityLinkBase(models.Model):
)
# A downstream entity can only link to single upstream entity
# whereas an entity can be upstream for multiple downstream entities.
downstream_usage_key = UsageKeyField(max_length=255, unique=True)
downstream_usage_key = UsageKeyField(unique=True)
# Search by course/downstream key
downstream_context_key = CourseKeyField(max_length=255, db_index=True)
downstream_context_key = CourseKeyField(db_index=True)
# This is present if the creation of this link is a consequence of
# importing a container that has one or more levels of children.
# This represents the parent (container) in the top level
@@ -152,7 +152,6 @@ class ComponentLink(EntityLinkBase):
blank=True,
)
upstream_usage_key = UsageKeyField(
max_length=255,
help_text=_(
"Upstream block usage key, this value cannot be null"
" and useful to track upstream library blocks that do not exist yet"
@@ -324,7 +323,6 @@ class ContainerLink(EntityLinkBase):
blank=True,
)
upstream_container_key = ContainerKeyField(
max_length=255,
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 "
@@ -564,7 +562,6 @@ class LearningContextLinksStatus(models.Model):
course or a learning context.
"""
context_key = CourseKeyField(
max_length=255,
# Single entry for a learning context or course
unique=True,
help_text=_("Linking status for course context key"),

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.11 on 2026-02-23 23:41
import opaque_keys.edx.django.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("modulestore_migrator", "0001_squashed_0007_switch_to_openedx_content"),
]
operations = [
migrations.AlterField(
model_name="modulestoreblocksource",
name="key",
field=opaque_keys.edx.django.models.UsageKeyField(
case_sensitive=True,
help_text="Original usage key of the XBlock that has been imported.",
max_length=255,
unique=True,
),
),
migrations.AlterField(
model_name="modulestoresource",
name="key",
field=opaque_keys.edx.django.models.LearningContextKeyField(
case_sensitive=True,
help_text="Key of the content source (a course or a legacy library)",
max_length=255,
unique=True,
),
),
]

View File

@@ -47,8 +47,8 @@ class ModulestoreSource(models.Model):
control whether `forwarded` is set to any given migration.
"""
key = LearningContextKeyField(
max_length=255,
unique=True,
case_sensitive=True,
help_text=_('Key of the content source (a course or a legacy library)'),
)
forwarded = models.OneToOneField(
@@ -189,7 +189,7 @@ class ModulestoreBlockSource(TimeStampedModel):
related_name="blocks",
)
key = UsageKeyField(
max_length=255,
case_sensitive=True,
unique=True,
help_text=_('Original usage key of the XBlock that has been imported.'),
)

View File

@@ -943,7 +943,7 @@ class CourseModesArchive(models.Model):
app_label = "course_modes"
# the course that this mode is attached to
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
# the reference to this mode that can be used by Enrollments to generate
# similar behavior for the same slug across courses

View File

@@ -64,7 +64,7 @@ class CourseMessage(models.Model):
.. no_pii:
"""
global_message = models.ForeignKey(GlobalStatusMessage, on_delete=models.CASCADE)
course_key = CourseKeyField(max_length=255, blank=True, db_index=True)
course_key = CourseKeyField(blank=True, db_index=True)
message = models.TextField(blank=True, null=True)
def __str__(self):

View File

@@ -93,7 +93,7 @@ class AnonymousUserId(models.Model):
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
anonymous_user_id = models.CharField(unique=True, max_length=32)
course_id = LearningContextKeyField(db_index=True, max_length=255, blank=True)
course_id = LearningContextKeyField(db_index=True, blank=True)
def anonymous_id_for_user(user, course_id):
@@ -1058,7 +1058,7 @@ class CourseAccessRole(models.Model):
# blank org is for global group based roles such as course creator (may be deprecated)
org = models.CharField(max_length=64, db_index=True, blank=True)
# blank course_id implies org wide role
course_id = CourseKeyField(max_length=255, db_index=True, blank=True)
course_id = CourseKeyField(db_index=True, blank=True)
role = models.CharField(max_length=64, db_index=True)
class Meta:
@@ -1116,7 +1116,7 @@ class CourseAccessRoleHistory(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
org = models.CharField(max_length=64, db_index=True, blank=True)
course_id = CourseKeyField(max_length=255, db_index=True, blank=True)
course_id = CourseKeyField(db_index=True, blank=True)
role = models.CharField(max_length=64, db_index=True)
action_type = models.CharField(max_length=10, choices=ACTION_CHOICES, db_index=True)
changed_by = models.ForeignKey(
@@ -1493,7 +1493,7 @@ class EntranceExamConfiguration(models.Model):
"""
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)

View File

@@ -255,7 +255,7 @@ class CourseEmail(Email):
class Meta:
app_label = "bulk_email"
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
# to_option is deprecated and unused, but dropping db columns is hard so it's still here for legacy reasons
to_option = models.CharField(max_length=64, choices=[("deprecated", "deprecated")])
targets = models.ManyToManyField(Target)
@@ -314,7 +314,7 @@ class Optout(models.Model):
# We need to first create the 'user' column with some sort of default in order to run the data migration,
# and given the unique index, 'null' is the best default value.
user = models.ForeignKey(User, db_index=True, null=True, on_delete=models.CASCADE)
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
class Meta:
app_label = "bulk_email"
@@ -430,7 +430,7 @@ class CourseAuthorization(models.Model):
app_label = "bulk_email"
# The course that these features are attached to.
course_id = CourseKeyField(max_length=255, db_index=True, unique=True)
course_id = CourseKeyField(db_index=True, unique=True)
# Whether or not to enable instructor email
email_enabled = models.BooleanField(default=False)
@@ -462,7 +462,7 @@ class DisabledCourse(models.Model):
class Meta:
app_label = "bulk_email"
course_id = CourseKeyField(max_length=255, db_index=True, unique=True)
course_id = CourseKeyField(db_index=True, unique=True)
@classmethod
def instructor_email_disabled_for_course(cls, course_id):

View File

@@ -26,7 +26,7 @@ class CustomCourseForEdX(models.Model):
.. no_pii:
"""
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
display_name = models.CharField(max_length=255)
coach = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
# if not empty, this field contains a json serialized list of
@@ -112,7 +112,7 @@ class CcxFieldOverride(models.Model):
.. no_pii:
"""
ccx = models.ForeignKey(CustomCourseForEdX, db_index=True, on_delete=models.CASCADE)
location = UsageKeyField(max_length=255, db_index=True)
location = UsageKeyField(db_index=True)
field = models.CharField(max_length=255)
class Meta:

View File

@@ -60,7 +60,7 @@ class CertificateAllowlist(TimeStampedModel):
objects = NoneToEmptyManager()
user = models.ForeignKey(User, on_delete=models.CASCADE)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
course_id = CourseKeyField(blank=True, default=None)
allowlist = models.BooleanField(default=0)
notes = models.TextField(default=None, null=True)
@@ -219,7 +219,7 @@ class GeneratedCertificate(models.Model):
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
course_id = CourseKeyField(blank=True, default=None)
verify_uuid = models.CharField(max_length=32, blank=True, default='', db_index=True)
grade = models.CharField(max_length=5, blank=True, default='')
key = models.CharField(max_length=32, blank=True, default='')
@@ -554,7 +554,7 @@ class CertificateGenerationHistory(TimeStampedModel):
.. no_pii:
"""
course_id = CourseKeyField(max_length=255)
course_id = CourseKeyField()
generated_by = models.ForeignKey(User, on_delete=models.CASCADE)
instructor_task = models.ForeignKey(InstructorTask, on_delete=models.CASCADE)
is_regeneration = models.BooleanField(default=False)
@@ -714,7 +714,7 @@ class ExampleCertificateSet(TimeStampedModel):
.. no_pii:
"""
course_key = CourseKeyField(max_length=255, db_index=True)
course_key = CourseKeyField(db_index=True)
class Meta:
get_latest_by = 'created'
@@ -975,7 +975,7 @@ class CertificateGenerationCourseSetting(TimeStampedModel):
.. no_pii:
"""
course_key = CourseKeyField(max_length=255, db_index=True)
course_key = CourseKeyField(db_index=True)
self_generation_enabled = models.BooleanField(
default=False,

View File

@@ -446,8 +446,8 @@ class CourseListSearchViewTest(CourseApiTestViewMixin, ModuleStoreTestCase, Sear
self.setup_user(self.audit_user)
# These query counts were found empirically
query_counts = [52, 46, 46, 46, 46, 46, 46, 46, 46, 46, 16]
ordered_course_ids = sorted([str(cid) for cid in (course_ids + [c.id for c in self.courses])])
query_counts = [57, 46, 46, 46, 46, 46, 46, 46, 46, 43, 12]
ordered_course_ids = sorted([str(cid) for cid in (course_ids + [c.id for c in self.courses])], key=str.lower)
self.clear_caches()

View File

@@ -129,8 +129,8 @@ class GradedAssignment(models.Model):
.. no_pii:
"""
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
course_key = CourseKeyField(max_length=255, db_index=True)
usage_key = UsageKeyField(max_length=255, db_index=True)
course_key = CourseKeyField(db_index=True)
usage_key = UsageKeyField(db_index=True)
outcome_service = models.ForeignKey(OutcomeService, on_delete=models.CASCADE)
lis_result_sourcedid = models.CharField(max_length=255, db_index=True)
version_number = models.IntegerField(default=0)

View File

@@ -128,7 +128,7 @@ class ProgramCourseEnrollment(TimeStampedModel):
blank=True,
on_delete=models.CASCADE
)
course_key = CourseKeyField(max_length=255)
course_key = CourseKeyField()
status = models.CharField(max_length=9, choices=STATUS_CHOICES)
historical_records = HistoricalRecords()

View File

@@ -24,7 +24,7 @@ class CourseResetCourseOptIn(TimeStampedModel):
.. no_pii:
"""
course_id = CourseKeyField(max_length=255, unique=True)
course_id = CourseKeyField(unique=True)
active = BooleanField()
def __str__(self):

View File

@@ -130,7 +130,7 @@ class CourseTeam(models.Model):
team_id = models.SlugField(max_length=255, unique=True)
discussion_topic_id = models.SlugField(max_length=255, unique=True)
name = models.CharField(max_length=255, db_index=True)
course_id = CourseKeyField(max_length=255, db_index=True)
course_id = CourseKeyField(db_index=True)
topic_id = models.CharField(default='', max_length=255, db_index=True, blank=True)
date_created = models.DateTimeField(auto_now_add=True)
description = models.CharField(max_length=300)

View File

@@ -1091,7 +1091,6 @@ class VerificationDeadline(TimeStampedModel):
app_label = "verify_student"
course_key = CourseKeyField(
max_length=255,
db_index=True,
unique=True,
help_text=gettext_lazy("The course for which this deadline applies"),

View File

@@ -485,7 +485,7 @@ edx-i18n-tools==1.9.0
# xblocks-contrib
edx-milestones==1.1.0
# via -r requirements/edx/kernel.in
edx-opaque-keys[django]==3.0.0
edx-opaque-keys[django]==3.1.0
# via
# -r requirements/edx/kernel.in
# edx-bulk-grades

View File

@@ -777,7 +777,7 @@ edx-milestones==1.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-opaque-keys[django]==3.0.0
edx-opaque-keys[django]==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt

View File

@@ -574,7 +574,7 @@ edx-i18n-tools==1.9.0
# xblocks-contrib
edx-milestones==1.1.0
# via -r requirements/edx/base.txt
edx-opaque-keys[django]==3.0.0
edx-opaque-keys[django]==3.1.0
# via
# -r requirements/edx/base.txt
# edx-bulk-grades

View File

@@ -598,7 +598,7 @@ edx-lint==5.6.0
# via -r requirements/edx/testing.in
edx-milestones==1.1.0
# via -r requirements/edx/base.txt
edx-opaque-keys[django]==3.0.0
edx-opaque-keys[django]==3.1.0
# via
# -r requirements/edx/base.txt
# edx-bulk-grades