Skip to content

Commit 2d5a17b

Browse files
authored
feat!: Collection.key -> Collection.collection_code (#542)
Also, standardize internal usage of collection_key to collection_code. This helps clarify that Collection.key is *not* an OpaqueKey, but is rather a local slug, which can be combined with other identifiers to form a fully- qualified LibraryCollectionKey instance. BREAKING CHANGE: Collection.key has been renamed to Collection.collection_code. BREAKING CHANGE: Collection.collection_code now validates that its contents matches '[A-Za-z0-9\-\_\.]+'. This was already effectively true, because LibraryCollectionKey can only be built with slug-like parts, but we now we explicitly raise ValiationError from create_collection. Backup-restore still write and reads the collection_code to/from TOML files as `key` for backwards compatibility. This may change in a future "v2" restore format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Part of: * #322
1 parent 10eae2d commit 2d5a17b

16 files changed

Lines changed: 258 additions & 95 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.claude
12
*.py[cod]
23
__pycache__
34
.pytest_cache

‎src/openedx_content/applets/backup_restore/serializers.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ class CollectionSerializer(serializers.Serializer): # pylint: disable=abstract-
156156
Serializer for collections.
157157
"""
158158
title = serializers.CharField(required=True)
159-
key = serializers.CharField(required=True)
159+
# The model field is now Collection.collection_code, but the archive format
160+
# still uses "key". A future v2 format may align the name.
161+
key = serializers.CharField(required=True, source="collection_code")
160162
description = serializers.CharField(required=True, allow_blank=True)
161163
entities = serializers.ListField(
162164
child=serializers.CharField(),

‎src/openedx_content/applets/backup_restore/toml.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,9 @@ def toml_collection(collection: Collection, entity_keys: list[str]) -> str:
220220

221221
collection_table = tomlkit.table()
222222
collection_table.add("title", collection.title)
223-
collection_table.add("key", collection.key)
223+
# Note: the model field is now Collection.collection_code, but the archive
224+
# format still uses "key". A future v2 format may align the name.
225+
collection_table.add("key", collection.collection_code)
224226
collection_table.add("description", collection.description)
225227
collection_table.add("created", collection.created)
226228
collection_table.add("entities", entities_array)

‎src/openedx_content/applets/backup_restore/zipper.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ def create_zip(self, path: str) -> None:
401401
collections = self.get_collections()
402402

403403
for collection in collections:
404-
collection_hash_slug = self.get_entity_toml_filename(collection.key)
404+
collection_hash_slug = self.get_entity_toml_filename(collection.collection_code)
405405
collection_toml_file_path = collections_folder / f"{collection_hash_slug}.toml"
406406
entity_keys_related = collection.entities.order_by("key").values_list("key", flat=True)
407407
self.add_file_to_zip(
@@ -779,7 +779,7 @@ def _save_collections(self, learning_package, collections):
779779
)
780780
collection = collections_api.add_to_collection(
781781
learning_package_id=learning_package.id,
782-
key=collection.key,
782+
collection_code=collection.collection_code,
783783
entities_qset=publishing_api.get_publishable_entities(learning_package.id).filter(key__in=entities)
784784
)
785785

‎src/openedx_content/applets/collections/admin.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ class CollectionAdmin(admin.ModelAdmin):
1313
1414
Allows users to easily disable/enable (aka soft delete and restore) or bulk delete Collections.
1515
"""
16-
readonly_fields = ["key", "learning_package"]
16+
readonly_fields = ["collection_code", "learning_package"]
1717
list_filter = ["enabled"]
18-
list_display = ["key", "title", "enabled", "modified"]
18+
list_display = ["collection_code", "title", "enabled", "modified"]
1919
fieldsets = [
2020
(
2121
"",
2222
{
23-
"fields": ["key", "learning_package"],
23+
"fields": ["collection_code", "learning_package"],
2424
}
2525
),
2626
(

‎src/openedx_content/applets/collections/api.py‎

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
def create_collection(
3636
learning_package_id: LearningPackage.ID,
37-
key: str,
37+
collection_code: str,
3838
*,
3939
title: str,
4040
created_by: int | None,
@@ -44,35 +44,37 @@ def create_collection(
4444
"""
4545
Create a new Collection
4646
"""
47-
collection = Collection.objects.create(
47+
collection = Collection(
4848
learning_package_id=learning_package_id,
49-
key=key,
49+
collection_code=collection_code,
5050
title=title,
5151
created_by_id=created_by,
5252
description=description,
5353
enabled=enabled,
5454
)
55+
collection.full_clean()
56+
collection.save()
5557
return collection
5658

5759

58-
def get_collection(learning_package_id: LearningPackage.ID, collection_key: str) -> Collection:
60+
def get_collection(learning_package_id: LearningPackage.ID, collection_code: str) -> Collection:
5961
"""
6062
Get a Collection by ID
6163
"""
62-
return Collection.objects.get_by_key(learning_package_id, collection_key)
64+
return Collection.objects.get_by_code(learning_package_id, collection_code)
6365

6466

6567
def update_collection(
6668
learning_package_id: LearningPackage.ID,
67-
key: str,
69+
collection_code: str,
6870
*,
6971
title: str | None = None,
7072
description: str | None = None,
7173
) -> Collection:
7274
"""
73-
Update a Collection identified by the learning_package_id + key.
75+
Update a Collection identified by the learning_package_id + collection_code.
7476
"""
75-
collection = get_collection(learning_package_id, key)
77+
collection = get_collection(learning_package_id, collection_code)
7678

7779
# If no changes were requested, there's nothing to update, so just return
7880
# the Collection as-is
@@ -90,17 +92,17 @@ def update_collection(
9092

9193
def delete_collection(
9294
learning_package_id: LearningPackage.ID,
93-
key: str,
95+
collection_code: str,
9496
*,
9597
hard_delete=False,
9698
) -> Collection:
9799
"""
98-
Disables or deletes a collection identified by the given learning_package + key.
100+
Disables or deletes a collection identified by the given learning_package + collection_code.
99101
100102
By default (hard_delete=False), the collection is "soft deleted", i.e disabled.
101103
Soft-deleted collections can be re-enabled using restore_collection.
102104
"""
103-
collection = get_collection(learning_package_id, key)
105+
collection = get_collection(learning_package_id, collection_code)
104106

105107
if hard_delete:
106108
collection.delete()
@@ -112,12 +114,12 @@ def delete_collection(
112114

113115
def restore_collection(
114116
learning_package_id: LearningPackage.ID,
115-
key: str,
117+
collection_code: str,
116118
) -> Collection:
117119
"""
118120
Undo a "soft delete" by re-enabling a Collection.
119121
"""
120-
collection = get_collection(learning_package_id, key)
122+
collection = get_collection(learning_package_id, collection_code)
121123

122124
collection.enabled = True
123125
collection.save()
@@ -126,7 +128,7 @@ def restore_collection(
126128

127129
def add_to_collection(
128130
learning_package_id: LearningPackage.ID,
129-
key: str,
131+
collection_code: str,
130132
entities_qset: QuerySet[PublishableEntity],
131133
created_by: int | None = None,
132134
) -> Collection:
@@ -146,10 +148,10 @@ def add_to_collection(
146148
if invalid_entity:
147149
raise ValidationError(
148150
f"Cannot add entity {invalid_entity.id} in learning package {invalid_entity.learning_package_id} "
149-
f"to collection {key} in learning package {learning_package_id}."
151+
f"to collection {collection_code} in learning package {learning_package_id}."
150152
)
151153

152-
collection = get_collection(learning_package_id, key)
154+
collection = get_collection(learning_package_id, collection_code)
153155
collection.entities.add(
154156
*entities_qset.all(),
155157
through_defaults={"created_by_id": created_by},
@@ -162,7 +164,7 @@ def add_to_collection(
162164

163165
def remove_from_collection(
164166
learning_package_id: LearningPackage.ID,
165-
key: str,
167+
collection_code: str,
166168
entities_qset: QuerySet[PublishableEntity],
167169
) -> Collection:
168170
"""
@@ -174,7 +176,7 @@ def remove_from_collection(
174176
175177
Returns the updated Collection.
176178
"""
177-
collection = get_collection(learning_package_id, key)
179+
collection = get_collection(learning_package_id, collection_code)
178180

179181
collection.entities.remove(*entities_qset.all())
180182
collection.modified = datetime.now(tz=timezone.utc)
@@ -198,7 +200,7 @@ def get_entity_collections(learning_package_id: LearningPackage.ID, entity_key:
198200

199201
def get_collection_entities(
200202
learning_package_id: LearningPackage.ID,
201-
collection_key: str,
203+
collection_code: str,
202204
) -> QuerySet[PublishableEntity]:
203205
"""
204206
Returns a QuerySet of PublishableEntities in a Collection.
@@ -207,7 +209,7 @@ def get_collection_entities(
207209
"""
208210
return PublishableEntity.objects.filter(
209211
learning_package_id=learning_package_id,
210-
collections__key=collection_key,
212+
collections__collection_code=collection_code,
211213
).order_by("pk")
212214

213215

‎src/openedx_content/applets/collections/models.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
from django.db import models
7171
from django.utils.translation import gettext_lazy as _
7272

73-
from openedx_django_lib.fields import MultiCollationTextField, case_insensitive_char_field, key_field
73+
from openedx_django_lib.fields import MultiCollationTextField, case_insensitive_char_field, code_field, code_field_check
7474
from openedx_django_lib.validators import validate_utc_datetime
7575

7676
from ..publishing.models import LearningPackage, PublishableEntity
@@ -85,12 +85,12 @@ class CollectionManager(models.Manager):
8585
"""
8686
Custom manager for Collection class.
8787
"""
88-
def get_by_key(self, learning_package_id: int, key: str):
88+
def get_by_code(self, learning_package_id: int, collection_code: str):
8989
"""
90-
Get the Collection for the given Learning Package + key.
90+
Get the Collection for the given Learning Package + collection code.
9191
"""
9292
return self.select_related('learning_package') \
93-
.get(learning_package_id=learning_package_id, key=key)
93+
.get(learning_package_id=learning_package_id, collection_code=collection_code)
9494

9595

9696
class Collection(models.Model):
@@ -105,10 +105,11 @@ class Collection(models.Model):
105105
learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
106106

107107
# Every collection is uniquely and permanently identified within its learning package
108-
# by a 'key' that is set during creation. Both will appear in the
108+
# by a 'code' that is set during creation. Both will appear in the
109109
# collection's opaque key:
110-
# e.g. "lib-collection:lib:key" is the opaque key for a library collection.
111-
key = key_field(db_column='_key')
110+
# e.g. "lib-collection:{org_code}:{library_code}:{collection_code}"
111+
# is the opaque key for a library collection.
112+
collection_code = code_field()
112113

113114
title = case_insensitive_char_field(
114115
null=False,
@@ -170,14 +171,15 @@ class Collection(models.Model):
170171
class Meta:
171172
verbose_name_plural = "Collections"
172173
constraints = [
173-
# Keys are unique within a given LearningPackage.
174+
# Collection codes are unique within a given LearningPackage.
174175
models.UniqueConstraint(
175176
fields=[
176177
"learning_package",
177-
"key",
178+
"collection_code",
178179
],
179180
name="oel_coll_uniq_lp_key",
180181
),
182+
code_field_check("collection_code", name="oel_coll_collection_code_regex"),
181183
]
182184
indexes = [
183185
models.Index(
@@ -196,7 +198,7 @@ def __str__(self) -> str:
196198
"""
197199
User-facing string representation of a Collection.
198200
"""
199-
return f"<{self.__class__.__name__}> (lp:{self.learning_package_id} {self.key}:{self.title})"
201+
return f"<{self.__class__.__name__}> (lp:{self.learning_package_id} {self.collection_code}:{self.title})"
200202

201203

202204
class CollectionPublishableEntity(models.Model):

‎src/openedx_content/applets/components/api.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ def get_components( # pylint: disable=too-many-positional-arguments
436436

437437
def get_collection_components(
438438
learning_package_id: LearningPackage.ID,
439-
collection_key: str,
439+
collection_code: str,
440440
) -> QuerySet[Component]:
441441
"""
442442
Returns a QuerySet of Components relating to the PublishableEntities in a Collection.
@@ -445,7 +445,7 @@ def get_collection_components(
445445
"""
446446
return Component.objects.filter(
447447
learning_package_id=learning_package_id,
448-
publishable_entity__collections__key=collection_key,
448+
publishable_entity__collections__collection_code=collection_code,
449449
).order_by('pk')
450450

451451

‎src/openedx_content/applets/units/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Models that implement units
33
"""
4+
from __future__ import annotations
45

56
from typing import NewType, cast, override
67

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
Rename Collection.key -> Collection.collection_code and change from key_field to code_field.
3+
"""
4+
import re
5+
6+
import django.core.validators
7+
import django.db.models.lookups
8+
from django.conf import settings
9+
from django.db import migrations, models
10+
11+
import openedx_django_lib.fields
12+
13+
14+
class Migration(migrations.Migration):
15+
16+
dependencies = [
17+
('openedx_content', '0007_publishlogrecord_direct'),
18+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
19+
]
20+
21+
operations = [
22+
# Drop old constraint (references the old field name).
23+
migrations.RemoveConstraint(
24+
model_name='collection',
25+
name='oel_coll_uniq_lp_key',
26+
),
27+
# Rename the column.
28+
migrations.RenameField(
29+
model_name='collection',
30+
old_name='key',
31+
new_name='collection_code',
32+
),
33+
# Change from key_field (max_length=500, no validator) to code_field
34+
# (max_length=255, with regex validator).
35+
migrations.AlterField(
36+
model_name='collection',
37+
name='collection_code',
38+
field=openedx_django_lib.fields.MultiCollationCharField(
39+
db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'},
40+
max_length=255,
41+
validators=[
42+
django.core.validators.RegexValidator(
43+
re.compile('^[a-zA-Z0-9_.-]+\\Z'),
44+
'Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.',
45+
'invalid',
46+
),
47+
],
48+
),
49+
),
50+
# Re-add uniqueness constraint with the new field name.
51+
migrations.AddConstraint(
52+
model_name='collection',
53+
constraint=models.UniqueConstraint(
54+
fields=('learning_package', 'collection_code'),
55+
name='oel_coll_uniq_lp_key',
56+
),
57+
),
58+
# DB-level regex check constraint.
59+
migrations.AddConstraint(
60+
model_name='collection',
61+
constraint=models.CheckConstraint(
62+
condition=django.db.models.lookups.Regex(
63+
models.F('collection_code'),
64+
'^[a-zA-Z0-9_.-]+\\Z',
65+
),
66+
name='oel_coll_collection_code_regex',
67+
violation_error_message=(
68+
'Enter a valid "code name" consisting of letters, numbers,'
69+
' underscores, hyphens, or periods.'
70+
),
71+
),
72+
),
73+
]

0 commit comments

Comments
 (0)