openedx-learning 0.5.1__py2.py3-none-any.whl → 0.6.1__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.
Files changed (24) hide show
  1. openedx_learning/__init__.py +1 -1
  2. openedx_learning/contrib/media_server/views.py +2 -2
  3. openedx_learning/core/components/admin.py +22 -31
  4. openedx_learning/core/components/api.py +51 -47
  5. openedx_learning/core/components/migrations/0001_initial.py +12 -12
  6. openedx_learning/core/components/migrations/0002_alter_componentversioncontent_key.py +20 -0
  7. openedx_learning/core/components/models.py +37 -30
  8. openedx_learning/core/contents/admin.py +13 -20
  9. openedx_learning/core/contents/api.py +104 -94
  10. openedx_learning/core/contents/migrations/0001_initial.py +23 -30
  11. openedx_learning/core/contents/models.py +230 -149
  12. openedx_learning/core/publishing/migrations/0001_initial.py +2 -2
  13. openedx_learning/core/publishing/migrations/0002_alter_learningpackage_key_and_more.py +25 -0
  14. openedx_learning/core/publishing/models.py +41 -2
  15. openedx_learning/lib/fields.py +14 -2
  16. openedx_learning/lib/managers.py +6 -2
  17. {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/METADATA +4 -4
  18. {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/RECORD +24 -22
  19. openedx_tagging/core/tagging/data.py +1 -0
  20. openedx_tagging/core/tagging/models/base.py +36 -5
  21. openedx_tagging/core/tagging/rest_api/v1/serializers.py +1 -0
  22. {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/LICENSE.txt +0 -0
  23. {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/WHEEL +0 -0
  24. {openedx_learning-0.5.1.dist-info → openedx_learning-0.6.1.dist-info}/top_level.txt +0 -0
@@ -6,58 +6,16 @@ are stored in this app.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
- import codecs
10
9
  from datetime import datetime
11
10
 
12
11
  from django.core.files.base import ContentFile
13
12
  from django.db.transaction import atomic
14
13
 
15
- from ...lib.cache import lru_cache
16
14
  from ...lib.fields import create_hash_digest
17
- from .models import MediaType, RawContent, TextContent
15
+ from .models import Content, MediaType
18
16
 
19
17
 
20
- def create_raw_content(
21
- learning_package_id: int,
22
- /,
23
- data_bytes: bytes,
24
- mime_type: str,
25
- created: datetime,
26
- hash_digest: str | None = None,
27
- ) -> RawContent:
28
- """
29
- Create a new RawContent instance and persist it to storage.
30
- """
31
- hash_digest = hash_digest or create_hash_digest(data_bytes)
32
-
33
- raw_content = RawContent.objects.create(
34
- learning_package_id=learning_package_id,
35
- media_type_id=get_or_create_media_type_id(mime_type),
36
- hash_digest=hash_digest,
37
- size=len(data_bytes),
38
- created=created,
39
- )
40
- raw_content.file.save(
41
- f"{raw_content.learning_package.uuid}/{hash_digest}",
42
- ContentFile(data_bytes),
43
- )
44
- return raw_content
45
-
46
-
47
- def create_text_from_raw_content(raw_content: RawContent, encoding="utf-8-sig") -> TextContent:
48
- """
49
- Create a new TextContent instance for the given RawContent.
50
- """
51
- text = codecs.decode(raw_content.file.open().read(), encoding)
52
- return TextContent.objects.create(
53
- raw_content=raw_content,
54
- text=text,
55
- length=len(text),
56
- )
57
-
58
-
59
- @lru_cache(maxsize=128)
60
- def get_or_create_media_type_id(mime_type: str) -> int:
18
+ def get_or_create_media_type(mime_type: str) -> MediaType:
61
19
  """
62
20
  Return the MediaType.id for the desired mime_type string.
63
21
 
@@ -68,10 +26,12 @@ def get_or_create_media_type_id(mime_type: str) -> int:
68
26
  the different XBlocks that will be installed in different server instances,
69
27
  each of which will use their own MediaType.
70
28
 
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.
29
+ Caching Warning: Be careful about putting any caching decorator around this
30
+ function (e.g. ``lru_cache``). It's possible that incorrect cache values
31
+ could leak out in the event of a rollback–e.g. new types are introduced in
32
+ a large import transaction which later fails. You can safely cache the
33
+ results that come back from this function with a local dict in your import
34
+ process instead.
75
35
  """
76
36
  if "+" in mime_type:
77
37
  base, suffix = mime_type.split("+")
@@ -80,69 +40,119 @@ def get_or_create_media_type_id(mime_type: str) -> int:
80
40
  suffix = ""
81
41
 
82
42
  main_type, sub_type = base.split("/")
83
- mt, _created = MediaType.objects.get_or_create(
43
+ media_type, _created = MediaType.objects.get_or_create(
84
44
  type=main_type,
85
45
  sub_type=sub_type,
86
46
  suffix=suffix,
87
47
  )
88
48
 
89
- return mt.id
49
+ return media_type
90
50
 
91
51
 
92
- def get_or_create_raw_content(
52
+ def get_content(content_id: int, /) -> Content:
53
+ """
54
+ Get a single Content object by its ID.
55
+
56
+ Content is always attached to something when it's created, like to a
57
+ ComponentVersion. That means the "right" way to access a Content is almost
58
+ always going to be via those relations and not via this function. But I
59
+ include this function anyway because it's tiny to write and it's better than
60
+ someone using a get_or_create_* function when they really just want to get.
61
+ """
62
+ return Content.objects.get(id=content_id)
63
+
64
+
65
+ def get_or_create_text_content(
93
66
  learning_package_id: int,
67
+ media_type_id: int,
94
68
  /,
95
- data_bytes: bytes,
96
- mime_type: str,
69
+ text: str,
97
70
  created: datetime,
98
- hash_digest: str | None = None,
99
- ) -> tuple[RawContent, bool]:
71
+ create_file: bool = False,
72
+ ) -> Content:
100
73
  """
101
- Get the RawContent in the given learning package with the specified data,
102
- or create it if it doesn't exist.
74
+ Get or create a Content entry with text data stored in the database.
75
+
76
+ Use this when you want to create relatively small chunks of text that need
77
+ to be accessed quickly, especially if you're pulling back multiple rows at
78
+ once. For example, this is the function to call when storing OLX for a
79
+ component XBlock like a ProblemBlock.
80
+
81
+ This function will *always* create a text entry in the database. In addition
82
+ to this, if you specify ``create_file=True``, it will also save a copy of
83
+ that text data to the file storage backend. This is useful if we want to let
84
+ that file be downloadable by browsers in the LMS at some point.
85
+
86
+ If you want to create a large text file, or want to create a text file that
87
+ doesn't need to be stored in the database, call ``create_file_content``
88
+ instead of this function.
103
89
  """
104
- hash_digest = hash_digest or create_hash_digest(data_bytes)
105
- try:
106
- raw_content = RawContent.objects.get(
107
- learning_package_id=learning_package_id, hash_digest=hash_digest
108
- )
109
- was_created = False
110
- except RawContent.DoesNotExist:
111
- raw_content = create_raw_content(
112
- learning_package_id, data_bytes, mime_type, created, hash_digest
113
- )
114
- was_created = True
115
-
116
- return raw_content, was_created
117
-
118
-
119
- def get_or_create_text_content_from_bytes(
90
+ text_as_bytes = text.encode('utf-8')
91
+ hash_digest = create_hash_digest(text_as_bytes)
92
+
93
+ with atomic():
94
+ try:
95
+ content = Content.objects.get(
96
+ learning_package_id=learning_package_id,
97
+ media_type_id=media_type_id,
98
+ hash_digest=hash_digest,
99
+ )
100
+ except Content.DoesNotExist:
101
+ content = Content(
102
+ learning_package_id=learning_package_id,
103
+ media_type_id=media_type_id,
104
+ hash_digest=hash_digest,
105
+ created=created,
106
+ size=len(text_as_bytes),
107
+ text=text,
108
+ has_file=create_file,
109
+ )
110
+ content.full_clean()
111
+ content.save()
112
+
113
+ if create_file:
114
+ content.write_file(ContentFile(text_as_bytes))
115
+
116
+ return content
117
+
118
+
119
+ def get_or_create_file_content(
120
120
  learning_package_id: int,
121
+ media_type_id: int,
121
122
  /,
122
- data_bytes: bytes,
123
- mime_type: str,
123
+ data: bytes,
124
124
  created: datetime,
125
- hash_digest: str | None = None,
126
- encoding: str = "utf-8-sig",
127
- ):
125
+ ) -> Content:
128
126
  """
129
- Get the TextContent in the given learning package with the specified data,
130
- or create it if it doesn't exist.
127
+ Get or create a Content with data stored in a file storage backend.
128
+
129
+ Use this function to store non-text data, large data, or data where low
130
+ latency access is not necessary. Also use this function (or
131
+ ``get_or_create_text_content`` with ``create_file=True``) to store any
132
+ Content that you want to be downloadable by browsers in the LMS, since the
133
+ static asset serving system will only work with file-backed Content.
131
134
  """
135
+ hash_digest = create_hash_digest(data)
132
136
  with atomic():
133
- raw_content, rc_created = get_or_create_raw_content(
134
- learning_package_id, data_bytes, mime_type, created, hash_digest
135
- )
136
- if rc_created or not hasattr(raw_content, "text_content"):
137
- text = codecs.decode(data_bytes, encoding)
138
- text_content = TextContent.objects.create(
139
- raw_content=raw_content,
140
- text=text,
141
- length=len(text),
137
+ try:
138
+ content = Content.objects.get(
139
+ learning_package_id=learning_package_id,
140
+ media_type_id=media_type_id,
141
+ hash_digest=hash_digest,
142
142
  )
143
- tc_created = True
144
- else:
145
- text_content = raw_content.text_content
146
- tc_created = False
143
+ except Content.DoesNotExist:
144
+ content = Content(
145
+ learning_package_id=learning_package_id,
146
+ media_type_id=media_type_id,
147
+ hash_digest=hash_digest,
148
+ created=created,
149
+ size=len(data),
150
+ text=None,
151
+ has_file=True,
152
+ )
153
+ content.full_clean()
154
+ content.save()
155
+
156
+ content.write_file(ContentFile(data))
147
157
 
148
- return (text_content, tc_created)
158
+ return content
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.23 on 2024-01-22 00:38
1
+ # Generated by Django 3.2.23 on 2024-02-06 03:23
2
2
 
3
3
  import django.core.validators
4
4
  import django.db.models.deletion
@@ -18,56 +18,49 @@ class Migration(migrations.Migration):
18
18
 
19
19
  operations = [
20
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
- ),
29
- migrations.CreateModel(
30
- name='RawContent',
21
+ name='Content',
31
22
  fields=[
32
23
  ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33
- ('hash_digest', models.CharField(editable=False, max_length=40)),
34
24
  ('size', models.PositiveBigIntegerField(validators=[django.core.validators.MaxValueValidator(50000000)])),
25
+ ('hash_digest', models.CharField(editable=False, max_length=40)),
26
+ ('has_file', models.BooleanField()),
27
+ ('text', openedx_learning.lib.fields.MultiCollationTextField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=50000, null=True)),
35
28
  ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
36
- ('file', models.FileField(null=True, upload_to='')),
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')),
39
29
  ],
40
30
  options={
41
- 'verbose_name': 'Raw Content',
42
- 'verbose_name_plural': 'Raw Contents',
31
+ 'verbose_name': 'Content',
32
+ 'verbose_name_plural': 'Contents',
43
33
  },
44
34
  ),
45
35
  migrations.CreateModel(
46
- name='TextContent',
36
+ name='MediaType',
47
37
  fields=[
48
- ('raw_content', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='text_content', serialize=False, to='oel_contents.rawcontent')),
49
- ('text', openedx_learning.lib.fields.MultiCollationTextField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=100000)),
50
- ('length', models.PositiveIntegerField()),
38
+ ('id', models.AutoField(primary_key=True, serialize=False)),
39
+ ('type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
40
+ ('sub_type', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
41
+ ('suffix', openedx_learning.lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, max_length=127)),
51
42
  ],
52
43
  ),
53
44
  migrations.AddConstraint(
54
45
  model_name='mediatype',
55
46
  constraint=models.UniqueConstraint(fields=('type', 'sub_type', 'suffix'), name='oel_contents_uniq_t_st_sfx'),
56
47
  ),
57
- migrations.AddIndex(
58
- model_name='rawcontent',
59
- index=models.Index(fields=['learning_package', 'media_type'], name='oel_content_idx_lp_media_type'),
48
+ migrations.AddField(
49
+ model_name='content',
50
+ name='learning_package',
51
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage'),
60
52
  ),
61
- migrations.AddIndex(
62
- model_name='rawcontent',
63
- index=models.Index(fields=['learning_package', '-size'], name='oel_content_idx_lp_rsize'),
53
+ migrations.AddField(
54
+ model_name='content',
55
+ name='media_type',
56
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='oel_contents.mediatype'),
64
57
  ),
65
58
  migrations.AddIndex(
66
- model_name='rawcontent',
67
- index=models.Index(fields=['learning_package', '-created'], name='oel_content_idx_lp_rcreated'),
59
+ model_name='content',
60
+ index=models.Index(fields=['learning_package', '-size'], name='oel_content_idx_lp_rsize'),
68
61
  ),
69
62
  migrations.AddConstraint(
70
- model_name='rawcontent',
63
+ model_name='content',
71
64
  constraint=models.UniqueConstraint(fields=('learning_package', 'media_type', 'hash_digest'), name='oel_content_uniq_lc_media_type_hash_digest'),
72
65
  ),
73
66
  ]