openedx-learning 0.29.0__py2.py3-none-any.whl → 0.30.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,4 +2,4 @@
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
4
 
5
- __version__ = "0.29.0"
5
+ __version__ = "0.30.0"
@@ -47,6 +47,7 @@ from openedx_learning.apps.authoring.backup_restore.toml import (
47
47
  )
48
48
  from openedx_learning.apps.authoring.collections import api as collections_api
49
49
  from openedx_learning.apps.authoring.components import api as components_api
50
+ from openedx_learning.apps.authoring.contents import api as contents_api
50
51
  from openedx_learning.apps.authoring.publishing import api as publishing_api
51
52
  from openedx_learning.apps.authoring.sections import api as sections_api
52
53
  from openedx_learning.apps.authoring.subsections import api as subsections_api
@@ -493,6 +494,7 @@ class LearningPackageUnzipper:
493
494
  self.zipf = zipf
494
495
  self.user = user
495
496
  self.lp_key = key # If provided, use this key for the restored learning package
497
+ self.learning_package_id: int | None = None # Will be set upon restoration
496
498
  self.utc_now: datetime = datetime.now(timezone.utc)
497
499
  self.component_types_cache: dict[tuple[str, str], ComponentType] = {}
498
500
  self.errors: list[dict[str, Any]] = []
@@ -735,6 +737,7 @@ class LearningPackageUnzipper:
735
737
  learning_package["key"] = self.lp_key
736
738
 
737
739
  learning_package_obj = publishing_api.create_learning_package(**learning_package)
740
+ self.learning_package_id = learning_package_obj.id
738
741
 
739
742
  with publishing_api.bulk_draft_changes_for(learning_package_obj.id):
740
743
  self._save_components(learning_package_obj, components, component_static_files)
@@ -937,16 +940,31 @@ class LearningPackageUnzipper:
937
940
  num_version: int,
938
941
  entity_key: str,
939
942
  static_files_map: dict[str, List[str]]
940
- ) -> dict[str, bytes]:
943
+ ) -> dict[str, bytes | int]:
941
944
  """Resolve static file paths into their binary content."""
942
- resolved_files: dict[str, bytes] = {}
945
+ resolved_files: dict[str, bytes | int] = {}
943
946
 
944
- static_file_key = f"{entity_key}:v{num_version}" # e.g., "my_component:123:v1"
947
+ static_file_key = f"{entity_key}:v{num_version}" # e.g., "xblock.v1:html:my_component_123456:v1"
948
+ block_type = entity_key.split(":")[1] # e.g., "html"
945
949
  static_files = static_files_map.get(static_file_key, [])
946
950
  for static_file in static_files:
947
951
  local_key = static_file.split(f"v{num_version}/")[-1]
948
952
  with self.zipf.open(static_file, "r") as f:
949
- resolved_files[local_key] = f.read()
953
+ content_bytes = f.read()
954
+ if local_key == "block.xml":
955
+ # Special handling for block.xml to ensure
956
+ # storing the value as a content instance
957
+ if not self.learning_package_id:
958
+ raise ValueError("learning_package_id must be set before resolving static files.")
959
+ text_content = contents_api.get_or_create_text_content(
960
+ self.learning_package_id,
961
+ contents_api.get_or_create_media_type(f"application/vnd.openedx.xblock.v1.{block_type}+xml").id,
962
+ text=content_bytes.decode("utf-8"),
963
+ created=self.utc_now,
964
+ )
965
+ resolved_files[local_key] = text_content.id
966
+ else:
967
+ resolved_files[local_key] = content_bytes
950
968
  return resolved_files
951
969
 
952
970
  def _resolve_children(self, entity_data: dict[str, Any], lookup_map: dict[str, Any]) -> list[Any]:
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
  import functools
7
7
 
8
8
  from django.contrib import admin
9
- from django.db.models import Count
9
+ from django.db.models import Count, F
10
10
  from django.utils.html import format_html
11
11
  from django.utils.safestring import SafeText
12
12
 
@@ -21,6 +21,7 @@ from .models import (
21
21
  EntityListRow,
22
22
  LearningPackage,
23
23
  PublishableEntity,
24
+ PublishableEntityVersion,
24
25
  PublishLog,
25
26
  PublishLogRecord,
26
27
  )
@@ -48,6 +49,7 @@ class PublishLogRecordTabularInline(admin.TabularInline):
48
49
  "title",
49
50
  "old_version_num",
50
51
  "new_version_num",
52
+ "dependencies_hash_digest",
51
53
  )
52
54
  readonly_fields = fields
53
55
 
@@ -89,28 +91,89 @@ class PublishLogAdmin(ReadOnlyModelAdmin):
89
91
  list_filter = ["learning_package"]
90
92
 
91
93
 
