openedx-learning 0.2.6__tar.gz → 0.3.3__tar.gz

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.
Files changed (108) hide show
  1. {openedx-learning-0.2.6/openedx_learning.egg-info → openedx-learning-0.3.3}/PKG-INFO +2 -2
  2. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/README.rst +1 -1
  3. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/__init__.py +1 -1
  4. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/models.py +1 -1
  5. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/admin.py +34 -8
  6. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/api.py +38 -2
  7. openedx-learning-0.3.3/openedx_learning/core/publishing/migrations/0002_alter_fk_on_delete.py +30 -0
  8. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/models.py +5 -3
  9. openedx-learning-0.3.3/openedx_learning/lib/admin_utils.py +100 -0
  10. {openedx-learning-0.2.6 → openedx-learning-0.3.3/openedx_learning.egg-info}/PKG-INFO +2 -2
  11. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning.egg-info/SOURCES.txt +5 -0
  12. openedx-learning-0.3.3/openedx_tagging/core/tagging/admin.py +44 -0
  13. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/api.py +54 -92
  14. openedx-learning-0.3.3/openedx_tagging/core/tagging/data.py +39 -0
  15. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/api.py +8 -8
  16. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/parsers.py +8 -7
  17. openedx-learning-0.3.3/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +19 -0
  18. openedx-learning-0.3.3/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +36 -0
  19. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/models/base.py +259 -70
  20. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/models/system_defined.py +0 -47
  21. openedx-learning-0.3.3/openedx_tagging/core/tagging/models/utils.py +24 -0
  22. openedx-learning-0.3.3/openedx_tagging/core/tagging/rest_api/v1/serializers.py +247 -0
  23. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/urls.py +1 -0
  24. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/views.py +205 -189
  25. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rules.py +0 -1
  26. openedx-learning-0.2.6/openedx_learning/lib/admin_utils.py +0 -28
  27. openedx-learning-0.2.6/openedx_tagging/core/tagging/admin.py +0 -10
  28. openedx-learning-0.2.6/openedx_tagging/core/tagging/rest_api/v1/serializers.py +0 -225
  29. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/CHANGELOG.rst +0 -0
  30. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/LICENSE.txt +0 -0
  31. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/MANIFEST.in +0 -0
  32. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/contrib/__init__.py +0 -0
  33. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/contrib/media_server/__init__.py +0 -0
  34. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/contrib/media_server/apps.py +0 -0
  35. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/contrib/media_server/urls.py +0 -0
  36. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/contrib/media_server/views.py +0 -0
  37. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/__init__.py +0 -0
  38. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/__init__.py +0 -0
  39. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/admin.py +0 -0
  40. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/api.py +0 -0
  41. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/apps.py +0 -0
  42. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/migrations/0001_initial.py +0 -0
  43. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/components/migrations/__init__.py +0 -0
  44. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/__init__.py +0 -0
  45. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/admin.py +0 -0
  46. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/api.py +0 -0
  47. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/apps.py +0 -0
  48. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/migrations/0001_initial.py +0 -0
  49. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/migrations/__init__.py +0 -0
  50. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/contents/models.py +0 -0
  51. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/__init__.py +0 -0
  52. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/apps.py +0 -0
  53. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/migrations/0001_initial.py +0 -0
  54. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/migrations/__init__.py +0 -0
  55. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/core/publishing/model_mixins.py +0 -0
  56. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/lib/__init__.py +0 -0
  57. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/lib/collations.py +0 -0
  58. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/lib/fields.py +0 -0
  59. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/lib/validators.py +0 -0
  60. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/__init__.py +0 -0
  61. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/apps.py +0 -0
  62. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/urls.py +0 -0
  63. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/v1/__init__.py +0 -0
  64. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/v1/components.py +0 -0
  65. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning/rest_api/v1/urls.py +0 -0
  66. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning.egg-info/dependency_links.txt +0 -0
  67. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning.egg-info/not-zip-safe +0 -0
  68. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning.egg-info/requires.txt +2 -2
  69. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_learning.egg-info/top_level.txt +0 -0
  70. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/__init__.py +0 -0
  71. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/__init__.py +0 -0
  72. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/__init__.py +0 -0
  73. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/apps.py +0 -0
  74. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  75. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/actions.py +0 -0
  76. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  77. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/import_plan.py +0 -0
  78. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  79. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  80. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  81. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  82. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  83. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  84. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  85. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  86. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  87. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  88. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  89. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  90. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  91. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  92. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  93. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  94. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  95. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  96. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  97. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  98. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  99. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  100. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  101. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  102. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  103. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/utils.py +0 -0
  104. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  105. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/openedx_tagging/core/tagging/urls.py +0 -0
  106. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/requirements/base.in +0 -0
  107. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/setup.cfg +0 -0
  108. {openedx-learning-0.2.6 → openedx-learning-0.3.3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.2.6
3
+ Version: 0.3.3
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -136,7 +136,7 @@ Description: openedx-learning
136
136
  Reporting Security Issues
137
137
  -------------------------
138
138
 
139
- Please do not report security issues in public. Please email security@edx.org.
139
+ Please do not report security issues in public. Please email security@openedx.org.
140
140
 
141
141
  Help
142
142
  ----
@@ -128,7 +128,7 @@ This repo is in a very experimental state. Discussion using GitHub Issues is wel
128
128
  Reporting Security Issues
129
129
  -------------------------
130
130
 
131
- Please do not report security issues in public. Please email security@edx.org.
131
+ Please do not report security issues in public. Please email security@openedx.org.
132
132
 
133
133
  Help
134
134
  ----
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.2.6"
4
+ __version__ = "0.3.3"
@@ -157,7 +157,7 @@ class ComponentVersion(PublishableEntityVersionMixin):
157
157
 
158
158
  # The raw_contents hold the actual interesting data associated with this
159
159
  # ComponentVersion.
160
- raw_contents = models.ManyToManyField(
160
+ raw_contents: models.ManyToManyField[RawContent, ComponentVersionRawContent] = models.ManyToManyField(
161
161
  RawContent,
162
162
  through="ComponentVersionRawContent",
163
163
  related_name="component_versions",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  from django.contrib import admin
7
7
 
8
- from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
8
+ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html
9
9
 
10
10
  from .models import LearningPackage, PublishableEntity, Published, PublishLog, PublishLogRecord
11
11
 
@@ -77,7 +77,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
77
77
  """
78
78
  Read-only admin view for Publishable Entities
79
79
  """
80
- fields = (
80
+ list_display = [
81
81
  "key",
82
82
  "draft_version",
83
83
  "published_version",
@@ -85,23 +85,49 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
85
85
  "learning_package",
86
86
  "created",
87
87
  "created_by",
88
- )
89
- readonly_fields = fields
90
- list_display = fields
88
+ ]
91
89
  list_filter = ["learning_package"]
92
90
  search_fields = ["key", "uuid"]
93
91
 
92
+ fields = [
93
+ "key",
94
+ "draft_version",
95
+ "published_version",
96
+ "uuid",
97
+ "learning_package",
98
+ "created",
99
+ "created_by",
100
+ "see_also",
101
+ ]
102
+ readonly_fields = [
103
+ "key",
104
+ "draft_version",
105
+ "published_version",
106
+ "uuid",
107
+ "learning_package",
108
+ "created",
109
+ "created_by",
110
+ "see_also",
111
+ ]
112
+
94
113
  def get_queryset(self, request):
95
114
  queryset = super().get_queryset(request)
96
115
  return queryset.select_related(
97
- "learning_package", "published__version", "draft__version"
116
+ "learning_package", "published__version",
98
117
  )
99
118
 
119
+ def see_also(self, entity):
120
+ return one_to_one_related_model_html(entity)
121
+
100
122
  def draft_version(self, entity):
101
- return entity.draft.version.version_num
123
+ if entity.draft.version:
124
+ return entity.draft.version.version_num
125
+ return None
102
126
 
103
127
  def published_version(self, entity):
104
- return entity.published.version.version_num
128
+ if entity.published.version:
129
+ return entity.published.version.version_num
130
+ return None
105
131
 
106
132
 
107
133
  @admin.register(Published)
@@ -93,9 +93,9 @@ def create_publishable_entity_version(
93
93
  created=created,
94
94
  created_by_id=created_by,
95
95
  )
96
- Draft.objects.create(
96
+ Draft.objects.update_or_create(
97
97
  entity_id=entity_id,
98
- version=version,
98
+ defaults={"version": version},
99
99
  )
100
100
  return version
101
101
 
@@ -180,6 +180,42 @@ def publish_from_drafts(
180
180
  return publish_log
181
181
 
182
182
 
183
+ def get_draft_version(publishable_entity_id: int) -> PublishableEntityVersion | None:
184
+ """
185
+ Return current draft PublishableEntityVersion for this PublishableEntity.
186
+
187
+ This function will return None if there is no current draft.
188
+ """
189
+ try:
190
+ draft = Draft.objects.select_related("version").get(
191
+ entity_id=publishable_entity_id
192
+ )
193
+ except Draft.DoesNotExist:
194
+ # No draft was ever created.
195
+ return None
196
+
197
+ # draft.version could be None if it was set that way by set_draft_version.
198
+ # Setting the Draft.version to None is how we show that we've "deleted" the
199
+ # content in Studio.
200
+ return draft.version
201
+
202
+
203
+ def set_draft_version(publishable_entity_id: int, publishable_entity_version_pk: int | None) -> None:
204
+ """
205
+ Modify the Draft of a PublishableEntity to be a PublishableEntityVersion.
206
+
207
+ This would most commonly be used to set the Draft to point to a newly
208
+ created PublishableEntityVersion that was created in Studio (because someone
209
+ edited some content). Setting a Draft's version to None is like deleting it
210
+ from Studio's editing point of view. We don't actually delete the Draft row
211
+ because we'll need that for publishing purposes (i.e. to delete content from
212
+ the published branch).
213
+ """
214
+ draft = Draft.objects.get(entity_id=publishable_entity_id)
215
+ draft.version_id = publishable_entity_version_pk
216
+ draft.save()
217
+
218
+
183
219
  def register_content_models(
184
220
  content_model_cls: type[PublishableEntityMixin],
185
221
  content_version_model_cls: type[PublishableEntityVersionMixin],
@@ -0,0 +1,30 @@
1
+ # Generated by Django 3.2.21 on 2023-10-13 14:25
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ """
9
+ Make it so that Draft and Published cascade deletes from PublishableEntity.
10
+
11
+ This makes it so that deleting a LearningPackage properly cleans up these
12
+ models as well.
13
+ """
14
+
15
+ dependencies = [
16
+ ('oel_publishing', '0001_initial'),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.AlterField(
21
+ model_name='draft',
22
+ name='entity',
23
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity'),
24
+ ),
25
+ migrations.AlterField(
26
+ model_name='published',
27
+ name='entity',
28
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity'),
29
+ ),
30
+ ]
@@ -308,10 +308,12 @@ class Draft(models.Model):
308
308
  are updated, not when publishing happens. The Published model only changes
309
309
  when something is published.
310
310
  """
311
-
311
+ # If we're removing a PublishableEntity entirely, also remove the Draft
312
+ # entry for it. This isn't a normal operation, but can happen if you're
313
+ # deleting an entire LearningPackage.
312
314
  entity = models.OneToOneField(
313
315
  PublishableEntity,
314
- on_delete=models.RESTRICT,
316
+ on_delete=models.CASCADE,
315
317
  primary_key=True,
316
318
  )
317
319
  version = models.OneToOneField(
@@ -425,7 +427,7 @@ class Published(models.Model):
425
427
 
426
428
  entity = models.OneToOneField(
427
429
  PublishableEntity,
428
- on_delete=models.RESTRICT,
430
+ on_delete=models.CASCADE,
429
431
  primary_key=True,
430
432
  )
431
433
  version = models.OneToOneField(
@@ -0,0 +1,100 @@
1
+ """
2
+ Convenience utilities for the Django Admin.
3
+ """
4
+ from django.contrib import admin
5
+ from django.db.models.fields.reverse_related import OneToOneRel
6
+ from django.urls import NoReverseMatch, reverse
7
+ from django.utils.html import format_html, format_html_join
8
+
9
+
10
+ class ReadOnlyModelAdmin(admin.ModelAdmin):
11
+ """
12
+ ModelAdmin subclass that removes any editing ability.
13
+
14
+ The Django Admin is really useful for quickly examining model data. At the
15
+ same time, model creation and updates follow specific rules that are meant
16
+ to be enforced above the model layer (in api.py files), so making edits in
17
+ the Django Admin is potentially dangerous.
18
+
19
+ In general, if you're providing Django Admin interfaces for your
20
+ openedx-learning related app data models, you should subclass this class
21
+ instead of subclassing admin.ModelAdmin directly.
22
+ """
23
+
24
+ def has_add_permission(self, request):
25
+ return False
26
+
27
+ def has_change_permission(self, request, obj=None):
28
+ return False
29
+
30
+ def has_delete_permission(self, request, obj=None):
31
+ return False
32
+
33
+
34
+ def one_to_one_related_model_html(model_obj):
35
+ """
36
+ HTML for clickable list of a models that are 1:1-related to ``model_obj``.
37
+
38
+ Our design pattern encourages people to hang models off of our lower-level
39
+ core lib models. For example, Component has a OneToOneField that references
40
+ PublishableEntity. It would be really convenient to have PublishableEntity's
41
+ admin page display the link to Component, but the ``publishable`` app is
42
+ intended to be a lower-level app than ``components`` and isn't supposed to
43
+ be aware of it. The same situation occurs for third-party apps that might
44
+ want to extend Component.
45
+
46
+ So instead of creating a circular dependency by having ``publishing``
47
+ referencing ``components``, we use Django model introspection to iterate
48
+ over all models that have a OneToOneField to the passe din``model_obj``.
49
+ This allows us to preserve our dependency boundaries within openedx-learning
50
+ and accomodate any third party apps that might further extend these models.
51
+
52
+ This will output a list with one entry for each related field.
53
+
54
+ * If the field's value is None, we output f"{field_name}: -"
55
+ * If the field has a value but no "change" admin page, we output the string
56
+ representation of the model obj referenced by that field, i.e.
57
+ f{"field_name: {related_model_obj}"}.
58
+ * If the field has a value and an admin page, we output the same as above,
59
+ but we make the related model object's string representation a link to its
60
+ "change" admin page.
61
+ """
62
+ one_to_one_field_names = [
63
+ field.name
64
+ for field in model_obj._meta.related_objects
65
+ if isinstance(field, OneToOneRel)
66
+ ]
67
+ text = []
68
+ for field_name in one_to_one_field_names:
69
+ related_model_obj = getattr(model_obj, field_name, None)
70
+
71
+ # No instance of the related model was found, so just use "-"
72
+ if related_model_obj is None:
73
+ text.append(f"{field_name}: -")
74
+ continue
75
+
76
+ app_label = related_model_obj._meta.app_label
77
+ model_name = related_model_obj._meta.model_name
78
+ try:
79
+ details_url = reverse(
80
+ f"admin:{app_label}_{model_name}_change",
81
+ args=(related_model_obj.pk,)
82
+ )
83
+ except NoReverseMatch:
84
+ # No Admin URL available, so just put the str representation of the
85
+ # related model instance.
86
+ text.append(f"{field_name}: {related_model_obj}")
87
+ continue
88
+
89
+ # If we go this far, there is a related model instance and it has a
90
+ # "change" admin page (even though it's probably read-only via
91
+ # permissions).
92
+ html = format_html(
93
+ '{}: <a href="{}">{}</a>',
94
+ field_name,
95
+ details_url,
96
+ related_model_obj,
97
+ )
98
+ text.append(html)
99
+
100
+ return format_html_join("\n", "<li>{}</li>", ((t,) for t in text))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.2.6
3
+ Version: 0.3.3
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -136,7 +136,7 @@ Description: openedx-learning
136
136
  Reporting Security Issues
137
137
  -------------------------
138
138
 
139
- Please do not report security issues in public. Please email security@edx.org.
139
+ Please do not report security issues in public. Please email security@openedx.org.
140
140
 
141
141
  Help
142
142
  ----
@@ -38,6 +38,7 @@ openedx_learning/core/publishing/apps.py
38
38
  openedx_learning/core/publishing/model_mixins.py
39
39
  openedx_learning/core/publishing/models.py
40
40
  openedx_learning/core/publishing/migrations/0001_initial.py
41
+ openedx_learning/core/publishing/migrations/0002_alter_fk_on_delete.py
41
42
  openedx_learning/core/publishing/migrations/__init__.py
42
43
  openedx_learning/lib/__init__.py
43
44
  openedx_learning/lib/admin_utils.py
@@ -56,6 +57,7 @@ openedx_tagging/core/tagging/__init__.py
56
57
  openedx_tagging/core/tagging/admin.py
57
58
  openedx_tagging/core/tagging/api.py
58
59
  openedx_tagging/core/tagging/apps.py
60
+ openedx_tagging/core/tagging/data.py
59
61
  openedx_tagging/core/tagging/rules.py
60
62
  openedx_tagging/core/tagging/urls.py
61
63
  openedx_tagging/core/tagging/import_export/__init__.py
@@ -81,11 +83,14 @@ openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py
81
83
  openedx_tagging/core/tagging/migrations/0010_cleanups.py
82
84
  openedx_tagging/core/tagging/migrations/0011_remove_required.py
83
85
  openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py
86
+ openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py
87
+ openedx_tagging/core/tagging/migrations/0014_minor_fixes.py
84
88
  openedx_tagging/core/tagging/migrations/__init__.py
85
89
  openedx_tagging/core/tagging/models/__init__.py
86
90
  openedx_tagging/core/tagging/models/base.py
87
91
  openedx_tagging/core/tagging/models/import_export.py
88
92
  openedx_tagging/core/tagging/models/system_defined.py
93
+ openedx_tagging/core/tagging/models/utils.py
89
94
  openedx_tagging/core/tagging/rest_api/__init__.py
90
95
  openedx_tagging/core/tagging/rest_api/paginators.py
91
96
  openedx_tagging/core/tagging/rest_api/urls.py
@@ -0,0 +1,44 @@
1
+ """
2
+ Tagging app admin
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from django.contrib import admin
7
+
8
+ from .models import ObjectTag, Tag, Taxonomy
9
+
10
+ admin.site.register(Taxonomy)
11
+
12
+
13
+ @admin.register(Tag)
14
+ class TagAdmin(admin.ModelAdmin):
15
+ """
16
+ Admin definition for Tag model
17
+ """
18
+ autocomplete_fields = ["parent"]
19
+ search_fields = ["value", "external_id"]
20
+ list_display = ["__str__", "taxonomy", "external_id"]
21
+ list_filter = ["taxonomy"]
22
+
23
+ def has_add_permission(self, request):
24
+ """
25
+ Don't create Tags using the django admin. Use the API or UI.
26
+ """
27
+ return False
28
+
29
+
30
+ @admin.register(ObjectTag)
31
+ class ObjectTagAdmin(admin.ModelAdmin):
32
+ """
33
+ Admin definition for ObjectTag model
34
+ """
35
+ fields = ["object_id", "taxonomy", "tag", "_value"]
36
+ autocomplete_fields = ["tag"]
37
+ list_display = ["object_id", "name", "value"]
38
+ readonly_fields = ["object_id"]
39
+
40
+ def has_add_permission(self, request):
41
+ """
42
+ Don't create ObjectTags using the django admin. Use the API or UI.
43
+ """
44
+ return False
@@ -12,11 +12,14 @@ are stored in this app.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- from django.db import transaction
16
- from django.db.models import F, QuerySet
15
+ from django.db import models, transaction
16
+ from django.db.models import F, QuerySet, Value
17
+ from django.db.models.functions import Coalesce, Concat, Lower
17
18
  from django.utils.translation import gettext as _
18
19
 
20
+ from .data import TagDataQuerySet
19
21
  from .models import ObjectTag, Tag, Taxonomy
22
+ from .models.utils import ConcatNull
20
23
 
21
24
  # Export this as part of the API
22
25
  TagDoesNotExist = Tag.DoesNotExist
@@ -70,54 +73,66 @@ def get_taxonomies(enabled=True) -> QuerySet[Taxonomy]:
70
73
  return queryset.filter(enabled=enabled)
71
74
 
72
75
 
73
- def get_tags(taxonomy: Taxonomy) -> list[Tag]:
76
+ def get_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
74
77
  """
75
- Returns a list of predefined tags for the given taxonomy.
78
+ Returns a QuerySet of all the tags in the given taxonomy.
76
79
 
77
- Note that if the taxonomy allows free-text tags, then the returned list will be empty.
80
+ Note that if the taxonomy is dynamic or free-text, only tags that have
81
+ already been applied to some object will be returned.
78
82
  """
79
- return taxonomy.cast().get_tags()
83
+ return taxonomy.cast().get_filtered_tags()
80
84
 
81
85
 
82
- def get_root_tags(taxonomy: Taxonomy) -> list[Tag]:
86
+ def get_root_tags(taxonomy: Taxonomy) -> TagDataQuerySet:
83
87
  """
84
88
  Returns a list of the root tags for the given taxonomy.
85
89
 
86
90
  Note that if the taxonomy allows free-text tags, then the returned list will be empty.
87
91
  """
88
- return list(taxonomy.cast().get_filtered_tags())
92
+ return taxonomy.cast().get_filtered_tags(depth=1)
89
93
 
90
94
 
91
- def search_tags(taxonomy: Taxonomy, search_term: str) -> list[Tag]:
95
+ def search_tags(
96
+ taxonomy: Taxonomy,
97
+ search_term: str,
98
+ exclude_object_id: str | None = None,
99
+ include_counts: bool = False,
100
+ ) -> TagDataQuerySet:
92
101
  """
93
- Returns a list of all tags that contains `search_term` of the given taxonomy.
102
+ Returns a list of all tags that contains `search_term` of the given
103
+ taxonomy, as well as their ancestors (so they can be displayed in a tree).
94
104
 
95
- Note that if the taxonomy allows free-text tags, then the returned list will be empty.
105
+ If exclude_object_id is set, any tags applied to that object will be
106
+ excluded from the results, e.g. to power an autocomplete search when adding
107
+ additional tags to an object.
96
108
  """
97
- return list(
98
- taxonomy.cast().get_filtered_tags(
99
- search_term=search_term,
100
- search_in_all=True,
109
+ excluded_values = None
110
+ if exclude_object_id:
111
+ # Fetch tags that the object already has to exclude them from the result.
112
+ # Note: this adds a fair bit of complexity. In the future, maybe we can just do this filtering on the frontend?
113
+ excluded_values = list(
114
+ taxonomy.objecttag_set.filter(object_id=exclude_object_id).values_list(
115
+ "_value", flat=True
116
+ )
101
117
  )
118
+ qs = taxonomy.cast().get_filtered_tags(
119
+ search_term=search_term,
120
+ excluded_values=excluded_values,
121
+ include_counts=include_counts,
102
122
  )
123
+ return qs
103
124
 
104
125
 
105
126
  def get_children_tags(
106
127
  taxonomy: Taxonomy,
107
- parent_tag_id: int,
108
- search_term: str | None = None,
109
- ) -> list[Tag]:
128
+ parent_tag_value: str,
129
+ ) -> TagDataQuerySet:
110
130
  """
111
- Returns a list of children tags for the given parent tag.
131
+ Returns a QuerySet of children tags for the given parent tag.
112
132
 
113
133
  Note that if the taxonomy allows free-text tags, then the returned list will be empty.
114
134
  """
115
- return list(
116
- taxonomy.cast().get_filtered_tags(
117
- parent_tag_id=parent_tag_id,
118
- search_term=search_term,
119
- )
120
- )
135
+ return taxonomy.cast().get_filtered_tags(parent_tag_value=parent_tag_value, depth=1)
121
136
 
122
137
 
123
138
  def resync_object_tags(object_tags: QuerySet | None = None) -> int:
@@ -152,8 +167,20 @@ def get_object_tags(
152
167
  filters = {"taxonomy_id": taxonomy_id} if taxonomy_id else {}
153
168
  tags = (
154
169
  object_tag_class.objects.filter(object_id=object_id, **filters)
155
- .select_related("tag", "taxonomy")
156
- .order_by("id")
170
+ # Preload related objects, including data for the "get_lineage" method on ObjectTag/Tag:
171
+ .select_related("taxonomy", "tag", "tag__parent", "tag__parent__parent")
172
+ # Sort the tags within each taxonomy in "tree order". See Taxonomy._get_filtered_tags_deep for details on this:
173
+ .annotate(sort_key=Lower(Concat(
174
+ ConcatNull(F("tag__parent__parent__parent__value"), Value("\t")),
175
+ ConcatNull(F("tag__parent__parent__value"), Value("\t")),
176
+ ConcatNull(F("tag__parent__value"), Value("\t")),
177
+ Coalesce(F("tag__value"), F("_value")),
178
+ Value("\t"),
179
+ output_field=models.CharField(),
180
+ )))
181
+ .annotate(taxonomy_name=Coalesce(F("taxonomy__name"), F("_name")))
182
+ # Sort first by taxonomy name, then by tag value in tree order:
183
+ .order_by("taxonomy_name", "sort_key")
157
184
  )
158
185
  return tags
159
186
 
@@ -253,71 +280,6 @@ def tag_object(
253
280
  object_tag.save()
254
281
 
255
282
 
256
- # TODO: return tags from closed taxonomies as well as the count of how many times each is used.
257
- def autocomplete_tags(
258
- taxonomy: Taxonomy,
259
- search: str,
260
- object_id: str | None = None,
261
- object_tags_only=True,
262
- ) -> QuerySet:
263
- """
264
- Provides auto-complete suggestions by matching the `search` string against existing
265
- ObjectTags linked to the given taxonomy. A case-insensitive search is used in order
266
- to return the highest number of relevant tags.
267
-
268
- If `object_id` is provided, then object tag values already linked to this object
269
- are omitted from the returned suggestions. (ObjectTag values must be unique for a
270
- given object + taxonomy, and so omitting these suggestions helps users avoid
271
- duplication errors.).
272
-
273
- Returns a QuerySet of dictionaries containing distinct `value` (string) and
274
- `tag` (numeric ID) values, sorted alphabetically by `value`.
275
- The `value` is what should be shown as a suggestion to users,
276
- and if it's a free-text taxonomy, `tag` will be `None`: we include the `tag` ID
277
- in anticipation of the second use case listed below.
278
-
279
- Use cases:
280
- * This method is useful for reducing tag variation in free-text taxonomies by showing
281
- users tags that are similar to what they're typing. E.g., if the `search` string "dn"
282
- shows that other objects have been tagged with "DNA", "DNA electrophoresis", and "DNA fingerprinting",
283
- this encourages users to use those existing tags if relevant, instead of creating new ones that
284
- look similar (e.g. "dna finger-printing").
285
- * It could also be used to assist tagging for closed taxonomies with a list of possible tags which is too
286
- large to return all at once, e.g. a user model taxonomy that dynamically creates tags on request for any
287
- registered user in the database. (Note that this is not implemented yet, but may be as part of a future change.)
288
- """
289
- if not object_tags_only:
290
- raise NotImplementedError(
291
- _(
292
- "Using this would return a query set of tags instead of object tags."
293
- "For now we recommend fetching all of the taxonomy's tags "
294
- "using get_tags() and filtering them on the frontend."
295
- )
296
- )
297
- # Fetch tags that the object already has to exclude them from the result
298
- excluded_tags: list[str] = []
299
- if object_id:
300
- excluded_tags = list(
301
- taxonomy.objecttag_set.filter(object_id=object_id).values_list(
302
- "_value", flat=True
303
- )
304
- )
305
- return (
306
- # Fetch object tags from this taxonomy whose value contains the search
307
- taxonomy.objecttag_set.filter(_value__icontains=search)
308
- # omit any tags whose values match the tags on the given object
309
- .exclude(_value__in=excluded_tags)
310
- # alphabetical ordering
311
- .order_by("_value")
312
- # Alias the `_value` field to `value` to make it nicer for users
313
- .annotate(value=F("_value"))
314
- # obtain tag values
315
- .values("value", "tag_id")
316
- # remove repeats
317
- .distinct()
318
- )
319
-
320
-
321
283
  def add_tag_to_taxonomy(
322
284
  taxonomy: Taxonomy,
323
285
  tag: str,
@@ -0,0 +1,39 @@
1
+ """
2
+ Data models used by openedx-tagging
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, Any, TypedDict
7
+
8
+ from django.db.models import QuerySet
9
+ from typing_extensions import NotRequired, TypeAlias
10
+
11
+
12
+ class TagData(TypedDict):
13
+ """
14
+ Data about a single tag. Many of the tagging API methods return Django
15
+ QuerySets that resolve to these dictionaries.
16
+
17
+ Even though the data will be in this same format, it will not necessarily
18
+ be an instance of this class but rather a plain dictionary. This is more a
19
+ type than a class.
20
+ """
21
+ value: str
22
+ external_id: str | None
23
+ child_count: int
24
+ depth: int
25
+ parent_value: str | None
26
+ # Note: usage_count may or may not be present, depending on the request.
27
+ usage_count: NotRequired[int]
28
+ # Internal database ID, if any. Generally should not be used; prefer 'value' which is unique within each taxonomy.
29
+ _id: int | None
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from django_stubs_ext import ValuesQuerySet
34
+ TagDataQuerySet: TypeAlias = ValuesQuerySet[Any, TagData]
35
+ # The following works better for pyright (provides proper VS Code autocompletions),
36
+ # but I can't find any way to specify different types for pyright vs mypy :/
37
+ # TagDataQuerySet: TypeAlias = QuerySet[TagData]
38
+ else:
39
+ TagDataQuerySet = QuerySet[TagData]