openedx-learning 0.3.6__tar.gz → 0.4.0__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 (107) hide show
  1. {openedx-learning-0.3.6/openedx_learning.egg-info → openedx-learning-0.4.0}/PKG-INFO +1 -1
  2. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/__init__.py +1 -1
  3. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/admin.py +1 -1
  4. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/api.py +1 -0
  5. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/migrations/0001_initial.py +2 -2
  6. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/admin.py +4 -4
  7. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/api.py +37 -2
  8. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/migrations/0001_initial.py +18 -6
  9. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/models.py +88 -14
  10. openedx-learning-0.4.0/openedx_learning/lib/cache.py +34 -0
  11. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/lib/fields.py +10 -0
  12. openedx-learning-0.4.0/openedx_learning/lib/test_utils.py +22 -0
  13. {openedx-learning-0.3.6 → openedx-learning-0.4.0/openedx_learning.egg-info}/PKG-INFO +1 -1
  14. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning.egg-info/SOURCES.txt +2 -0
  15. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/actions.py +6 -4
  16. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/api.py +1 -1
  17. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/import_plan.py +10 -8
  18. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/serializers.py +6 -0
  19. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/CHANGELOG.rst +0 -0
  20. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/LICENSE.txt +0 -0
  21. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/MANIFEST.in +0 -0
  22. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/README.rst +0 -0
  23. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/contrib/__init__.py +0 -0
  24. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/contrib/media_server/__init__.py +0 -0
  25. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/contrib/media_server/apps.py +0 -0
  26. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/contrib/media_server/urls.py +0 -0
  27. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/contrib/media_server/views.py +0 -0
  28. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/__init__.py +0 -0
  29. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/__init__.py +0 -0
  30. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/apps.py +0 -0
  31. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/migrations/__init__.py +0 -0
  32. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/components/models.py +0 -0
  33. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/__init__.py +0 -0
  34. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/apps.py +0 -0
  35. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/contents/migrations/__init__.py +0 -0
  36. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/__init__.py +0 -0
  37. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/admin.py +0 -0
  38. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/api.py +0 -0
  39. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/apps.py +0 -0
  40. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/migrations/0001_initial.py +0 -0
  41. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/migrations/0002_alter_fk_on_delete.py +0 -0
  42. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/migrations/__init__.py +0 -0
  43. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/model_mixins.py +0 -0
  44. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/core/publishing/models.py +0 -0
  45. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/lib/__init__.py +0 -0
  46. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/lib/admin_utils.py +0 -0
  47. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/lib/collations.py +0 -0
  48. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/lib/validators.py +0 -0
  49. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/__init__.py +0 -0
  50. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/apps.py +0 -0
  51. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/urls.py +0 -0
  52. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/v1/__init__.py +0 -0
  53. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/v1/components.py +0 -0
  54. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning/rest_api/v1/urls.py +0 -0
  55. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning.egg-info/dependency_links.txt +0 -0
  56. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning.egg-info/not-zip-safe +0 -0
  57. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning.egg-info/requires.txt +4 -4
  58. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_learning.egg-info/top_level.txt +0 -0
  59. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/__init__.py +0 -0
  60. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/__init__.py +0 -0
  61. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/__init__.py +0 -0
  62. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/admin.py +0 -0
  63. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/api.py +0 -0
  64. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/apps.py +0 -0
  65. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/data.py +0 -0
  66. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/__init__.py +0 -0
  67. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/exceptions.py +0 -0
  68. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/parsers.py +0 -0
  69. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/tasks.py +0 -0
  70. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/template.csv +0 -0
  71. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/import_export/template.json +0 -0
  72. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0001_initial.py +0 -0
  73. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0001_squashed.py +0 -0
  74. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0002_auto_20230718_2026.py +0 -0
  75. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0003_auto_20230721_1238.py +0 -0
  76. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py +0 -0
  77. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py +0 -0
  78. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0006_alter_objecttag_unique_together.py +0 -0
  79. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py +0 -0
  80. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py +0 -0
  81. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py +0 -0
  82. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0009_alter_objecttag_object_id.py +0 -0
  83. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0010_cleanups.py +0 -0
  84. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0011_remove_required.py +0 -0
  85. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0012_language_taxonomy.py +0 -0
  86. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0013_tag_parent_blank.py +0 -0
  87. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/0014_minor_fixes.py +0 -0
  88. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/migrations/__init__.py +0 -0
  89. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/models/__init__.py +0 -0
  90. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/models/base.py +0 -0
  91. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/models/import_export.py +0 -0
  92. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/models/system_defined.py +0 -0
  93. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/models/utils.py +0 -0
  94. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/__init__.py +0 -0
  95. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/paginators.py +0 -0
  96. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/urls.py +0 -0
  97. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/__init__.py +0 -0
  98. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/permissions.py +0 -0
  99. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/urls.py +0 -0
  100. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/utils.py +0 -0
  101. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/views.py +0 -0
  102. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rest_api/v1/views_import.py +0 -0
  103. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/rules.py +0 -0
  104. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/openedx_tagging/core/tagging/urls.py +0 -0
  105. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/requirements/base.in +0 -0
  106. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/setup.cfg +0 -0
  107. {openedx-learning-0.3.6 → openedx-learning-0.4.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.3.6
3
+ Version: 0.4.0
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.3.6"
4
+ __version__ = "0.4.0"
@@ -158,7 +158,7 @@ def content_preview(cvc_obj: ComponentVersionRawContent) -> SafeText:
158
158
  """
159
159
  raw_content_obj = cvc_obj.raw_content
160
160
 
161
- if raw_content_obj.mime_type.startswith("image/"):
161
+ if raw_content_obj.media_type.type == "image":
162
162
  return format_html(
163
163
  '<img src="{}" style="max-width: 100%;" />',
164
164
  # TODO: configure with settings value:
@@ -117,6 +117,7 @@ def get_component_version_content(
117
117
  """
118
118
  return ComponentVersionRawContent.objects.select_related(
119
119
  "raw_content",
120
+ "raw_content__media_type",
120
121
  "component_version",
121
122
  "component_version__component",
122
123
  "component_version__component__learning_package",
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.19 on 2023-06-15 14:43
1
+ # Generated by Django 3.2.23 on 2023-12-04 00:41
2
2
 
3
3
  import uuid
4
4
 
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
13
13
  initial = True
14
14
 
15
15
  dependencies = [
16
- ('oel_publishing', '0001_initial'),
16
+ ('oel_publishing', '0002_alter_fk_on_delete'),
17
17
  ('oel_contents', '0001_initial'),
18
18
  ]
19
19
 
@@ -18,14 +18,14 @@ class RawContentAdmin(ReadOnlyModelAdmin):
18
18
  "hash_digest",
19
19
  "file_link",
20
20
  "learning_package",
21
- "mime_type",
21
+ "media_type",
22
22
  "size",
23
23
  "created",
24
24
  ]
25
25
  fields = [
26
26
  "learning_package",
27
27
  "hash_digest",
28
- "mime_type",
28
+ "media_type",
29
29
  "size",
30
30
  "created",
31
31
  "file_link",
@@ -34,13 +34,13 @@ class RawContentAdmin(ReadOnlyModelAdmin):
34
34
  readonly_fields = [
35
35
  "learning_package",
36
36
  "hash_digest",
37
- "mime_type",
37
+ "media_type",
38
38
  "size",
39
39
  "created",
40
40
  "file_link",
41
41
  "text_preview",
42
42
  ]
43
- list_filter = ("mime_type", "learning_package")
43
+ list_filter = ("media_type", "learning_package")
44
44
  search_fields = ("hash_digest",)
45
45
 
46
46
  def file_link(self, raw_content):
@@ -12,9 +12,10 @@ from datetime import datetime
12
12
  from django.core.files.base import ContentFile
13
13
  from django.db.transaction import atomic
14
14
 
15
+ from openedx_learning.lib.cache import lru_cache
15
16
  from openedx_learning.lib.fields import create_hash_digest
16
17
 
17
- from .models import RawContent, TextContent
18
+ from .models import MediaType, RawContent, TextContent
18
19
 
19
20
 
20
21
  def create_raw_content(
@@ -28,9 +29,10 @@ def create_raw_content(
28
29
  Create a new RawContent instance and persist it to storage.
29
30
  """
30
31
  hash_digest = hash_digest or create_hash_digest(data_bytes)
32
+
31
33
  raw_content = RawContent.objects.create(
32
34
  learning_package_id=learning_package_id,
33
- mime_type=mime_type,
35
+ media_type_id=get_media_type_id(mime_type),
34
36
  hash_digest=hash_digest,
35
37
  size=len(data_bytes),
36
38
  created=created,
@@ -54,6 +56,39 @@ def create_text_from_raw_content(raw_content: RawContent, encoding="utf-8-sig")
54
56
  )
55
57
 
56
58
 
59
+ @lru_cache(maxsize=128)
60
+ def get_media_type_id(mime_type: str) -> int:
61
+ """
62
+ Return the MediaType.id for the desired mime_type string.
63
+
64
+ If it is not found in the database, a new entry will be created for it. This
65
+ lazy-writing means that MediaType entry IDs will *not* be the same across
66
+ different server instances, and apps should not assume that will be the
67
+ case. Even if we were to preload a bunch of common ones, we can't anticipate
68
+ the different XBlocks that will be installed in different server instances,
69
+ each of which will use their own MediaType.
70
+
71
+ This will typically only be called when create_raw_content is calling it to
72
+ lookup the media_type_id it should use for a new RawContent. If you already
73
+ have a RawContent instance, it makes much more sense to access its
74
+ media_type relation.
75
+ """
76
+ if "+" in mime_type:
77
+ base, suffix = mime_type.split("+")
78
+ else:
79
+ base = mime_type
80
+ suffix = ""
81
+
82
+ main_type, sub_type = base.split("/")
83
+ mt, _created = MediaType.objects.get_or_create(
84
+ type=main_type,
85
+ sub_type=sub_type,
86
+ suffix=suffix,
87
+ )
88
+
89
+ return mt.id
90
+
91
+
57
92
  def get_or_create_raw_content(
58
93
  learning_package_id: int,
59
94
  data_bytes: bytes,
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.19 on 2023-06-15 14:43
1
+ # Generated by Django 3.2.23 on 2023-12-04 00:41
2
2
 
3
3
  import django.core.validators
4
4
  import django.db.models.deletion
@@ -13,20 +13,29 @@ class Migration(migrations.Migration):
13
13
  initial = True
14
14
 
15
15
  dependencies = [
16
- ('oel_publishing', '0001_initial'),
16
+ ('oel_publishing', '0002_alter_fk_on_delete'),
17
17
  ]
18
18
 
19
19
  operations = [
20
+ migrations.CreateModel(
21
+ name='MediaType',
22
+ fields=[
23
+ ('id', models.AutoField(primary_key=True, serialize=False)),
24
+ ('type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
25
+ ('sub_type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
26
+ ('suffix', openedx_learning.lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
27
+ ],
28
+ ),
20
29
  migrations.CreateModel(
21
30
  name='RawContent',
22
31
  fields=[
23
32
  ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
33
  ('hash_digest', models.CharField(editable=False, max_length=40)),
25
- ('mime_type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=255)),
26
34
  ('size', models.PositiveBigIntegerField(validators=[django.core.validators.MaxValueValidator(50000000)])),
27
35
  ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
28
36
  ('file', models.FileField(null=True, upload_to='')),
29
37
  ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')),
38
+ ('media_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='oel_contents.mediatype')),
30
39
  ],
31
40
  options={
32
41
  'verbose_name': 'Raw Content',
@@ -41,10 +50,13 @@ class Migration(migrations.Migration):
41
50
  ('length', models.PositiveIntegerField()),
42
51
  ],
43
52
  ),
44
- # Call out to custom code here to change row format for TextContent
53
+ migrations.AddConstraint(
54
+ model_name='mediatype',
55
+ constraint=models.UniqueConstraint(fields=('type', 'sub_type', 'suffix'), name='oel_contents_uniq_t_st_sfx'),
56
+ ),
45
57
  migrations.AddIndex(
46
58
  model_name='rawcontent',
47
- index=models.Index(fields=['learning_package', 'mime_type'], name='oel_content_idx_lp_mime_type'),
59
+ index=models.Index(fields=['learning_package', 'media_type'], name='oel_content_idx_lp_media_type'),
48
60
  ),
49
61
  migrations.AddIndex(
50
62
  model_name='rawcontent',
@@ -56,6 +68,6 @@ class Migration(migrations.Migration):
56
68
  ),
57
69
  migrations.AddConstraint(
58
70
  model_name='rawcontent',
59
- constraint=models.UniqueConstraint(fields=('learning_package', 'mime_type', 'hash_digest'), name='oel_content_uniq_lc_mime_type_hash_digest'),
71
+ constraint=models.UniqueConstraint(fields=('learning_package', 'media_type', 'hash_digest'), name='oel_content_uniq_lc_media_type_hash_digest'),
60
72
  ),
61
73
  ]
@@ -3,6 +3,8 @@ These models are the most basic pieces of content we support. Think of them as
3
3
  the simplest building blocks to store data with. They need to be composed into
4
4
  more intelligent data models to be useful.
5
5
  """
6
+ from functools import cached_property
7
+
6
8
  from django.conf import settings
7
9
  from django.core.files.storage import default_storage
8
10
  from django.core.validators import MaxValueValidator
@@ -18,6 +20,81 @@ from openedx_learning.lib.fields import (
18
20
  from ..publishing.models import LearningPackage
19
21
 
20
22
 
23
+ class MediaType(models.Model):
24
+ """
25
+ Stores Media types for use by RawContent models.
26
+
27
+ This is the same as MIME types (the IANA renamed MIME Types to Media Types).
28
+ We don't pre-populate this table, so APIs that add RawContent must ensure
29
+ that the desired Media Type exists.
30
+
31
+ Media types are written as {type}/{sub_type}+{suffix}, where suffixes are
32
+ seldom used.
33
+
34
+ * application/json
35
+ * text/css
36
+ * image/svg+xml
37
+ * application/vnd.openedx.xblock.v1.problem+xml
38
+
39
+ We have this as a separate model (instead of a field on RawContent) because:
40
+
41
+ 1. We can save a lot on storage and indexing for RawContent if we're just
42
+ storing foreign key references there, rather than the entire content
43
+ string to be indexed. This is especially relevant for our (long) custom
44
+ types like "application/vnd.openedx.xblock.v1.problem+xml".
45
+ 2. These values can occasionally change. For instance, "text/javascript" vs.
46
+ "application/javascript". Also, we will be using a fair number of "vnd."
47
+ style of custom content types, and we may want the flexibility of
48
+ changing that without having to worry about migrating millions of rows of
49
+ RawContent.
50
+ """
51
+ # We're going to have many foreign key references from RawContent into this
52
+ # model, and we don't need to store those as 8-byte BigAutoField, as is the
53
+ # default for this app. It's likely that a SmallAutoField would work, but I
54
+ # can just barely imagine using more than 32K Media types if we have a bunch
55
+ # of custom "vnd." entries, or start tracking suffixes and parameters. Which
56
+ # is how we end up at the 4-byte AutoField.
57
+ id = models.AutoField(primary_key=True)
58
+
59
+ # Media types are denoted as {type}/{sub_type}+{suffix}. We currently do not
60
+ # support parameters.
61
+
62
+ # Media type, e.g. "application", "text", "image". Per RFC 4288, this can be
63
+ # at most 127 chars long and is case insensitive. In practice, it's almost
64
+ # always written in lowercase.
65
+ type = case_insensitive_char_field(max_length=127, blank=False, null=False)
66
+
67
+ # Media sub-type, e.g. "json", "css", "png". Per RFC 4288, this can be at
68
+ # most 127 chars long and is case insensitive. In practice, it's almost
69
+ # always written in lowercase.
70
+ sub_type = case_insensitive_char_field(max_length=127, blank=False, null=False)
71
+
72
+ # Suffix, usually just "xml" (e.g. "image/svg+xml"). Usually blank. I
73
+ # couldn't find an RFC description of the length limit, and 127 is probably
74
+ # excessive. But this table should be small enough where it doesn't really
75
+ # matter.
76
+ suffix = case_insensitive_char_field(max_length=127, blank=True, null=False)
77
+
78
+ class Meta:
79
+ constraints = [
80
+ # Make sure all (type + sub_type + suffix) combinations are unique.
81
+ models.UniqueConstraint(
82
+ fields=[
83
+ "type",
84
+ "sub_type",
85
+ "suffix",
86
+ ],
87
+ name="oel_contents_uniq_t_st_sfx",
88
+ ),
89
+ ]
90
+
91
+ def __str__(self):
92
+ base = f"{self.type}/{self.sub_type}"
93
+ if self.suffix:
94
+ return f"{base}+{self.suffix}"
95
+ return base
96
+
97
+
21
98
  class RawContent(models.Model): # type: ignore[django-manager-missing]
22
99
  """
23
100
  This is the most basic piece of raw content data, with no version metadata.
@@ -77,15 +154,8 @@ class RawContent(models.Model): # type: ignore[django-manager-missing]
77
154
  # openedx.lib.fields module.
78
155
  hash_digest = hash_field()
79
156
 
80
- # MIME type, such as "text/html", "image/png", etc. Per RFC 4288, MIME type
81
- # and sub-type may each be 127 chars, making a max of 255 (including the "/"
82
- # in between). Also, while MIME types are almost always written in lowercase
83
- # as a matter of convention, by spec they are NOT case sensitive.
84
- #
85
- # DO NOT STORE parameters here, e.g. "charset=". We can make a new field if
86
- # that becomes necessary. If we do decide to store parameters and values
87
- # later, note that those *may be* case sensitive.
88
- mime_type = case_insensitive_char_field(max_length=255, blank=False)
157
+ # What is the Media type (a.k.a. MIME type) of this data?
158
+ media_type = models.ForeignKey(MediaType, on_delete=models.PROTECT)
89
159
 
90
160
  # This is the size of the raw data file in bytes. This can be different than
91
161
  # the character length, since UTF-8 encoding can use anywhere between 1-4
@@ -107,6 +177,10 @@ class RawContent(models.Model): # type: ignore[django-manager-missing]
107
177
  storage=settings.OPENEDX_LEARNING.get("STORAGE", default_storage), # type: ignore
108
178
  )
109
179
 
180
+ @cached_property
181
+ def mime_type(self):
182
+ return str(self.media_type)
183
+
110
184
  class Meta:
111
185
  constraints = [
112
186
  # Make sure we don't store duplicates of this raw data within the
@@ -114,21 +188,21 @@ class RawContent(models.Model): # type: ignore[django-manager-missing]
114
188
  models.UniqueConstraint(
115
189
  fields=[
116
190
  "learning_package",
117
- "mime_type",
191
+ "media_type",
118
192
  "hash_digest",
119
193
  ],
120
- name="oel_content_uniq_lc_mime_type_hash_digest",
194
+ name="oel_content_uniq_lc_media_type_hash_digest",
121
195
  ),
122
196
  ]
123
197
  indexes = [
124
- # LearningPackage MIME type Index:
198
+ # LearningPackage Media type Index:
125
199
  # * Break down Content counts by type/subtype with in a
126
200
  # LearningPackage.
127
201
  # * Find all the Content in a LearningPackage that matches a
128
202
  # certain MIME type (e.g. "image/png", "application/pdf".
129
203
  models.Index(
130
- fields=["learning_package", "mime_type"],
131
- name="oel_content_idx_lp_mime_type",
204
+ fields=["learning_package", "media_type"],
205
+ name="oel_content_idx_lp_media_type",
132
206
  ),
133
207
  # LearningPackage (reverse) Size Index:
134
208
  # * Find largest Content in a LearningPackage.
@@ -0,0 +1,34 @@
1
+ """
2
+ Test-friendly helpers for caching.
3
+
4
+ LRU caching can be especially helpful for Learning Core data models because so
5
+ much of it is immutable, e.g. Media Type lookups. But while these data models
6
+ are immutable within a given site, we also want to be able to track and clear
7
+ these caches across test runs. Later on, we may also want to inspect them to
8
+ make sure they are not growing overly large.
9
+ """
10
+ import functools
11
+
12
+ # List of functions that have our
13
+ _lru_cached_fns = []
14
+
15
+
16
+ def lru_cache(*args, **kwargs):
17
+ """
18
+ Thin wrapper over functools.lru_cache that lets us clear all caches later.
19
+ """
20
+ def decorator(fn):
21
+ wrapped_fn = functools.lru_cache(*args, **kwargs)(fn)
22
+ _lru_cached_fns.append(wrapped_fn)
23
+ return wrapped_fn
24
+ return decorator
25
+
26
+
27
+ def clear_lru_caches():
28
+ """
29
+ Clear all LRU caches that use our lru_cache decorator.
30
+
31
+ Useful for tests.
32
+ """
33
+ for fn in _lru_cached_fns:
34
+ fn.cache_clear()
@@ -121,6 +121,16 @@ def hash_field() -> models.CharField:
121
121
 
122
122
  Use the create_hash_digest function to generate data suitable for this
123
123
  field.
124
+
125
+ There are a couple of ways that we could have stored this more efficiently,
126
+ but we don't at this time:
127
+
128
+ 1. A BinaryField would be the most space efficient, but Django doesn't
129
+ support indexing a BinaryField in a MySQL database.
130
+ 2. We could make the field case-sensitive and run it through a URL-safe
131
+ base64 encoding. But the amount of space this saves vs. the complexity
132
+ didn't seem worthwhile, particularly the possibility of case-sensitivity
133
+ related bugs.
124
134
  """
125
135
  return models.CharField(
126
136
  max_length=40,
@@ -0,0 +1,22 @@
1
+ """
2
+ Test utilities for Learning Core.
3
+
4
+ The only thing here now is a TestCase class that knows how to clean up the
5
+ caching used by the cache module in this package.
6
+ """
7
+ import django.test
8
+
9
+ from .cache import clear_lru_caches
10
+
11
+
12
+ class TestCase(django.test.TestCase):
13
+ """
14
+ Subclass of Django's TestCase that knows how to reset caching we might use.
15
+ """
16
+ def setUp(self) -> None:
17
+ clear_lru_caches()
18
+ super().setUp()
19
+
20
+ def tearDown(self) -> None:
21
+ clear_lru_caches()
22
+ super().tearDown()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.2
2
2
  Name: openedx-learning
3
- Version: 0.3.6
3
+ Version: 0.4.0
4
4
  Summary: An experiment.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -42,8 +42,10 @@ openedx_learning/core/publishing/migrations/0002_alter_fk_on_delete.py
42
42
  openedx_learning/core/publishing/migrations/__init__.py
43
43
  openedx_learning/lib/__init__.py
44
44
  openedx_learning/lib/admin_utils.py
45
+ openedx_learning/lib/cache.py
45
46
  openedx_learning/lib/collations.py
46
47
  openedx_learning/lib/fields.py
48
+ openedx_learning/lib/test_utils.py
47
49
  openedx_learning/lib/validators.py
48
50
  openedx_learning/rest_api/__init__.py
49
51
  openedx_learning/rest_api/apps.py
@@ -373,8 +373,7 @@ class DeleteTag(ImportAction):
373
373
  """
374
374
 
375
375
  def __str__(self) -> str:
376
- taxonomy_tag = self._get_tag()
377
- return str(_("Delete tag (external_id={external_id})").format(external_id=taxonomy_tag.external_id))
376
+ return str(_("Delete tag (external_id={external_id})").format(external_id=self.tag.id))
378
377
 
379
378
  name = "delete"
380
379
 
@@ -397,8 +396,11 @@ class DeleteTag(ImportAction):
397
396
  """
398
397
  Delete a tag
399
398
  """
400
- taxonomy_tag = self._get_tag()
401
- taxonomy_tag.delete()
399
+ try:
400
+ taxonomy_tag = self._get_tag()
401
+ taxonomy_tag.delete()
402
+ except Tag.DoesNotExist:
403
+ pass # The tag may be already cascade deleted if the parent tag was deleted
402
404
 
403
405
 
404
406
  class WithoutChanges(ImportAction):
@@ -115,7 +115,7 @@ def import_tags(
115
115
  tag_import_plan.execute(task)
116
116
  task.end_success()
117
117
  return True
118
- except Exception as exception: # pylint: disable=broad-exception-caught
118
+ except Exception as exception:
119
119
  # Log any exception
120
120
  task.log_exception(exception)
121
121
  return False
@@ -93,14 +93,16 @@ class TagImportPlan:
93
93
  # Verify if there is not a parent update before
94
94
  if not self._search_parent_update(child.external_id, tag.external_id):
95
95
  # Change parent to avoid delete childs
96
- self._build_action(
97
- UpdateParentTag,
98
- TagItem(
99
- id=child.external_id,
100
- value=child.value,
101
- parent_id=None,
102
- ),
103
- )
96
+ if child.external_id not in tags:
97
+ # Only update parent if the child is not going to be deleted
98
+ self._build_action(
99
+ UpdateParentTag,
100
+ TagItem(
101
+ id=child.external_id,
102
+ value=child.value,
103
+ parent_id=None,
104
+ ),
105
+ )
104
106
 
105
107
  # Delete action
106
108
  self._build_action(
@@ -34,6 +34,8 @@ class TaxonomySerializer(serializers.ModelSerializer):
34
34
  """
35
35
  Serializer for the Taxonomy model.
36
36
  """
37
+ tags_count = serializers.SerializerMethodField()
38
+
37
39
  class Meta:
38
40
  model = Taxonomy
39
41
  fields = [
@@ -45,6 +47,7 @@ class TaxonomySerializer(serializers.ModelSerializer):
45
47
  "allow_free_text",
46
48
  "system_defined",
47
49
  "visible_to_authors",
50
+ "tags_count",
48
51
  ]
49
52
 
50
53
  def to_representation(self, instance):
@@ -54,6 +57,9 @@ class TaxonomySerializer(serializers.ModelSerializer):
54
57
  instance = instance.cast()
55
58
  return super().to_representation(instance)
56
59
 
60
+ def get_tags_count(self, instance):
61
+ return instance.tag_set.count()
62
+
57
63
 
58
64
  class ObjectTagListQueryParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method
59
65
  """
@@ -1,6 +1,6 @@
1
- edx-drf-extensions
2
- rules<4.0
3
1
  celery
4
- djangorestframework<4.0
5
- Django<5.0
2
+ rules<4.0
6
3
  attrs
4
+ Django<5.0
5
+ djangorestframework<4.0
6
+ edx-drf-extensions