openedx-learning 0.23.1__py2.py3-none-any.whl → 0.25.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.
Files changed (22) hide show
  1. openedx_learning/__init__.py +1 -1
  2. openedx_learning/api/authoring.py +2 -0
  3. openedx_learning/api/authoring_models.py +2 -0
  4. openedx_learning/apps/authoring/publishing/api.py +7 -11
  5. openedx_learning/apps/authoring/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py +1 -2
  6. openedx_learning/apps/authoring/sections/__init__.py +0 -0
  7. openedx_learning/apps/authoring/sections/api.py +310 -0
  8. openedx_learning/apps/authoring/sections/apps.py +25 -0
  9. openedx_learning/apps/authoring/sections/migrations/0001_initial.py +36 -0
  10. openedx_learning/apps/authoring/sections/migrations/__init__.py +0 -0
  11. openedx_learning/apps/authoring/sections/models.py +50 -0
  12. openedx_learning/apps/authoring/subsections/__init__.py +0 -0
  13. openedx_learning/apps/authoring/subsections/api.py +309 -0
  14. openedx_learning/apps/authoring/subsections/apps.py +25 -0
  15. openedx_learning/apps/authoring/subsections/migrations/0001_initial.py +36 -0
  16. openedx_learning/apps/authoring/subsections/migrations/__init__.py +0 -0
  17. openedx_learning/apps/authoring/subsections/models.py +50 -0
  18. {openedx_learning-0.23.1.dist-info → openedx_learning-0.25.0.dist-info}/METADATA +3 -3
  19. {openedx_learning-0.23.1.dist-info → openedx_learning-0.25.0.dist-info}/RECORD +22 -10
  20. {openedx_learning-0.23.1.dist-info → openedx_learning-0.25.0.dist-info}/WHEEL +1 -1
  21. {openedx_learning-0.23.1.dist-info → openedx_learning-0.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  22. {openedx_learning-0.23.1.dist-info → openedx_learning-0.25.0.dist-info}/top_level.txt +0 -0
@@ -2,4 +2,4 @@
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
4
 
5
- __version__ = "0.23.1"
5
+ __version__ = "0.25.0"
@@ -13,6 +13,8 @@ from ..apps.authoring.collections.api import *
13
13
  from ..apps.authoring.components.api import *
14
14
  from ..apps.authoring.contents.api import *
15
15
  from ..apps.authoring.publishing.api import *
16
+ from ..apps.authoring.sections.api import *
17
+ from ..apps.authoring.subsections.api import *
16
18
  from ..apps.authoring.units.api import *
17
19
 
18
20
  # This was renamed after the authoring API refactoring pushed this and other
@@ -11,4 +11,6 @@ from ..apps.authoring.collections.models import *
11
11
  from ..apps.authoring.components.models import *
12
12
  from ..apps.authoring.contents.models import *
13
13
  from ..apps.authoring.publishing.models import *
14
+ from ..apps.authoring.sections.models import *
15
+ from ..apps.authoring.subsections.models import *
14
16
  from ..apps.authoring.units.models import *
@@ -1322,7 +1322,10 @@ def contains_unpublished_changes(container_id: int) -> bool:
1322
1322
  return True
1323
1323
 
1324
1324
  # We only care about children that are un-pinned, since published changes to pinned children don't matter
1325
- entity_list = container.versioning.draft.entity_list
1325
+ entity_list = getattr(container.versioning.draft, "entity_list", None)
1326
+ if entity_list is None:
1327
+ # This container has been soft-deleted, so it has no children.
1328
+ return False
1326
1329
 
1327
1330
  # This is a naive and inefficient implementation but should be correct.
1328
1331
  # TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but
@@ -1367,23 +1370,16 @@ def get_containers_with_entity(
1367
1370
  ignore_pinned: if true, ignore any pinned references to the entity.
1368
1371
  """
1369
1372
  if ignore_pinned:
1370
- # TODO: Do we need to run distinct() on this? Will fix in https://github.com/openedx/openedx-learning/issues/303
1371
1373
  qs = Container.objects.filter(
1372
1374
  # Note: these two conditions must be in the same filter() call, or the query won't be correct.
1373
1375
  publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1374
1376
  publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501
1375
- ).order_by("pk") # Ordering is mostly for consistent test cases.
1377
+ )
1376
1378
  else:
1377
1379
  qs = Container.objects.filter(
1378
1380
  publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1379
- ).order_by("pk") # Ordering is mostly for consistent test cases.
1380
- # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it.
1381
- # # Find all the EntityLists that contain the given entity:
1382
- # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True)
1383
- # qs = Container.objects.filter(
1384
- # publishable_entity__draft__version__containerversion__entity_list__in=lists
1385
- # )
1386
- return qs
1381
+ )
1382
+ return qs.order_by("pk").distinct() # Ordering is mostly for consistent test cases.
1387
1383
 
1388
1384
 
1389
1385
  def get_container_children_count(
@@ -1,7 +1,6 @@
1
1
  # Generated by Django 4.2.20 on 2025-04-17 18:22
2
-
3
- from django.db import migrations, models
4
2
  import django.db.models.deletion
3
+ from django.db import migrations, models
5
4
 
6
5
 
7
6
  class Migration(migrations.Migration):
File without changes
@@ -0,0 +1,310 @@
1
+ """Sections API.
2
+
3
+ This module provides functions to manage sections.
4
+ """
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ from django.db.transaction import atomic
9
+
10
+ from openedx_learning.apps.authoring.subsections.models import Subsection, SubsectionVersion
11
+
12
+ from ..publishing import api as publishing_api
13
+ from .models import Section, SectionVersion
14
+
15
+ # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured
16
+ # out our approach to dynamic content (randomized, A/B tests, etc.)
17
+ __all__ = [
18
+ "create_section",
19
+ "create_section_version",
20
+ "create_next_section_version",
21
+ "create_section_and_version",
22
+ "get_section",
23
+ "get_section_version",
24
+ "get_latest_section_version",
25
+ "SectionListEntry",
26
+ "get_subsections_in_section",
27
+ "get_subsections_in_section",
28
+ "get_subsections_in_published_section_as_of",
29
+ ]
30
+
31
+
32
+ def create_section(
33
+ learning_package_id: int,
34
+ key: str,
35
+ created: datetime,
36
+ created_by: int | None,
37
+ *,
38
+ can_stand_alone: bool = True,
39
+ ) -> Section:
40
+ """
41
+ [ 🛑 UNSTABLE ] Create a new section.
42
+
43
+ Args:
44
+ learning_package_id: The learning package ID.
45
+ key: The key.
46
+ created: The creation date.
47
+ created_by: The user who created the section.
48
+ can_stand_alone: Set to False when created as part of containers
49
+ """
50
+ return publishing_api.create_container(
51
+ learning_package_id,
52
+ key,
53
+ created,
54
+ created_by,
55
+ can_stand_alone=can_stand_alone,
56
+ container_cls=Section,
57
+ )
58
+
59
+
60
+ def create_section_version(
61
+ section: Section,
62
+ version_num: int,
63
+ *,
64
+ title: str,
65
+ entity_rows: list[publishing_api.ContainerEntityRow],
66
+ created: datetime,
67
+ created_by: int | None = None,
68
+ ) -> SectionVersion:
69
+ """
70
+ [ 🛑 UNSTABLE ] Create a new section version.
71
+
72
+ This is a very low-level API, likely only needed for import/export. In
73
+ general, you will use `create_section_and_version()` and
74
+ `create_next_section_version()` instead.
75
+
76
+ Args:
77
+ section_pk: The section ID.
78
+ version_num: The version number.
79
+ title: The title.
80
+ entity_rows: child entities/versions
81
+ created: The creation date.
82
+ created_by: The user who created the section.
83
+ """
84
+ return publishing_api.create_container_version(
85
+ section.pk,
86
+ version_num,
87
+ title=title,
88
+ entity_rows=entity_rows,
89
+ created=created,
90
+ created_by=created_by,
91
+ container_version_cls=SectionVersion,
92
+ )
93
+
94
+
95
+ def _pub_entities_for_subsections(
96
+ subsections: list[Subsection | SubsectionVersion] | None,
97
+ ) -> list[publishing_api.ContainerEntityRow] | None:
98
+ """
99
+ Helper method: given a list of Subsection | SubsectionVersion, return the
100
+ lists of publishable_entities_pks and entity_version_pks needed for the
101
+ base container APIs.
102
+
103
+ SubsectionVersion is passed when we want to pin a specific version, otherwise
104
+ Subsection is used for unpinned.
105
+ """
106
+ if subsections is None:
107
+ # When these are None, that means don't change the entities in the list.
108
+ return None
109
+ for u in subsections:
110
+ if not isinstance(u, (Subsection, SubsectionVersion)):
111
+ raise TypeError("Section subsections must be either Subsection or SubsectionVersion.")
112
+ return [
113
+ (
114
+ publishing_api.ContainerEntityRow(
115
+ entity_pk=s.container.publishable_entity_id,
116
+ version_pk=None,
117
+ ) if isinstance(s, Subsection)
118
+ else publishing_api.ContainerEntityRow(
119
+ entity_pk=s.subsection.container.publishable_entity_id,
120
+ version_pk=s.container_version.publishable_entity_version_id,
121
+ )
122
+ )
123
+ for s in subsections
124
+ ]
125
+
126
+
127
+ def create_next_section_version(
128
+ section: Section,
129
+ *,
130
+ title: str | None = None,
131
+ subsections: list[Subsection | SubsectionVersion] | None = None,
132
+ created: datetime,
133
+ created_by: int | None = None,
134
+ entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
135
+ ) -> SectionVersion:
136
+ """
137
+ [ 🛑 UNSTABLE ] Create the next section version.
138
+
139
+ Args:
140
+ section_pk: The section ID.
141
+ title: The title. Leave as None to keep the current title.
142
+ subsections: The subsections, as a list of Subsections (unpinned) and/or SubsectionVersions (pinned).
143
+ Passing None will leave the existing subsections unchanged.
144
+ created: The creation date.
145
+ created_by: The user who created the section.
146
+ """
147
+ entity_rows = _pub_entities_for_subsections(subsections)
148
+ section_version = publishing_api.create_next_container_version(
149
+ section.pk,
150
+ title=title,
151
+ entity_rows=entity_rows,
152
+ created=created,
153
+ created_by=created_by,
154
+ container_version_cls=SectionVersion,
155
+ entities_action=entities_action,
156
+ )
157
+ return section_version
158
+
159
+
160
+ def create_section_and_version(
161
+ learning_package_id: int,
162
+ key: str,
163
+ *,
164
+ title: str,
165
+ subsections: list[Subsection | SubsectionVersion] | None = None,
166
+ created: datetime,
167
+ created_by: int | None = None,
168
+ can_stand_alone: bool = True,
169
+ ) -> tuple[Section, SectionVersion]:
170
+ """
171
+ [ 🛑 UNSTABLE ] Create a new section and its version.
172
+
173
+ Args:
174
+ learning_package_id: The learning package ID.
175
+ key: The key.
176
+ created: The creation date.
177
+ created_by: The user who created the section.
178
+ can_stand_alone: Set to False when created as part of containers
179
+ """
180
+ entity_rows = _pub_entities_for_subsections(subsections)
181
+ with atomic():
182
+ section = create_section(
183
+ learning_package_id,
184
+ key,
185
+ created,
186
+ created_by,
187
+ can_stand_alone=can_stand_alone,
188
+ )
189
+ section_version = create_section_version(
190
+ section,
191
+ 1,
192
+ title=title,
193
+ entity_rows=entity_rows or [],
194
+ created=created,
195
+ created_by=created_by,
196
+ )
197
+ return section, section_version
198
+
199
+
200
+ def get_section(section_pk: int) -> Section:
201
+ """
202
+ [ 🛑 UNSTABLE ] Get a section.
203
+
204
+ Args:
205
+ section_pk: The section ID.
206
+ """
207
+ return Section.objects.get(pk=section_pk)
208
+
209
+
210
+ def get_section_version(section_version_pk: int) -> SectionVersion:
211
+ """
212
+ [ 🛑 UNSTABLE ] Get a section version.
213
+
214
+ Args:
215
+ section_version_pk: The section version ID.
216
+ """
217
+ return SectionVersion.objects.get(pk=section_version_pk)
218
+
219
+
220
+ def get_latest_section_version(section_pk: int) -> SectionVersion:
221
+ """
222
+ [ 🛑 UNSTABLE ] Get the latest section version.
223
+
224
+ Args:
225
+ section_pk: The section ID.
226
+ """
227
+ return Section.objects.get(pk=section_pk).versioning.latest
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class SectionListEntry:
232
+ """
233
+ [ 🛑 UNSTABLE ]
234
+ Data about a single entity in a container, e.g. a subsection in a section.
235
+ """
236
+ subsection_version: SubsectionVersion
237
+ pinned: bool = False
238
+
239
+ @property
240
+ def subsection(self):
241
+ return self.subsection_version.subsection
242
+
243
+
244
+ def get_subsections_in_section(
245
+ section: Section,
246
+ *,
247
+ published: bool,
248
+ ) -> list[SectionListEntry]:
249
+ """
250
+ [ 🛑 UNSTABLE ]
251
+ Get the list of entities and their versions in the draft or published
252
+ version of the given Section.
253
+
254
+ Args:
255
+ section: The Section, e.g. returned by `get_section()`
256
+ published: `True` if we want the published version of the section, or
257
+ `False` for the draft version.
258
+ """
259
+ assert isinstance(section, Section)
260
+ subsections = []
261
+ for entry in publishing_api.get_entities_in_container(section, published=published):
262
+ # Convert from generic PublishableEntityVersion to SubsectionVersion:
263
+ subsection_version = entry.entity_version.containerversion.subsectionversion
264
+ assert isinstance(subsection_version, SubsectionVersion)
265
+ subsections.append(SectionListEntry(subsection_version=subsection_version, pinned=entry.pinned))
266
+ return subsections
267
+
268
+
269
+ def get_subsections_in_published_section_as_of(
270
+ section: Section,
271
+ publish_log_id: int,
272
+ ) -> list[SectionListEntry] | None:
273
+ """
274
+ [ 🛑 UNSTABLE ]
275
+ Get the list of entities and their versions in the published version of the
276
+ given container as of the given PublishLog version (which is essentially a
277
+ version for the entire learning package).
278
+
279
+ TODO: This API should be updated to also return the SectionVersion so we can
280
+ see the section title and any other metadata from that point in time.
281
+ TODO: accept a publish log UUID, not just int ID?
282
+ TODO: move the implementation to be a generic 'containers' implementation
283
+ that this sections function merely wraps.
284
+ TODO: optimize, perhaps by having the publishlog store a record of all
285
+ ancestors of every modified PublishableEntity in the publish.
286
+ """
287
+ assert isinstance(section, Section)
288
+ section_pub_entity_version = publishing_api.get_published_version_as_of(
289
+ section.publishable_entity_id, publish_log_id
290
+ )
291
+ if section_pub_entity_version is None:
292
+ return None # This section was not published as of the given PublishLog ID.
293
+ container_version = section_pub_entity_version.containerversion
294
+
295
+ entity_list = []
296
+ rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
297
+ for row in rows:
298
+ if row.entity_version is not None:
299
+ subsection_version = row.entity_version.containerversion.subsectionversion
300
+ assert isinstance(subsection_version, SubsectionVersion)
301
+ entity_list.append(SectionListEntry(subsection_version=subsection_version, pinned=True))
302
+ else:
303
+ # Unpinned subsection - figure out what its latest published version was.
304
+ # This is not optimized. It could be done in one query per section rather than one query per subsection.
305
+ pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
306
+ if pub_entity_version:
307
+ entity_list.append(SectionListEntry(
308
+ subsection_version=pub_entity_version.containerversion.subsectionversion, pinned=False
309
+ ))
310
+ return entity_list
@@ -0,0 +1,25 @@
1
+ """
2
+ Subsection Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class SectionsConfig(AppConfig):
9
+ """
10
+ Configuration for the subsections Django application.
11
+ """
12
+
13
+ name = "openedx_learning.apps.authoring.sections"
14
+ verbose_name = "Learning Core > Authoring > Sections"
15
+ default_auto_field = "django.db.models.BigAutoField"
16
+ label = "oel_sections"
17
+
18
+ def ready(self):
19
+ """
20
+ Register Section and SectionVersion.
21
+ """
22
+ from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel
23
+ from .models import Section, SectionVersion # pylint: disable=import-outside-toplevel
24
+
25
+ register_content_models(Section, SectionVersion)
@@ -0,0 +1,36 @@
1
+ # Generated by Django 4.2.20 on 2025-04-11 12:53
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('oel_publishing', '0005_alter_entitylistrow_options'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='Section',
18
+ fields=[
19
+ ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')),
20
+ ],
21
+ options={
22
+ 'abstract': False,
23
+ },
24
+ bases=('oel_publishing.container',),
25
+ ),
26
+ migrations.CreateModel(
27
+ name='SectionVersion',
28
+ fields=[
29
+ ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.containerversion')),
30
+ ],
31
+ options={
32
+ 'abstract': False,
33
+ },
34
+ bases=('oel_publishing.containerversion',),
35
+ ),
36
+ ]
@@ -0,0 +1,50 @@
1
+ """
2
+ Models that implement sections
3
+ """
4
+ from django.db import models
5
+
6
+ from ..publishing.models import Container, ContainerVersion
7
+
8
+ __all__ = [
9
+ "Section",
10
+ "SectionVersion",
11
+ ]
12
+
13
+
14
+ class Section(Container):
15
+ """
16
+ A Section is type of Container that holds Units.
17
+
18
+ Via Container and its PublishableEntityMixin, Sections are also publishable
19
+ entities and can be added to other containers.
20
+ """
21
+ container = models.OneToOneField(
22
+ Container,
23
+ on_delete=models.CASCADE,
24
+ parent_link=True,
25
+ primary_key=True,
26
+ )
27
+
28
+
29
+ class SectionVersion(ContainerVersion):
30
+ """
31
+ A SectionVersion is a specific version of a Section.
32
+
33
+ Via ContainerVersion and its EntityList, it defines the list of Units
34
+ in this version of the Section.
35
+ """
36
+ container_version = models.OneToOneField(
37
+ ContainerVersion,
38
+ on_delete=models.CASCADE,
39
+ parent_link=True,
40
+ primary_key=True,
41
+ )
42
+
43
+ @property
44
+ def section(self):
45
+ """ Convenience accessor to the Section this version is associated with """
46
+ return self.container_version.container.section # pylint: disable=no-member
47
+
48
+ # Note: the 'publishable_entity_version' field is inherited and will appear on this model, but does not exist
49
+ # in the underlying database table. It only exists in the ContainerVersion table.
50
+ # You can verify this by running 'python manage.py sqlmigrate oel_sections 0001_initial'
@@ -0,0 +1,309 @@
1
+ """Subsections API.
2
+
3
+ This module provides functions to manage subsections.
4
+ """
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ from django.db.transaction import atomic
9
+
10
+ from openedx_learning.apps.authoring.units.models import Unit, UnitVersion
11
+
12
+ from ..publishing import api as publishing_api
13
+ from .models import Subsection, SubsectionVersion
14
+
15
+ # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured
16
+ # out our approach to dynamic content (randomized, A/B tests, etc.)
17
+ __all__ = [
18
+ "create_subsection",
19
+ "create_subsection_version",
20
+ "create_next_subsection_version",
21
+ "create_subsection_and_version",
22
+ "get_subsection",
23
+ "get_subsection_version",
24
+ "get_latest_subsection_version",
25
+ "SubsectionListEntry",
26
+ "get_units_in_subsection",
27
+ "get_units_in_subsection",
28
+ "get_units_in_published_subsection_as_of",
29
+ ]
30
+
31
+
32
+ def create_subsection(
33
+ learning_package_id: int,
34
+ key: str,
35
+ created: datetime,
36
+ created_by: int | None,
37
+ *,
38
+ can_stand_alone: bool = True,
39
+ ) -> Subsection:
40
+ """
41
+ [ 🛑 UNSTABLE ] Create a new subsection.
42
+
43
+ Args:
44
+ learning_package_id: The learning package ID.
45
+ key: The key.
46
+ created: The creation date.
47
+ created_by: The user who created the subsection.
48
+ can_stand_alone: Set to False when created as part of containers
49
+ """
50
+ return publishing_api.create_container(
51
+ learning_package_id,
52
+ key,
53
+ created,
54
+ created_by,
55
+ can_stand_alone=can_stand_alone,
56
+ container_cls=Subsection,
57
+ )
58
+
59
+
60
+ def create_subsection_version(
61
+ subsection: Subsection,
62
+ version_num: int,
63
+ *,
64
+ title: str,
65
+ entity_rows: list[publishing_api.ContainerEntityRow],
66
+ created: datetime,
67
+ created_by: int | None = None,
68
+ ) -> SubsectionVersion:
69
+ """
70
+ [ 🛑 UNSTABLE ] Create a new subsection version.
71
+
72
+ This is a very low-level API, likely only needed for import/export. In
73
+ general, you will use `create_subsection_and_version()` and
74
+ `create_next_subsection_version()` instead.
75
+
76
+ Args:
77
+ subsection_pk: The subsection ID.
78
+ version_num: The version number.
79
+ title: The title.
80
+ entity_rows: child entities/versions
81
+ created: The creation date.
82
+ created_by: The user who created the subsection.
83
+ """
84
+ return publishing_api.create_container_version(
85
+ subsection.pk,
86
+ version_num,
87
+ title=title,
88
+ entity_rows=entity_rows,
89
+ created=created,
90
+ created_by=created_by,
91
+ container_version_cls=SubsectionVersion,
92
+ )
93
+
94
+
95
+ def _pub_entities_for_units(
96
+ units: list[Unit | UnitVersion] | None,
97
+ ) -> list[publishing_api.ContainerEntityRow] | None:
98
+ """
99
+ Helper method: given a list of Unit | UnitVersion, return the
100
+ list of ContainerEntityRows needed for the base container APIs.
101
+
102
+ UnitVersion is passed when we want to pin a specific version, otherwise
103
+ Unit is used for unpinned.
104
+ """
105
+ if units is None:
106
+ # When these are None, that means don't change the entities in the list.
107
+ return None
108
+ for u in units:
109
+ if not isinstance(u, (Unit, UnitVersion)):
110
+ raise TypeError("Subsection units must be either Unit or UnitVersion.")
111
+ return [
112
+ (
113
+ publishing_api.ContainerEntityRow(
114
+ entity_pk=u.container.publishable_entity_id,
115
+ version_pk=None,
116
+ ) if isinstance(u, Unit)
117
+ else publishing_api.ContainerEntityRow(
118
+ entity_pk=u.unit.container.publishable_entity_id,
119
+ version_pk=u.container_version.publishable_entity_version_id,
120
+ )
121
+ )
122
+ for u in units
123
+ ]
124
+
125
+
126
+ def create_next_subsection_version(
127
+ subsection: Subsection,
128
+ *,
129
+ title: str | None = None,
130
+ units: list[Unit | UnitVersion] | None = None,
131
+ created: datetime,
132
+ created_by: int | None = None,
133
+ entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
134
+ ) -> SubsectionVersion:
135
+ """
136
+ [ 🛑 UNSTABLE ] Create the next subsection version.
137
+
138
+ Args:
139
+ subsection_pk: The subsection ID.
140
+ title: The title. Leave as None to keep the current title.
141
+ units: The units, as a list of Units (unpinned) and/or UnitVersions (pinned). Passing None
142
+ will leave the existing units unchanged.
143
+ created: The creation date.
144
+ created_by: The user who created the subsection.
145
+ """
146
+ entity_rows = _pub_entities_for_units(units)
147
+ subsection_version = publishing_api.create_next_container_version(
148
+ subsection.pk,
149
+ title=title,
150
+ entity_rows=entity_rows,
151
+ created=created,
152
+ created_by=created_by,
153
+ container_version_cls=SubsectionVersion,
154
+ entities_action=entities_action,
155
+ )
156
+ return subsection_version
157
+
158
+
159
+ def create_subsection_and_version(
160
+ learning_package_id: int,
161
+ key: str,
162
+ *,
163
+ title: str,
164
+ units: list[Unit | UnitVersion] | None = None,
165
+ created: datetime,
166
+ created_by: int | None = None,
167
+ can_stand_alone: bool = True,
168
+ ) -> tuple[Subsection, SubsectionVersion]:
169
+ """
170
+ [ 🛑 UNSTABLE ] Create a new subsection and its version.
171
+
172
+ Args:
173
+ learning_package_id: The learning package ID.
174
+ key: The key.
175
+ created: The creation date.
176
+ created_by: The user who created the subsection.
177
+ can_stand_alone: Set to False when created as part of containers
178
+ """
179
+ entity_rows = _pub_entities_for_units(units)
180
+ with atomic():
181
+ subsection = create_subsection(
182
+ learning_package_id,
183
+ key,
184
+ created,
185
+ created_by,
186
+ can_stand_alone=can_stand_alone,
187
+ )
188
+ subsection_version = create_subsection_version(
189
+ subsection,
190
+ 1,
191
+ title=title,
192
+ entity_rows=entity_rows or [],
193
+ created=created,
194
+ created_by=created_by,
195
+ )
196
+ return subsection, subsection_version
197
+
198
+
199
+ def get_subsection(subsection_pk: int) -> Subsection:
200
+ """
201
+ [ 🛑 UNSTABLE ] Get a subsection.
202
+
203
+ Args:
204
+ subsection_pk: The subsection ID.
205
+ """
206
+ return Subsection.objects.get(pk=subsection_pk)
207
+
208
+
209
+ def get_subsection_version(subsection_version_pk: int) -> SubsectionVersion:
210
+ """
211
+ [ 🛑 UNSTABLE ] Get a subsection version.
212
+
213
+ Args:
214
+ subsection_version_pk: The subsection version ID.
215
+ """
216
+ return SubsectionVersion.objects.get(pk=subsection_version_pk)
217
+
218
+
219
+ def get_latest_subsection_version(subsection_pk: int) -> SubsectionVersion:
220
+ """
221
+ [ 🛑 UNSTABLE ] Get the latest subsection version.
222
+
223
+ Args:
224
+ subsection_pk: The subsection ID.
225
+ """
226
+ return Subsection.objects.get(pk=subsection_pk).versioning.latest
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class SubsectionListEntry:
231
+ """
232
+ [ 🛑 UNSTABLE ]
233
+ Data about a single entity in a container, e.g. a unit in a subsection.
234
+ """
235
+ unit_version: UnitVersion
236
+ pinned: bool = False
237
+
238
+ @property
239
+ def unit(self):
240
+ return self.unit_version.unit
241
+
242
+
243
+ def get_units_in_subsection(
244
+ subsection: Subsection,
245
+ *,
246
+ published: bool,
247
+ ) -> list[SubsectionListEntry]:
248
+ """
249
+ [ 🛑 UNSTABLE ]
250
+ Get the list of entities and their versions in the draft or published
251
+ version of the given Subsection.
252
+
253
+ Args:
254
+ subsection: The Subsection, e.g. returned by `get_subsection()`
255
+ published: `True` if we want the published version of the subsection, or
256
+ `False` for the draft version.
257
+ """
258
+ assert isinstance(subsection, Subsection)
259
+ units = []
260
+ for entry in publishing_api.get_entities_in_container(subsection, published=published):
261
+ # Convert from generic PublishableEntityVersion to UnitVersion:
262
+ unit_version = entry.entity_version.containerversion.unitversion
263
+ assert isinstance(unit_version, UnitVersion)
264
+ units.append(SubsectionListEntry(unit_version=unit_version, pinned=entry.pinned))
265
+ return units
266
+
267
+
268
+ def get_units_in_published_subsection_as_of(
269
+ subsection: Subsection,
270
+ publish_log_id: int,
271
+ ) -> list[SubsectionListEntry] | None:
272
+ """
273
+ [ 🛑 UNSTABLE ]
274
+ Get the list of entities and their versions in the published version of the
275
+ given container as of the given PublishLog version (which is essentially a
276
+ version for the entire learning package).
277
+
278
+ TODO: This API should be updated to also return the SubsectionVersion so we can
279
+ see the subsection title and any other metadata from that point in time.
280
+ TODO: accept a publish log UUID, not just int ID?
281
+ TODO: move the implementation to be a generic 'containers' implementation
282
+ that this subsections function merely wraps.
283
+ TODO: optimize, perhaps by having the publishlog store a record of all
284
+ ancestors of every modified PublishableEntity in the publish.
285
+ """
286
+ assert isinstance(subsection, Subsection)
287
+ subsection_pub_entity_version = publishing_api.get_published_version_as_of(
288
+ subsection.publishable_entity_id, publish_log_id
289
+ )
290
+ if subsection_pub_entity_version is None:
291
+ return None # This subsection was not published as of the given PublishLog ID.
292
+ container_version = subsection_pub_entity_version.containerversion
293
+
294
+ entity_list = []
295
+ rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
296
+ for row in rows:
297
+ if row.entity_version is not None:
298
+ unit_version = row.entity_version.containerversion.unitversion
299
+ assert isinstance(unit_version, UnitVersion)
300
+ entity_list.append(SubsectionListEntry(unit_version=unit_version, pinned=True))
301
+ else:
302
+ # Unpinned unit - figure out what its latest published version was.
303
+ # This is not optimized. It could be done in one query per subsection rather than one query per unit.
304
+ pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
305
+ if pub_entity_version:
306
+ entity_list.append(
307
+ SubsectionListEntry(unit_version=pub_entity_version.containerversion.unitversion, pinned=False)
308
+ )
309
+ return entity_list
@@ -0,0 +1,25 @@
1
+ """
2
+ Subsection Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class SubsectionsConfig(AppConfig):
9
+ """
10
+ Configuration for the subsections Django application.
11
+ """
12
+
13
+ name = "openedx_learning.apps.authoring.subsections"
14
+ verbose_name = "Learning Core > Authoring > Subsections"
15
+ default_auto_field = "django.db.models.BigAutoField"
16
+ label = "oel_subsections"
17
+
18
+ def ready(self):
19
+ """
20
+ Register Subsection and SubsectionVersion.
21
+ """
22
+ from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel
23
+ from .models import Subsection, SubsectionVersion # pylint: disable=import-outside-toplevel
24
+
25
+ register_content_models(Subsection, SubsectionVersion)
@@ -0,0 +1,36 @@
1
+ # Generated by Django 4.2.19 on 2025-04-09 12:59
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('oel_publishing', '0005_alter_entitylistrow_options'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='Subsection',
18
+ fields=[
19
+ ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')),
20
+ ],
21
+ options={
22
+ 'abstract': False,
23
+ },
24
+ bases=('oel_publishing.container',),
25
+ ),
26
+ migrations.CreateModel(
27
+ name='SubsectionVersion',
28
+ fields=[
29
+ ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.containerversion')),
30
+ ],
31
+ options={
32
+ 'abstract': False,
33
+ },
34
+ bases=('oel_publishing.containerversion',),
35
+ ),
36
+ ]
@@ -0,0 +1,50 @@
1
+ """
2
+ Models that implement subsections
3
+ """
4
+ from django.db import models
5
+
6
+ from ..publishing.models import Container, ContainerVersion
7
+
8
+ __all__ = [
9
+ "Subsection",
10
+ "SubsectionVersion",
11
+ ]
12
+
13
+
14
+ class Subsection(Container):
15
+ """
16
+ A Subsection is type of Container that holds Units.
17
+
18
+ Via Container and its PublishableEntityMixin, Subsections are also publishable
19
+ entities and can be added to other containers.
20
+ """
21
+ container = models.OneToOneField(
22
+ Container,
23
+ on_delete=models.CASCADE,
24
+ parent_link=True,
25
+ primary_key=True,
26
+ )
27
+
28
+
29
+ class SubsectionVersion(ContainerVersion):
30
+ """
31
+ A SubsectionVersion is a specific version of a Subsection.
32
+
33
+ Via ContainerVersion and its EntityList, it defines the list of Units
34
+ in this version of the Subsection.
35
+ """
36
+ container_version = models.OneToOneField(
37
+ ContainerVersion,
38
+ on_delete=models.CASCADE,
39
+ parent_link=True,
40
+ primary_key=True,
41
+ )
42
+
43
+ @property
44
+ def subsection(self):
45
+ """ Convenience accessor to the Subsection this version is associated with """
46
+ return self.container_version.container.subsection # pylint: disable=no-member
47
+
48
+ # Note: the 'publishable_entity_version' field is inherited and will appear on this model, but does not exist
49
+ # in the underlying database table. It only exists in the ContainerVersion table.
50
+ # You can verify this by running 'python manage.py sqlmigrate oel_subsections 0001_initial'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openedx-learning
3
- Version: 0.23.1
3
+ Version: 0.25.0
4
4
  Summary: Open edX Learning Core and Tagging.
5
5
  Home-page: https://github.com/openedx/openedx-learning
6
6
  Author: David Ormsbee
@@ -20,11 +20,11 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Requires-Python: >=3.11
21
21
  License-File: LICENSE.txt
22
22
  Requires-Dist: attrs
23
- Requires-Dist: Django
24
23
  Requires-Dist: edx-drf-extensions
24
+ Requires-Dist: djangorestframework<4.0
25
25
  Requires-Dist: celery
26
+ Requires-Dist: Django
26
27
  Requires-Dist: rules<4.0
27
- Requires-Dist: djangorestframework<4.0
28
28
  Dynamic: author
29
29
  Dynamic: author-email
30
30
  Dynamic: classifier
@@ -1,8 +1,8 @@
1
- openedx_learning/__init__.py,sha256=U0-MtvTb7-MpDx0zi9AhepGePH3oI3FKWsiFGVLf9j8,69
1
+ openedx_learning/__init__.py,sha256=lNlJOtMWHuLa4KYYQnRoBVXd6zGNsGJtRfTQzq3XeD8,69
2
2
  openedx_learning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  openedx_learning/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- openedx_learning/api/authoring.py,sha256=AuEegFTT3tAer_3zSxcxqLq3Yd_eilk-Hxt4ESYearA,970
5
- openedx_learning/api/authoring_models.py,sha256=oXwqeMAnXBWgVIUcJa0he5QjHvG296AJE-1hDpjd8JM,681
4
+ openedx_learning/api/authoring.py,sha256=1sh-hUH3pJLVIQHzzjlqWb_9uxf9y3-hanLpU4mRvXc,1061
5
+ openedx_learning/api/authoring_models.py,sha256=lA500-C7LBlVzeaEVqmCiQMAPPmeoDMi8wS0TbzgdGw,778
6
6
  openedx_learning/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  openedx_learning/apps/authoring/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  openedx_learning/apps/authoring/collections/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -37,7 +37,7 @@ openedx_learning/apps/authoring/contents/migrations/0001_initial.py,sha256=FtOTm
37
37
  openedx_learning/apps/authoring/contents/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
38
  openedx_learning/apps/authoring/publishing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
39
  openedx_learning/apps/authoring/publishing/admin.py,sha256=sLmtttaYIQi8XWw_wOdDD9xbF34g1giMEj5yIN-b-4M,7012
40
- openedx_learning/apps/authoring/publishing/api.py,sha256=14JojKz57Vl_16JwWcDSOM9JzZm6h0sxAmldmb3SVeE,55712
40
+ openedx_learning/apps/authoring/publishing/api.py,sha256=tGFZGI6cod2ymabjLXn3RqfjzUwb95ioSOm77LD8vcA,55246
41
41
  openedx_learning/apps/authoring/publishing/apps.py,sha256=v9PTe3YoICaYT9wfu268ZkVAlnZFvxi-DqYdbRi25bY,750
42
42
  openedx_learning/apps/authoring/publishing/contextmanagers.py,sha256=AH5zhr0Tz_gUG9--dfr_oZAu8DMy94n6mnOJuPbWkeU,6723
43
43
  openedx_learning/apps/authoring/publishing/migrations/0001_initial.py,sha256=wvekNV19YRSdxRmQaFnLSn_nCsQlHIucPDVMmgKf_OE,9272
@@ -47,7 +47,7 @@ openedx_learning/apps/authoring/publishing/migrations/0004_publishableentity_can
47
47
  openedx_learning/apps/authoring/publishing/migrations/0005_alter_entitylistrow_options.py,sha256=Pp4O-VSI75n3UTZZW6CU4TOVIVc5lZvUg5YfMvZyrYM,377
48
48
  openedx_learning/apps/authoring/publishing/migrations/0006_draftchangelog.py,sha256=TdioE59_fXCf4ykWhXJfEjyxdDmqbbbLCwLeT1C9irA,3497
49
49
  openedx_learning/apps/authoring/publishing/migrations/0007_bootstrap_draftchangelog.py,sha256=EoFFSv52CeiuMGf3KmBvr34sx9N-tv8afdXWVR8wPFA,4154
50
- openedx_learning/apps/authoring/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py,sha256=5Xm5EV3HvnguLC2UbMTyt_I3q9iI4ZBH0YoNEpNipFI,1207
50
+ openedx_learning/apps/authoring/publishing/migrations/0008_alter_draftchangelogrecord_options_and_more.py,sha256=m_UjGSYcdYBRIMAlxwXEdO4kVgQOQyr1VjufbssldhI,1206
51
51
  openedx_learning/apps/authoring/publishing/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  openedx_learning/apps/authoring/publishing/models/__init__.py,sha256=ZhpKo4F30VJatp1F-jQSJnKw8AeHFig9E9SlGnxxBCc,1108
53
53
  openedx_learning/apps/authoring/publishing/models/container.py,sha256=GCUD3WTlgvgZSQOKcoOsfhNAfc5pz3Wbs9ClE9mhtB0,2594
@@ -56,6 +56,18 @@ openedx_learning/apps/authoring/publishing/models/entity_list.py,sha256=8MyJqDdC
56
56
  openedx_learning/apps/authoring/publishing/models/learning_package.py,sha256=1fuNLHD6k0qGuL0jXYGf4-TA5WczgxJrXUdAIM_JNBI,2688
57
57
  openedx_learning/apps/authoring/publishing/models/publish_log.py,sha256=QD7Fb00yUMWM4HHae_m60JW-TDX8upbQdWCEIdhB0yE,5615
58
58
  openedx_learning/apps/authoring/publishing/models/publishable_entity.py,sha256=-6Fde_sYqaq1AUC4sw4Rf1X9Hh4jun2Py7Xhsl4VWuY,25419
59
+ openedx_learning/apps/authoring/sections/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
+ openedx_learning/apps/authoring/sections/api.py,sha256=cwsxfHZa6fY_GyC4gSvSRAjayNQWKGY9DWLAyJ_u8Oc,10357
61
+ openedx_learning/apps/authoring/sections/apps.py,sha256=iamzAiVlAWXGlZExP_bKSn38u4mUF8H0-Pa7eFtAkro,743
62
+ openedx_learning/apps/authoring/sections/models.py,sha256=2GK-dDMJwNRw_9gNFho8iKcDV-iYz_zBzqGMDmQ_jbc,1450
63
+ openedx_learning/apps/authoring/sections/migrations/0001_initial.py,sha256=iW5AFhC26mfZNWEVNu8cTsr32Ca4htL4CUanHoXfaeY,1152
64
+ openedx_learning/apps/authoring/sections/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
+ openedx_learning/apps/authoring/subsections/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
+ openedx_learning/apps/authoring/subsections/api.py,sha256=Cgjq3Y6ZZTzIQXMX6p7Y4jhFwIBFwKhSY_HCTeziMGc,10260
67
+ openedx_learning/apps/authoring/subsections/apps.py,sha256=WueCaPOE-7x3cu-6rA9FdeKzipCZSNIhvqpAbxTysOg,773
68
+ openedx_learning/apps/authoring/subsections/models.py,sha256=1uhdpS9Eg6keSqkzQaE8-XSVLAQlmi0llIIU2V7Nl44,1492
69
+ openedx_learning/apps/authoring/subsections/migrations/0001_initial.py,sha256=7kEHIC-EwG2KvlW4hg5tnl45--dW4Yv5gqV5SDqNYNo,1158
70
+ openedx_learning/apps/authoring/subsections/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
71
  openedx_learning/apps/authoring/units/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
72
  openedx_learning/apps/authoring/units/api.py,sha256=KJA7Bh7IBE22E6cMSZNp6pamjjA1Vf8EhHw0rvQtlPM,9797
61
73
  openedx_learning/apps/authoring/units/apps.py,sha256=cIzphjDw5sjIZ3NLE911N7IMUa8JQSXMReNl03uI7jg,701
@@ -75,7 +87,7 @@ openedx_learning/lib/fields.py,sha256=eiGoXMPhRuq25EH2qf6BAODshAQE3DBVdIYAMIUAXW
75
87
  openedx_learning/lib/managers.py,sha256=-Q3gxalSqyPZ9Im4DTROW5tF8wVTZLlmfTe62_xmowY,1643
76
88
  openedx_learning/lib/test_utils.py,sha256=g3KLuepIZbaDBCsaj9711YuqyUx7LD4gXDcfNC-mWdc,527
77
89
  openedx_learning/lib/validators.py,sha256=iqEdEAvFV2tC7Ecssx69kjecpdU8nE87AlDJYrqrsnc,404
78
- openedx_learning-0.23.1.dist-info/licenses/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
90
+ openedx_learning-0.25.0.dist-info/licenses/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
79
91
  openedx_tagging/__init__.py,sha256=V9N8M7f9LYlAbA_DdPUsHzTnWjYRXKGa5qHw9P1JnNI,30
80
92
  openedx_tagging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
93
  openedx_tagging/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -131,7 +143,7 @@ openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=0HQD_Jrf6-YpocYfz
131
143
  openedx_tagging/core/tagging/rest_api/v1/urls.py,sha256=dNUKCtUCx_YzrwlbEbpDfjGVQbb2QdJ1VuJCkladj6E,752
132
144
  openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=Hf92cy-tE767DE9FgsZcPKiCYrf5ihfETz8qGKBnuiU,36278
133
145
  openedx_tagging/core/tagging/rest_api/v1/views_import.py,sha256=kbHUPe5A6WaaJ3J1lFIcYCt876ecLNQfd19m7YYub6c,1470
134
- openedx_learning-0.23.1.dist-info/METADATA,sha256=4BwlvkX7Uk-Nxt1yBCzarwd79d6GeATiexL4SOjyfG0,9032
135
- openedx_learning-0.23.1.dist-info/WHEEL,sha256=MAQBAzGbXNI3bUmkDsiV_duv8i-gcdnLzw7cfUFwqhU,109
136
- openedx_learning-0.23.1.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
137
- openedx_learning-0.23.1.dist-info/RECORD,,
146
+ openedx_learning-0.25.0.dist-info/METADATA,sha256=rmB371B58VVRJ5nrps1VFxqAdI8uMm0oMvpaAi5Uj2g,9032
147
+ openedx_learning-0.25.0.dist-info/WHEEL,sha256=Td9E1opt19FSuwsk_gcDwtsGPmyXw7uz9xQf-y2gvl8,109
148
+ openedx_learning-0.25.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
149
+ openedx_learning-0.25.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any