94
+ class PublishableEntityVersionTabularInline(admin.TabularInline):
95
+ """
96
+ Tabular inline for a single Draft change.
97
+ """
98
+ model = PublishableEntityVersion
99
+
100
+ fields = (
101
+ "version_num",
102
+ "title",
103
+ "created",
104
+ "created_by",
105
+ "dependencies_list",
106
+ )
107
+ readonly_fields = fields
108
+
109
+ def dependencies_list(self, version: PublishableEntityVersion):
110
+ identifiers = sorted(
111
+ [str(dep.key) for dep in version.dependencies.all()]
112
+ )
113
+ return "\n".join(identifiers)
114
+
115
+ def get_queryset(self, request):
116
+ queryset = super().get_queryset(request)
117
+ return (
118
+ queryset
119
+ .order_by('-version_num')
120
+ .select_related('created_by', 'entity')
121
+ .prefetch_related('dependencies')
122
+ )
123
+
124
+
125
+ class PublishStatusFilter(admin.SimpleListFilter):
126
+ """
127
+ Custom filter for entities that have unpublished changes.
128
+ """
129
+ title = "publish status"
130
+ parameter_name = "publish_status"
131
+
132
+ def lookups(self, request, model_admin):
133
+ return [
134
+ ("unpublished_changes", "Has unpublished changes"),
135
+ ]
136
+
137
+ def queryset(self, request, queryset):
138
+ if self.value() == "unpublished_changes":
139
+ return (
140
+ queryset
141
+ .exclude(
142
+ published__version__isnull=True,
143
+ draft__version__isnull=True,
144
+ )
145
+ .exclude(
146
+ published__version=F("draft__version"),
147
+ published__dependencies_hash_digest=F("draft__dependencies_hash_digest")
148
+ )
149
+ )
150
+ return queryset
151
+
152
+
92
153
  @admin.register(PublishableEntity)
93
154
  class PublishableEntityAdmin(ReadOnlyModelAdmin):
94
155
  """
95
156
  Read-only admin view for Publishable Entities
96
157
  """
158
+ inlines = [PublishableEntityVersionTabularInline]
159
+
97
160
  list_display = [
98
161
  "key",
99
- "draft_version",
100
162
  "published_version",
163
+ "draft_version",
101
164
  "uuid",
102
165
  "learning_package",
103
166
  "created",
104
167
  "created_by",
105
168
  "can_stand_alone",
106
169
  ]
107
- list_filter = ["learning_package"]
170
+ list_filter = ["learning_package", PublishStatusFilter]
108
171
  search_fields = ["key", "uuid"]
109
172
 
110
173
  fields = [
111
174
  "key",
112
- "draft_version",
113
175
  "published_version",
176
+ "draft_version",
114
177
  "uuid",
115
178
  "learning_package",
116
179
  "created",
@@ -120,8 +183,8 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
120
183
  ]
121
184
  readonly_fields = [
122
185
  "key",
123
- "draft_version",
124
186
  "published_version",
187
+ "draft_version",
125
188
  "uuid",
126
189
  "learning_package",
127
190
  "created",
@@ -130,21 +193,55 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
130
193
  "can_stand_alone",
131
194
  ]
132
195
 
133
- def draft_version(self, entity: PublishableEntity):
134
- return entity.draft.version.version_num if entity.draft.version else None
135
-
136
- def published_version(self, entity: PublishableEntity):
137
- return entity.published.version.version_num if entity.published and entity.published.version else None
138
-
139
196
  def get_queryset(self, request):
140
197
  queryset = super().get_queryset(request)
141
198
  return queryset.select_related(
142
- "learning_package", "published__version",
199
+ "learning_package", "published__version", "draft__version", "created_by"
143
200
  )
144
201
 
145
202
  def see_also(self, entity):
146
203
  return one_to_one_related_model_html(entity)
147
204
 
205
+ def draft_version(self, entity: PublishableEntity):
206
+ """
207
+ Version num + dependency hash if applicable, e.g. "5" or "5 (825064c2)"
208
+
209
+ If the version info is different from the published version, we
210
+ italicize the text for emphasis.
211
+ """
212
+ if hasattr(entity, "draft") and entity.draft.version:
213
+ draft_log_record = entity.draft.draft_log_record
214
+ if draft_log_record and draft_log_record.dependencies_hash_digest:
215
+ version_str = (
216
+ f"{entity.draft.version.version_num} "
217
+ f"({draft_log_record.dependencies_hash_digest})"
218
+ )
219
+ else:
220
+ version_str = str(entity.draft.version.version_num)
221
+
222
+ if version_str == self.published_version(entity):
223
+ return version_str
224
+ else:
225
+ return format_html("<em>{}</em>", version_str)
226
+
227
+ return None
228
+
229
+ def published_version(self, entity: PublishableEntity):
230
+ """
231
+ Version num + dependency hash if applicable, e.g. "5" or "5 (825064c2)"
232
+ """
233
+ if hasattr(entity, "published") and entity.published.version:
234
+ publish_log_record = entity.published.publish_log_record
235
+ if publish_log_record.dependencies_hash_digest:
236
+ return (
237
+ f"{entity.published.version.version_num} "
238
+ f"({publish_log_record.dependencies_hash_digest})"
239
+ )
240
+ else:
241
+ return str(entity.published.version.version_num)
242
+
243
+ return None
244
+
148
245
 
149
246
  @admin.register(Published)
150
247
  class PublishedAdmin(ReadOnlyModelAdmin):
@@ -197,6 +294,7 @@ class DraftChangeLogRecordTabularInline(admin.TabularInline):
197
294
  "title",
198
295
  "old_version_num",
199
296
  "new_version_num",
297
+ "dependencies_hash_digest",
200
298
  )
201
299
  readonly_fields = fields
202
300