openedx-learning 0.14.0__py2.py3-none-any.whl → 0.16.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.
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
- __version__ = "0.14.0"
4
+ __version__ = "0.16.0"
@@ -12,16 +12,18 @@ are stored in this app.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- from datetime import datetime
15
+ from datetime import datetime, timezone
16
16
  from enum import StrEnum, auto
17
17
  from logging import getLogger
18
18
  from pathlib import Path
19
19
  from uuid import UUID
20
20
 
21
+ from django.core.exceptions import ValidationError
21
22
  from django.db.models import Q, QuerySet
22
23
  from django.db.transaction import atomic
23
24
  from django.http.response import HttpResponse, HttpResponseNotFound
24
25
 
26
+ from ..collections.models import Collection, CollectionPublishableEntity
25
27
  from ..contents import api as contents_api
26
28
  from ..publishing import api as publishing_api
27
29
  from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
@@ -48,6 +50,7 @@ __all__ = [
48
50
  "look_up_component_version_content",
49
51
  "AssetError",
50
52
  "get_redirect_response_for_component_asset",
53
+ "set_collections",
51
54
  ]
52
55
 
53
56
 
@@ -603,3 +606,54 @@ def get_redirect_response_for_component_asset(
603
606
  )
604
607
 
605
608
  return HttpResponse(headers={**info_headers, **redirect_headers})
609
+
610
+
611
+ def set_collections(
612
+ learning_package_id: int,
613
+ component: Component,
614
+ collection_qset: QuerySet[Collection],
615
+ created_by: int | None = None,
616
+ ) -> set[Collection]:
617
+ """
618
+ Set collections for a given component.
619
+
620
+ These Collections must belong to the same LearningPackage as the Component, or a ValidationError will be raised.
621
+
622
+ Modified date of all collections related to component is updated.
623
+
624
+ Returns the updated collections.
625
+ """
626
+ # Disallow adding entities outside the collection's learning package
627
+ invalid_collection = collection_qset.exclude(learning_package_id=learning_package_id).first()
628
+ if invalid_collection:
629
+ raise ValidationError(
630
+ f"Cannot add collection {invalid_collection.pk} in learning package "
631
+ f"{invalid_collection.learning_package_id} to component {component} in "
632
+ f"learning package {learning_package_id}."
633
+ )
634
+ current_relations = CollectionPublishableEntity.objects.filter(
635
+ entity=component.publishable_entity
636
+ ).select_related('collection')
637
+ # Clear other collections for given component and add only new collections from collection_qset
638
+ removed_collections = set(
639
+ r.collection for r in current_relations.exclude(collection__in=collection_qset)
640
+ )
641
+ new_collections = set(collection_qset.exclude(
642
+ id__in=current_relations.values_list('collection', flat=True)
643
+ ))
644
+ # Use `remove` instead of `CollectionPublishableEntity.delete()` to trigger m2m_changed signal which will handle
645
+ # updating component index.
646
+ component.publishable_entity.collections.remove(*removed_collections)
647
+ component.publishable_entity.collections.add(
648
+ *new_collections,
649
+ through_defaults={"created_by_id": created_by},
650
+ )
651
+ # Update modified date via update to avoid triggering post_save signal for collections
652
+ # The signal triggers index update for each collection synchronously which will be very slow in this case.
653
+ # Instead trigger the index update in the caller function asynchronously.
654
+ affected_collection = removed_collections | new_collections
655
+ Collection.objects.filter(
656
+ id__in=[collection.id for collection in affected_collection]
657
+ ).update(modified=datetime.now(tz=timezone.utc))
658
+
659
+ return affected_collection
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: openedx-learning
3
- Version: 0.14.0
3
+ Version: 0.16.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
@@ -18,11 +18,11 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Python: >=3.11
20
20
  License-File: LICENSE.txt
21
- Requires-Dist: attrs
22
21
  Requires-Dist: celery
23
22
  Requires-Dist: djangorestframework<4.0
24
- Requires-Dist: rules<4.0
23
+ Requires-Dist: attrs
25
24
  Requires-Dist: Django<5.0
25
+ Requires-Dist: rules<4.0
26
26
  Requires-Dist: edx-drf-extensions
27
27
 
28
28
  Open edX Learning Core (and Tagging)
@@ -1,4 +1,4 @@
1
- openedx_learning/__init__.py,sha256=SLzxSo0bKJgEYEmz17a3mAeKVzFkuo0Z1DWkbFaBRxc,68
1
+ openedx_learning/__init__.py,sha256=GHiA1alNbJesOg1QdBMkyg2GfyNlz1kwiYuSV7HUxcY,68
2
2
  openedx_learning/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  openedx_learning/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  openedx_learning/api/authoring.py,sha256=vbRpiQ2wOfN3oR2bbN0-bI2ra0QRtha9tVixKW1ENis,929
@@ -18,7 +18,7 @@ openedx_learning/apps/authoring/collections/migrations/0005_alter_collection_opt
18
18
  openedx_learning/apps/authoring/collections/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  openedx_learning/apps/authoring/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  openedx_learning/apps/authoring/components/admin.py,sha256=3kFu_PR0BFb8U0zVv3WWhi27i__TDuJG0pFlwr3tKAw,4614
21
- openedx_learning/apps/authoring/components/api.py,sha256=pm5kM3YG6-o6BK_UQvR55NLKfGV6OdawVML_9fbJJ7o,23285
21
+ openedx_learning/apps/authoring/components/api.py,sha256=TCLFCPb7ScoRWhe7YpQ7hBiJGjoa47OmazQWcN2hIwU,25759
22
22
  openedx_learning/apps/authoring/components/apps.py,sha256=YoYPsI9gcleA3uEs8CiLIrjUncRMo2DKbYt4mDfzePg,770
23
23
  openedx_learning/apps/authoring/components/models.py,sha256=T-wc7vxaWMlulQmMsVH7m6Pd857P3Eguo0vtflTLURI,13415
24
24
  openedx_learning/apps/authoring/components/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -61,7 +61,7 @@ openedx_tagging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
61
  openedx_tagging/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
62
  openedx_tagging/core/tagging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
63
  openedx_tagging/core/tagging/admin.py,sha256=Ngc2l9Mf6gkzmqu7aOwq-d0mgV8szx0GzSeuWFX7Kyg,1080
64
- openedx_tagging/core/tagging/api.py,sha256=FLGXU16GRdvYE6-A2uH5MYHkSXwu37TGiIm9XqsyUR8,18795
64
+ openedx_tagging/core/tagging/api.py,sha256=HsdCmr1xv54fk6ggPuib3xKOwx5yJFbD3ZL4Lk5-d1o,19815
65
65
  openedx_tagging/core/tagging/apps.py,sha256=-gp0VYqX4XQzwjjd-G68Ev2Op0INLh9Byz5UOqF5_7k,345
66
66
  openedx_tagging/core/tagging/data.py,sha256=421EvmDzdM7H523dBVQk4J0W_UwTT4U5syqPRXUYK4g,1353
67
67
  openedx_tagging/core/tagging/rules.py,sha256=Gzw2RCQxoAv2PpOwOWgpD17XoZfowlFnNgQqYn59q_g,6715
@@ -94,9 +94,10 @@ openedx_tagging/core/tagging/migrations/0014_minor_fixes.py,sha256=46_F-el1UylSR
94
94
  openedx_tagging/core/tagging/migrations/0015_taxonomy_export_id.py,sha256=jhS-T8o2mwu61E7hPbjjE_6MPLKRPQFAVu7pJHZNRz4,1454
95
95
  openedx_tagging/core/tagging/migrations/0016_object_tag_export_id.py,sha256=X62sThGNv_0_wCBhWPcYRcs90EJwz9km9Nszr9yMVkM,2371
96
96
  openedx_tagging/core/tagging/migrations/0017_alter_tagimporttask_status.py,sha256=ljxONZOAGRW-tvPUIFmc0cNXN4Hoo3GJiyzGMncN6LI,567
97
+ openedx_tagging/core/tagging/migrations/0018_objecttag_is_copied.py,sha256=zmr4b65T0vX6fYc8MpvSmQnYkAiNMpx3RKEd5tudsl8,517
97
98
  openedx_tagging/core/tagging/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
99
  openedx_tagging/core/tagging/models/__init__.py,sha256=yYdOnthuc7EUdfEULtZgqRwn5Y4bbYQmJCjVZqR5GTM,236
99
- openedx_tagging/core/tagging/models/base.py,sha256=1WAPxssL8thAg8LHh1GbwSo98H81-nVdTVQt1nC1ZdU,39335
100
+ openedx_tagging/core/tagging/models/base.py,sha256=RX6rZcG7njrgLvM4huh79lC1vXzFDLxqElJqOU50Od8,39606
100
101
  openedx_tagging/core/tagging/models/import_export.py,sha256=Aj0pleh0nh2LNS6zmdB1P4bpdgUMmvmobTkqBerORAI,4570
101
102
  openedx_tagging/core/tagging/models/system_defined.py,sha256=_6LfvUZGEltvQMtm2OXy6TOLh3C8GnVTqtZDSAZW6K4,9062
102
103
  openedx_tagging/core/tagging/models/utils.py,sha256=-A3Dj24twmTf65UB7G4WLvb_9qEvduEPIwahZ-FJDlg,1926
@@ -106,12 +107,12 @@ openedx_tagging/core/tagging/rest_api/urls.py,sha256=egXaRQv1EAgF04ThgVZBQuvLK1L
106
107
  openedx_tagging/core/tagging/rest_api/utils.py,sha256=XZXixZ44vpNlxiyFplW8Lktyh_m1EfR3Y-tnyvA7acc,3620
107
108
  openedx_tagging/core/tagging/rest_api/v1/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
108
109
  openedx_tagging/core/tagging/rest_api/v1/permissions.py,sha256=7HPE_NuKku_ISnkeE_HsFNXVYt0IbVkJN6M4wqwHGHU,2443
109
- openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=gbvEBLvsmfPc3swWz-_TEK8YpXiOa4oBXsU89_5iXiE,13749
110
+ openedx_tagging/core/tagging/rest_api/v1/serializers.py,sha256=D7brBbgmU7MnbU7Ln_xiLG-TtXno0aciE5AhjsXlClE,13903
110
111
  openedx_tagging/core/tagging/rest_api/v1/urls.py,sha256=dNUKCtUCx_YzrwlbEbpDfjGVQbb2QdJ1VuJCkladj6E,752
111
- openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=ZRkSILdb8g5k_BcuuVVfdffEdY9vFQ_YtMa3JrN0Xz8,35581
112
+ openedx_tagging/core/tagging/rest_api/v1/views.py,sha256=LA0EF7-p91JgVrLZpZaG474elKD1dswODGvoPIw47Mg,35837
112
113
  openedx_tagging/core/tagging/rest_api/v1/views_import.py,sha256=kbHUPe5A6WaaJ3J1lFIcYCt876ecLNQfd19m7YYub6c,1470
113
- openedx_learning-0.14.0.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
114
- openedx_learning-0.14.0.dist-info/METADATA,sha256=lhKFt6PNk29mBmXqRSrTIl8cFuONTNjSHjs7d0xQHKo,8777
115
- openedx_learning-0.14.0.dist-info/WHEEL,sha256=AHX6tWk3qWuce7vKLrj7lnulVHEdWoltgauo8bgCXgU,109
116
- openedx_learning-0.14.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
117
- openedx_learning-0.14.0.dist-info/RECORD,,
114
+ openedx_learning-0.16.0.dist-info/LICENSE.txt,sha256=QTW2QN7q3XszgUAXm9Dzgtu5LXYKbR1SGnqMa7ufEuY,35139
115
+ openedx_learning-0.16.0.dist-info/METADATA,sha256=KFY2MnwnFFqI4lWfz9rVFuvcSvhhLWr9L26T-RIJRtI,8777
116
+ openedx_learning-0.16.0.dist-info/WHEEL,sha256=AHX6tWk3qWuce7vKLrj7lnulVHEdWoltgauo8bgCXgU,109
117
+ openedx_learning-0.16.0.dist-info/top_level.txt,sha256=IYFbr5mgiEHd-LOtZmXj3q3a0bkGK1M9LY7GXgnfi4M,33
118
+ openedx_learning-0.16.0.dist-info/RECORD,,
@@ -484,3 +484,35 @@ def delete_tags_from_taxonomy(
484
484
  """
485
485
  taxonomy = taxonomy.cast()
486
486
  taxonomy.delete_tags(tags, with_subtags)
487
+
488
+
489
+ def copy_tags(source_object_id: str, dest_object_id: str):
490
+ """
491
+ Copy all tags from one object to another.
492
+
493
+ This keeps all not-copied tags and delete all
494
+ previous copied tags of the dest object.
495
+ If there are not-copied tags that also are in 'source_object_id',
496
+ then they become copied.
497
+ """
498
+ source_object_tags = get_object_tags(
499
+ source_object_id,
500
+ )
501
+ copied_tags = ObjectTag.objects.filter(
502
+ object_id=dest_object_id,
503
+ is_copied=True,
504
+ )
505
+
506
+ with transaction.atomic():
507
+ # Delete all copied tags of destination
508
+ copied_tags.delete()
509
+
510
+ # Copy an create object_tags in destination
511
+ for object_tag in source_object_tags:
512
+ ObjectTag.objects.update_or_create(
513
+ object_id=dest_object_id,
514
+ taxonomy_id=object_tag.taxonomy_id,
515
+ tag_id=object_tag.tag_id,
516
+ defaults={"is_copied": True},
517
+ # Note: _value and _export_id are set automatically
518
+ )
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.16 on 2024-10-04 19:21
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('oel_tagging', '0017_alter_tagimporttask_status'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='objecttag',
15
+ name='is_copied',
16
+ field=models.BooleanField(default=False, help_text="True if this object tag has been copied from one object to another using 'copy_tags' api function"),
17
+ ),
18
+ ]
@@ -795,6 +795,13 @@ class ObjectTag(models.Model):
795
795
  "Tag associated with this object tag. Provides the tag's 'value' if set."
796
796
  ),
797
797
  )
798
+ is_copied = models.BooleanField(
799
+ default=False,
800
+ help_text=_(
801
+ "True if this object tag has been copied from one object to another"
802
+ " using 'copy_tags' api function"
803
+ ),
804
+ )
798
805
  _export_id = case_insensitive_char_field(
799
806
  max_length=255,
800
807
  help_text=_(
@@ -981,6 +988,7 @@ class ObjectTag(models.Model):
981
988
  self.tag = object_tag.tag
982
989
  self.taxonomy = object_tag.taxonomy
983
990
  self.object_id = object_tag.object_id
991
+ self.is_copied = object_tag.is_copied
984
992
  self._value = object_tag._value # pylint: disable=protected-access
985
993
  self._export_id = object_tag._export_id # pylint: disable=protected-access
986
994
  return self
@@ -176,6 +176,9 @@ class ObjectTagsByTaxonomySerializer(UserPermissionsSerializerMixin, serializers
176
176
  """
177
177
  Convert this list of ObjectTags to the serialized dictionary, grouped by Taxonomy
178
178
  """
179
+ # Allows consumers like edx-platform to override this
180
+ ObjectTagViewMinimalSerializer = self.context["view"].minimal_serializer_class
181
+
179
182
  can_tag_object_perm = f"{self.app_label}.can_tag_object"
180
183
  by_object: dict[str, dict[str, Any]] = {}
181
184
  for obj_tag in instance:
@@ -194,7 +197,7 @@ class ObjectTagsByTaxonomySerializer(UserPermissionsSerializerMixin, serializers
194
197
  "export_id": obj_tag.export_id,
195
198
  }
196
199
  taxonomies.append(tax_entry)
197
- tax_entry["tags"].append(ObjectTagMinimalSerializer(obj_tag, context=self.context).data)
200
+ tax_entry["tags"].append(ObjectTagViewMinimalSerializer(obj_tag, context=self.context).data)
198
201
  return by_object
199
202
 
200
203
 
@@ -37,6 +37,7 @@ from ..utils import view_auth_classes
37
37
  from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions, TaxonomyTagsObjectPermissions
38
38
  from .serializers import (
39
39
  ObjectTagListQueryParamsSerializer,
40
+ ObjectTagMinimalSerializer,
40
41
  ObjectTagsByTaxonomySerializer,
41
42
  ObjectTagSerializer,
42
43
  ObjectTagUpdateBodySerializer,
@@ -443,7 +444,10 @@ class ObjectTagView(
443
444
  * 405 - Method not allowed
444
445
  """
445
446
 
447
+ # Serializer used in `get_queryset` when getting tags per taxonomy
446
448
  serializer_class = ObjectTagSerializer
449
+ # Serializer used in the result in `to_representation` in `ObjectTagsByTaxonomySerializer`
450
+ minimal_serializer_class = ObjectTagMinimalSerializer
447
451
  permission_classes = [ObjectTagObjectPermissions]
448
452
  lookup_field = "object_id"
449
453