diff --git a/cms/envs/common.py b/cms/envs/common.py index 9f438847b024..5b8a88bde7f6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1748,6 +1748,10 @@ # API Documentation 'drf_yasg', + # Tagging + 'openedx_tagging.core.tagging.apps.TaggingConfig', + 'openedx.features.content_tagging', + 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', 'openedx.features.discounts', diff --git a/lms/envs/common.py b/lms/envs/common.py index 21353c97d010..54417eebe7cf 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3198,6 +3198,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Course Goals 'lms.djangoapps.course_goals.apps.CourseGoalsConfig', + # Tagging + 'openedx_tagging.core.tagging.apps.TaggingConfig', + 'openedx.features.content_tagging', + # Features 'openedx.features.calendar_sync', 'openedx.features.course_bookmarks', diff --git a/openedx/features/content_tagging/__init__.py b/openedx/features/content_tagging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/content_tagging/admin.py b/openedx/features/content_tagging/admin.py new file mode 100644 index 000000000000..a9fa146ce8bd --- /dev/null +++ b/openedx/features/content_tagging/admin.py @@ -0,0 +1,6 @@ +""" Tagging app admin """ +from django.contrib import admin + +from .models import TaxonomyOrg + +admin.site.register(TaxonomyOrg) diff --git a/openedx/features/content_tagging/api.py b/openedx/features/content_tagging/api.py new file mode 100644 index 000000000000..f1bb43f66ce9 --- /dev/null +++ b/openedx/features/content_tagging/api.py @@ -0,0 +1,157 @@ +""" +Content Tagging APIs +""" +from typing import Iterator, List, Type, Union + +import openedx_tagging.core.tagging.api as oel_tagging +from django.db.models import QuerySet +from opaque_keys.edx.keys import LearningContextKey +from opaque_keys.edx.locator import BlockUsageLocator +from openedx_tagging.core.tagging.models import Taxonomy +from organizations.models import Organization + +from .models import ContentObjectTag, ContentTaxonomy, TaxonomyOrg + + +def create_taxonomy( + name: str, + description: str = None, + enabled=True, + required=False, + allow_multiple=False, + allow_free_text=False, + taxonomy_class: Type = ContentTaxonomy, +) -> Taxonomy: + """ + Creates, saves, and returns a new Taxonomy with the given attributes. + + If `taxonomy_class` not provided, then uses ContentTaxonomy. + """ + return oel_tagging.create_taxonomy( + name=name, + description=description, + enabled=enabled, + required=required, + allow_multiple=allow_multiple, + allow_free_text=allow_free_text, + taxonomy_class=taxonomy_class, + ) + + +def set_taxonomy_orgs( + taxonomy: Taxonomy, + all_orgs=False, + orgs: List[Organization] = None, + relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER, +): + """ + Updates the list of orgs associated with the given taxonomy. + + Currently, we only have an "owner" relationship, but there may be other types added in future. + + When an org has an "owner" relationship with a taxonomy, that taxonomy is available for use by content in that org, + mies + + If `all_orgs`, then the taxonomy is associated with all organizations, and the `orgs` parameter is ignored. + + If not `all_orgs`, the taxonomy is associated with each org in the `orgs` list. If that list is empty, the + taxonomy is not associated with any orgs. + """ + TaxonomyOrg.objects.filter( + taxonomy=taxonomy, + rel_type=relationship, + ).delete() + + # org=None means the relationship is with "all orgs" + if all_orgs: + orgs = [None] + if orgs: + TaxonomyOrg.objects.bulk_create( + [ + TaxonomyOrg( + taxonomy=taxonomy, + org=org, + rel_type=relationship, + ) + for org in orgs + ] + ) + + +def get_taxonomies_for_org( + enabled=True, + org_owner: Organization = None, +) -> QuerySet: + """ + Generates a list of the enabled Taxonomies available for the given org, sorted by name. + + We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases. + So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use. + + If no `org` is provided, then only Taxonomies which are available for _all_ Organizations are returned. + + If you want the disabled Taxonomies, pass enabled=False. + If you want all Taxonomies (both enabled and disabled), pass enabled=None. + """ + taxonomies = oel_tagging.get_taxonomies(enabled=enabled) + return ContentTaxonomy.taxonomies_for_org( + org=org_owner, + queryset=taxonomies, + ) + + +def get_content_tags( + object_id: str, taxonomy: Taxonomy = None, valid_only=True +) -> Iterator[ContentObjectTag]: + """ + Generates a list of content tags for a given object. + + Pass taxonomy to limit the returned object_tags to a specific taxonomy. + + Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too. + Invalid tags will (probably) be hidden from learners. + """ + for object_tag in oel_tagging.get_object_tags( + object_id=object_id, + taxonomy=taxonomy, + valid_only=valid_only, + ): + yield ContentObjectTag.cast(object_tag) + + +def tag_content_object( + taxonomy: Taxonomy, + tags: List, + object_id: Union[BlockUsageLocator, LearningContextKey], +) -> List[ContentObjectTag]: + """ + This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or + course). + + It works one "Taxonomy" at a time, i.e. one field at a time, so you can set call it with taxonomy=Keywords, + tags=["gravity", "newton"] to replace any "Keywords" [Taxonomy] tags on the given content object with "gravity" and + "newton". Doing so to change the "Keywords" Taxonomy won't affect other Taxonomy's tags (other fields) on the + object, such as "Language: [en]" or "Difficulty: [hard]". + + If it's a free-text taxonomy, then the list should be a list of tag values. + Otherwise, it should be a list of existing Tag IDs. + + Raises ValueError if the proposed tags are invalid for this taxonomy. + Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. + """ + content_tags = [] + for object_tag in oel_tagging.tag_object( + taxonomy=taxonomy, + tags=tags, + object_id=str(object_id), + ): + content_tags.append(ContentObjectTag.cast(object_tag)) + return content_tags + + +# Expose the oel_tagging APIs + +get_taxonomy = oel_tagging.get_taxonomy +get_taxonomies = oel_tagging.get_taxonomies +get_tags = oel_tagging.get_tags +resync_object_tags = oel_tagging.resync_object_tags diff --git a/openedx/features/content_tagging/apps.py b/openedx/features/content_tagging/apps.py new file mode 100644 index 000000000000..29f9c5005f43 --- /dev/null +++ b/openedx/features/content_tagging/apps.py @@ -0,0 +1,12 @@ +""" +Define the content tagging Django App. +""" + +from django.apps import AppConfig + + +class ContentTaggingConfig(AppConfig): + """App config for the content tagging feature""" + + default_auto_field = "django.db.models.BigAutoField" + name = "openedx.features.content_tagging" diff --git a/openedx/features/content_tagging/migrations/0001_initial.py b/openedx/features/content_tagging/migrations/0001_initial.py new file mode 100644 index 000000000000..7d74d7ab73a0 --- /dev/null +++ b/openedx/features/content_tagging/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 3.2.20 on 2023-07-25 06:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("oel_tagging", "0002_auto_20230718_2026"), + ("organizations", "0003_historicalorganizationcourse"), + ] + + operations = [ + migrations.CreateModel( + name="ContentObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.objecttag",), + ), + migrations.CreateModel( + name="ContentTaxonomy", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.taxonomy",), + ), + migrations.CreateModel( + name="TaxonomyOrg", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rel_type", + models.CharField( + choices=[("OWN", "owner")], default="OWN", max_length=3 + ), + ), + ( + "org", + models.ForeignKey( + default=None, + help_text="Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="organizations.organization", + ), + ), + ( + "taxonomy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="oel_tagging.taxonomy", + ), + ), + ], + ), + migrations.AddIndex( + model_name="taxonomyorg", + index=models.Index( + fields=["taxonomy", "rel_type"], name="content_tag_taxonom_b04dd1_idx" + ), + ), + migrations.AddIndex( + model_name="taxonomyorg", + index=models.Index( + fields=["taxonomy", "rel_type", "org"], + name="content_tag_taxonom_70d60b_idx", + ), + ), + ] diff --git a/openedx/features/content_tagging/migrations/__init__.py b/openedx/features/content_tagging/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/content_tagging/models.py b/openedx/features/content_tagging/models.py new file mode 100644 index 000000000000..a3b094a8e9ff --- /dev/null +++ b/openedx/features/content_tagging/models.py @@ -0,0 +1,166 @@ +""" +Content Tagging models +""" +from typing import List, Union + +from django.db import models +from django.db.models import Exists, OuterRef, Q, QuerySet +from django.utils.translation import gettext as _ +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import LearningContextKey +from opaque_keys.edx.locator import BlockUsageLocator +from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy +from organizations.models import Organization + + +class TaxonomyOrg(models.Model): + """ + Represents the many-to-many relationship between Taxonomies and Organizations. + + We keep this as a separate class from ContentTaxonomy so that class can remain a proxy for Taxonomy, keeping the + data models and usage simple. + """ + + class RelType(models.TextChoices): + OWNER = "OWN", _("owner") + + taxonomy = models.ForeignKey(Taxonomy, on_delete=models.CASCADE) + org = models.ForeignKey( + Organization, + null=True, + default=None, + on_delete=models.CASCADE, + help_text=_( + "Organization that is related to this taxonomy." + "If None, then this taxonomy is related to all organizations." + ), + ) + rel_type = models.CharField( + max_length=3, + choices=RelType.choices, + default=RelType.OWNER, + ) + + class Meta: + indexes = [ + models.Index(fields=["taxonomy", "rel_type"]), + models.Index(fields=["taxonomy", "rel_type", "org"]), + ] + + @classmethod + def get_relationships( + cls, taxonomy: Taxonomy, rel_type: RelType, org_short_name: str = None + ) -> QuerySet: + """ + Returns the relationships of the given rel_type and taxonomy where: + * the relationship is available for all organizations, OR + * (if provided) the relationship is available to the org with the given org_short_name + """ + # A relationship with org=None means all Organizations + org_filter = Q(org=None) + if org_short_name is not None: + org_filter |= Q(org__short_name=org_short_name) + return cls.objects.filter( + taxonomy=taxonomy, + rel_type=rel_type, + ).filter(org_filter) + + @classmethod + def get_organizations( + cls, taxonomy: Taxonomy, rel_type: RelType + ) -> List[Organization]: + """ + Returns the list of Organizations which have the given relationship to the taxonomy. + """ + rels = cls.objects.filter( + taxonomy=taxonomy, + rel_type=rel_type, + ) + # A relationship with org=None means all Organizations + if rels.filter(org=None).exists(): + return list(Organization.objects.all()) + return [rel.org for rel in rels] + + +class ContentObjectTag(ObjectTag): + """ + ObjectTag that requires an LearningContextKey or BlockUsageLocator as the object ID. + """ + + class Meta: + proxy = True + + @property + def object_key(self) -> Union[BlockUsageLocator, LearningContextKey]: + """ + Returns the object ID parsed as a UsageKey or LearningContextKey. + Raises InvalidKeyError object_id cannot be parse into one of those key types. + + Returns None if there's no object_id. + """ + try: + return LearningContextKey.from_string(str(self.object_id)) + except InvalidKeyError: + return BlockUsageLocator.from_string(str(self.object_id)) + + +class ContentTaxonomy(Taxonomy): + """ + Taxonomy which can only tag Content objects (e.g. XBlocks or Courses) via ContentObjectTag. + + Also ensures a valid TaxonomyOrg owner relationship with the content object. + """ + + class Meta: + proxy = True + + @classmethod + def taxonomies_for_org( + cls, + queryset: QuerySet, + org: Organization = None, + ) -> QuerySet: + """ + Filters the given QuerySet to those ContentTaxonomies which are available for the given organization. + + If no `org` is provided, then only ContentTaxonomies available to all organizations are returned. + If `org` is provided, then ContentTaxonomies available to this organizations are also returned. + """ + org_short_name = org.short_name if org else None + return queryset.filter( + Exists( + TaxonomyOrg.get_relationships( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + org_short_name=org_short_name, + ) + ) + ) + + def _check_object(self, object_tag: ObjectTag) -> bool: + """ + Returns True if this ObjectTag has a valid object_id. + """ + content_tag = ContentObjectTag.cast(object_tag) + try: + content_tag.object_key + except InvalidKeyError: + return False + return super()._check_object(content_tag) + + def _check_taxonomy(self, object_tag: ObjectTag) -> bool: + """ + Returns True if this taxonomy is owned by the tag's org. + """ + content_tag = ContentObjectTag.cast(object_tag) + try: + object_key = content_tag.object_key + except InvalidKeyError: + return False + if not TaxonomyOrg.get_relationships( + taxonomy=self, + rel_type=TaxonomyOrg.RelType.OWNER, + org_short_name=object_key.org, + ).exists(): + return False + return super()._check_taxonomy(content_tag) diff --git a/openedx/features/content_tagging/rules.py b/openedx/features/content_tagging/rules.py new file mode 100644 index 000000000000..9ab7072bf96e --- /dev/null +++ b/openedx/features/content_tagging/rules.py @@ -0,0 +1,106 @@ +"""Django rules-based permissions for tagging""" + +import openedx_tagging.core.tagging.rules as oel_tagging +import rules +from django.contrib.auth import get_user_model + +from common.djangoapps.student.auth import is_content_creator + +from .models import TaxonomyOrg + +User = get_user_model() + + +def is_taxonomy_admin(user: User, taxonomy: oel_tagging.Taxonomy = None) -> bool: + """ + Returns True if the given user is a Taxonomy Admin for the given content taxonomy. + + Global Taxonomy Admins include global staff and superusers, plus course creators who can create courses for any org. + Otherwise, a taxonomy must be provided to determine if the user is a org-level course creator for one of the + taxonomy's org owners. + """ + if oel_tagging.is_taxonomy_admin(user): + return True + + if not taxonomy: + return is_content_creator(user, None) + + taxonomy_orgs = TaxonomyOrg.get_organizations( + taxonomy=taxonomy, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + for org in taxonomy_orgs: + if is_content_creator(user, org.short_name): + return True + return False + + +@rules.predicate +def can_view_taxonomy(user: User, taxonomy: oel_tagging.Taxonomy = None) -> bool: + """ + Anyone can view an enabled taxonomy, + but only taxonomy admins can view a disabled taxonomy. + """ + if taxonomy: + taxonomy = taxonomy.cast() + return (taxonomy and taxonomy.enabled) or is_taxonomy_admin(user, taxonomy) + + +@rules.predicate +def can_change_taxonomy(user: User, taxonomy: oel_tagging.Taxonomy = None) -> bool: + """ + Even taxonomy admins cannot change system taxonomies. + """ + if taxonomy: + taxonomy = taxonomy.cast() + return is_taxonomy_admin(user, taxonomy) and ( + not taxonomy or (taxonomy and not taxonomy.system_defined) + ) + + +@rules.predicate +def can_change_taxonomy_tag(user: User, tag: oel_tagging.Tag = None) -> bool: + """ + Even taxonomy admins cannot add tags to system taxonomies (their tags are system-defined), or free-text taxonomies + (these don't have predefined tags). + """ + taxonomy = tag.taxonomy if tag else None + if taxonomy: + taxonomy = taxonomy.cast() + return is_taxonomy_admin(user, taxonomy) and ( + not tag + or not taxonomy + or (taxonomy and not taxonomy.allow_free_text and not taxonomy.system_defined) + ) + + +@rules.predicate +def can_change_object_tag(user: User, object_tag: oel_tagging.ObjectTag = None) -> bool: + """ + Taxonomy admins can create or modify object tags on enabled taxonomies. + """ + taxonomy = object_tag.taxonomy if object_tag else None + if taxonomy: + taxonomy = taxonomy.cast() + return is_taxonomy_admin(user, taxonomy) and ( + not object_tag or not taxonomy or (taxonomy and taxonomy.cast().enabled) + ) + + +# Taxonomy +rules.set_perm("oel_tagging.add_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.change_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.delete_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.view_taxonomy", can_view_taxonomy) + +# Tag +rules.set_perm("oel_tagging.add_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.change_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.delete_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.view_tag", rules.always_allow) + +# ObjectTag +rules.set_perm("oel_tagging.add_object_tag", can_change_object_tag) +rules.set_perm("oel_tagging.change_object_tag", can_change_object_tag) +rules.set_perm("oel_tagging.delete_object_tag", can_change_object_tag) +rules.set_perm("oel_tagging.view_object_tag", rules.always_allow) diff --git a/openedx/features/content_tagging/tests/__init__.py b/openedx/features/content_tagging/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/content_tagging/tests/test_api.py b/openedx/features/content_tagging/tests/test_api.py new file mode 100644 index 000000000000..f97958f9f60c --- /dev/null +++ b/openedx/features/content_tagging/tests/test_api.py @@ -0,0 +1,245 @@ +"""Tests for the Tagging models""" +import ddt +from django.test.testcases import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_tagging.core.tagging.models import ObjectTag, Tag +from organizations.models import Organization + +from .. import api + + +class TestTaxonomyMixin: + """ + Sets up data for testing Content Taxonomies. + """ + + def setUp(self): + super().setUp() + self.org1 = Organization.objects.create(name="OpenedX", short_name="OeX") + self.org2 = Organization.objects.create(name="Axim", short_name="Ax") + # Taxonomies + self.taxonomy_disabled = api.create_taxonomy( + name="Learning Objectives", + enabled=False, + ) + api.set_taxonomy_orgs(self.taxonomy_disabled, all_orgs=True) + self.taxonomy_all_orgs = api.create_taxonomy( + name="Content Types", + enabled=True, + ) + api.set_taxonomy_orgs(self.taxonomy_all_orgs, all_orgs=True) + self.taxonomy_both_orgs = api.create_taxonomy( + name="OpenedX/Axim Content Types", + enabled=True, + ) + api.set_taxonomy_orgs(self.taxonomy_both_orgs, orgs=[self.org1, self.org2]) + self.taxonomy_one_org = api.create_taxonomy( + name="OpenedX Content Types", + enabled=True, + ) + api.set_taxonomy_orgs(self.taxonomy_one_org, orgs=[self.org1]) + self.taxonomy_no_orgs = api.create_taxonomy( + name="No orgs", + enabled=True, + ) + # Tags + self.tag_disabled = Tag.objects.create( + taxonomy=self.taxonomy_disabled, + value="learning", + ) + self.tag_all_orgs = Tag.objects.create( + taxonomy=self.taxonomy_all_orgs, + value="learning", + ) + self.tag_both_orgs = Tag.objects.create( + taxonomy=self.taxonomy_both_orgs, + value="learning", + ) + self.tag_one_org = Tag.objects.create( + taxonomy=self.taxonomy_one_org, + value="learning", + ) + self.tag_no_orgs = Tag.objects.create( + taxonomy=self.taxonomy_no_orgs, + value="learning", + ) + # ObjectTags + self.all_orgs_course_tag = api.tag_content_object( + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.id], + object_id=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), + )[0] + self.all_orgs_block_tag = api.tag_content_object( + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.id], + object_id=UsageKey.from_string( + "block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde" + ), + )[0] + self.both_orgs_course_tag = api.tag_content_object( + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.id], + object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + )[0] + self.both_orgs_block_tag = api.tag_content_object( + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.id], + object_id=UsageKey.from_string( + "block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde" + ), + )[0] + self.one_org_block_tag = api.tag_content_object( + taxonomy=self.taxonomy_one_org, + tags=[self.tag_one_org.id], + object_id=UsageKey.from_string( + "block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde" + ), + )[0] + self.disabled_course_tag = api.tag_content_object( + taxonomy=self.taxonomy_disabled, + tags=[self.tag_disabled.id], + object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + )[0] + + # Invalid object tags must be manually created + self.all_orgs_invalid_tag = ObjectTag.objects.create( + taxonomy=self.taxonomy_all_orgs, + tag=self.tag_all_orgs, + object_id="course-v1_OpenedX_DemoX_Demo_Course", + ) + self.one_org_invalid_org_tag = ObjectTag.objects.create( + taxonomy=self.taxonomy_one_org, + tag=self.tag_one_org, + object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde", + ) + self.no_orgs_invalid_tag = ObjectTag.objects.create( + taxonomy=self.taxonomy_no_orgs, + tag=self.tag_no_orgs, + object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + ) + + +@ddt.ddt +class TestAPITaxonomy(TestTaxonomyMixin, TestCase): + """ + Tests the Content Taxonomy APIs. + """ + + def test_get_taxonomies_enabled_subclasses(self): + with self.assertNumQueries(1): + taxonomies = list(taxonomy.cast() for taxonomy in api.get_taxonomies()) + assert taxonomies == [ + self.taxonomy_all_orgs, + self.taxonomy_no_orgs, + self.taxonomy_one_org, + self.taxonomy_both_orgs, + ] + + @ddt.data( + # All orgs + (None, True, ["taxonomy_all_orgs"]), + (None, False, ["taxonomy_disabled"]), + (None, None, ["taxonomy_all_orgs", "taxonomy_disabled"]), + # Org 1 + ("org1", True, ["taxonomy_all_orgs", "taxonomy_one_org", "taxonomy_both_orgs"]), + ("org1", False, ["taxonomy_disabled"]), + ( + "org1", + None, + [ + "taxonomy_all_orgs", + "taxonomy_disabled", + "taxonomy_one_org", + "taxonomy_both_orgs", + ], + ), + # Org 2 + ("org2", True, ["taxonomy_all_orgs", "taxonomy_both_orgs"]), + ("org2", False, ["taxonomy_disabled"]), + ( + "org2", + None, + ["taxonomy_all_orgs", "taxonomy_disabled", "taxonomy_both_orgs"], + ), + ) + @ddt.unpack + def test_get_taxonomies_for_org(self, org_attr, enabled, expected): + org_owner = getattr(self, org_attr) if org_attr else None + taxonomies = list( + taxonomy.cast() + for taxonomy in api.get_taxonomies_for_org( + org_owner=org_owner, enabled=enabled + ) + ) + assert taxonomies == [ + getattr(self, taxonomy_attr) for taxonomy_attr in expected + ] + + @ddt.data( + ("taxonomy_all_orgs", "all_orgs_course_tag"), + ("taxonomy_all_orgs", "all_orgs_block_tag"), + ("taxonomy_both_orgs", "both_orgs_course_tag"), + ("taxonomy_both_orgs", "both_orgs_block_tag"), + ("taxonomy_one_org", "one_org_block_tag"), + ) + @ddt.unpack + def test_get_content_tags_valid_for_org( + self, + taxonomy_attr, + object_tag_attr, + ): + taxonomy_id = getattr(self, taxonomy_attr).id + taxonomy = api.get_taxonomy(taxonomy_id) + object_tag = getattr(self, object_tag_attr) + with self.assertNumQueries(1): + valid_tags = list( + api.get_content_tags( + taxonomy=taxonomy, + object_id=object_tag.object_id, + valid_only=True, + ) + ) + assert len(valid_tags) == 1 + assert valid_tags[0].id == object_tag.id + + @ddt.data( + ("taxonomy_disabled", "disabled_course_tag"), + ("taxonomy_all_orgs", "all_orgs_course_tag"), + ("taxonomy_all_orgs", "all_orgs_block_tag"), + ("taxonomy_all_orgs", "all_orgs_invalid_tag"), + ("taxonomy_both_orgs", "both_orgs_course_tag"), + ("taxonomy_both_orgs", "both_orgs_block_tag"), + ("taxonomy_one_org", "one_org_block_tag"), + ("taxonomy_one_org", "one_org_invalid_org_tag"), + ) + @ddt.unpack + def test_get_content_tags_include_invalid( + self, + taxonomy_attr, + object_tag_attr, + ): + taxonomy_id = getattr(self, taxonomy_attr).id + taxonomy = api.get_taxonomy(taxonomy_id) + object_tag = getattr(self, object_tag_attr) + with self.assertNumQueries(1): + valid_tags = list( + api.get_content_tags( + taxonomy=taxonomy, + object_id=object_tag.object_id, + valid_only=False, + ) + ) + assert len(valid_tags) == 1 + assert valid_tags[0].id == object_tag.id + + @ddt.data( + "all_orgs_invalid_tag", + "one_org_invalid_org_tag", + "no_orgs_invalid_tag", + ) + def test_object_tag_not_valid_check_object(self, tag_attr): + object_tag = getattr(self, tag_attr) + assert not object_tag.is_valid() + + def test_get_tags(self): + assert api.get_tags(self.taxonomy_all_orgs) == [self.tag_all_orgs] diff --git a/openedx/features/content_tagging/tests/test_rules.py b/openedx/features/content_tagging/tests/test_rules.py new file mode 100644 index 000000000000..029657e44f23 --- /dev/null +++ b/openedx/features/content_tagging/tests/test_rules.py @@ -0,0 +1,481 @@ +"""Tests content_tagging rules-based permissions""" + +import ddt +from django.contrib.auth import get_user_model +from django.test.testcases import TestCase, override_settings +from openedx_tagging.core.tagging.models import ObjectTag, Tag +from organizations.models import Organization + +from common.djangoapps.student.auth import add_users, update_org_role +from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole + +from .. import api +from .test_api import TestTaxonomyMixin + +User = get_user_model() + + +@ddt.ddt +@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": True}) +class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): + """ + Tests that the expected rules have been applied to the Taxonomy models. + + We set ENABLE_CREATOR_GROUP for these tests, otherwise all users have course creator access for all orgs. + """ + + def setUp(self): + super().setUp() + self.superuser = User.objects.create( + username="superuser", + email="superuser@example.com", + is_superuser=True, + ) + self.staff = User.objects.create( + username="staff", + email="staff@example.com", + is_staff=True, + ) + # Normal user: grant course creator role (for all orgs) + self.user_all_orgs = User.objects.create( + username="user_all_orgs", + email="staff+all@example.com", + ) + add_users(self.staff, CourseCreatorRole(), self.user_all_orgs) + + # Normal user: grant course creator access to both org1 and org2 + self.user_both_orgs = User.objects.create( + username="user_both_orgs", + email="staff+both@example.com", + ) + update_org_role( + self.staff, + OrgContentCreatorRole, + self.user_both_orgs, + [self.org1.short_name, self.org2.short_name], + ) + + # Normal user: grant course creator access to org2 + self.user_org2 = User.objects.create( + username="user_org2", + email="staff+org2@example.com", + ) + update_org_role( + self.staff, OrgContentCreatorRole, self.user_org2, [self.org2.short_name] + ) + + # Normal user: no course creator access + self.learner = User.objects.create( + username="learner", + email="learner@example.com", + ) + + def _expected_users_have_perm( + self, perm, obj, learner_perm=False, learner_obj=False, user_org2=True + ): + """ + Checks that all users have the given permission on the given object. + + If learners_too, then the learner user should have it too. + """ + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm) + assert self.superuser.has_perm(perm, obj) + assert self.staff.has_perm(perm) + assert self.staff.has_perm(perm, obj) + assert self.user_all_orgs.has_perm(perm) + assert self.user_all_orgs.has_perm(perm, obj) + + # Org content creators are bound by a taxonomy's org restrictions + assert self.user_both_orgs.has_perm(perm) == learner_perm + assert self.user_both_orgs.has_perm(perm, obj) + assert self.user_org2.has_perm(perm) == learner_perm + # user_org2 does not have course creator access for org 1 + assert self.user_org2.has_perm(perm, obj) == user_org2 + + # Learners can't do much but view + assert self.learner.has_perm(perm) == learner_perm + assert self.learner.has_perm(perm, obj) == learner_obj + + # Taxonomy + + @ddt.data( + ("oel_tagging.add_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.add_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.add_taxonomy", "taxonomy_disabled"), + ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_disabled"), + ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_disabled"), + ) + @ddt.unpack + def test_change_taxonomy_all_orgs(self, perm, taxonomy_attr): + """Taxonomy administrators with course creator access for the taxonomy org""" + taxonomy = getattr(self, taxonomy_attr) + self._expected_users_have_perm(perm, taxonomy) + + @ddt.data( + ("oel_tagging.add_taxonomy", "taxonomy_one_org"), + ("oel_tagging.change_taxonomy", "taxonomy_one_org"), + ("oel_tagging.delete_taxonomy", "taxonomy_one_org"), + ) + @ddt.unpack + def test_change_taxonomy_org1(self, perm, taxonomy_attr): + taxonomy = getattr(self, taxonomy_attr) + self._expected_users_have_perm(perm, taxonomy, user_org2=False) + + @ddt.data( + "oel_tagging.add_taxonomy", + "oel_tagging.change_taxonomy", + "oel_tagging.delete_taxonomy", + ) + def test_system_taxonomy(self, perm): + """Taxonomy administrators cannot edit system taxonomies""" + system_taxonomy = api.create_taxonomy( + name="System Languages", + ) + system_taxonomy.system_defined = True + assert self.superuser.has_perm(perm, system_taxonomy) + assert not self.staff.has_perm(perm, system_taxonomy) + assert not self.user_all_orgs.has_perm(perm, system_taxonomy) + assert not self.user_both_orgs.has_perm(perm, system_taxonomy) + assert not self.user_org2.has_perm(perm, system_taxonomy) + assert not self.learner.has_perm(perm, system_taxonomy) + + @ddt.data( + (True, "taxonomy_all_orgs"), + (False, "taxonomy_all_orgs"), + (True, "taxonomy_both_orgs"), + (False, "taxonomy_both_orgs"), + ) + @ddt.unpack + def test_view_taxonomy_enabled(self, enabled, taxonomy_attr): + """Anyone can see enabled taxonomies, but learners cannot see disabled taxonomies""" + taxonomy = getattr(self, taxonomy_attr) + taxonomy.enabled = enabled + perm = "oel_tagging.view_taxonomy" + self._expected_users_have_perm(perm, taxonomy, learner_obj=enabled) + + @ddt.data( + (True, "taxonomy_no_orgs"), + (False, "taxonomy_no_orgs"), + ) + @ddt.unpack + def test_view_taxonomy_no_orgs(self, enabled, taxonomy_attr): + """ + Enabled taxonomies with no org can be viewed by anyone. + Disabled taxonomies with no org can only be viewed by staff/superusers. + """ + taxonomy = getattr(self, taxonomy_attr) + taxonomy.enabled = enabled + perm = "oel_tagging.view_taxonomy" + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_all_orgs.has_perm(perm, taxonomy) == enabled + assert self.user_both_orgs.has_perm(perm, taxonomy) == enabled + assert self.user_org2.has_perm(perm, taxonomy) == enabled + assert self.learner.has_perm(perm, taxonomy) == enabled + + @ddt.data( + ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), + ) + @ddt.unpack + def test_change_taxonomy_no_orgs(self, perm, taxonomy_attr): + """ + Taxonomies with no org can only be changed by staff and superusers. + """ + taxonomy = getattr(self, taxonomy_attr) + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_all_orgs.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + # Tag + + @ddt.data( + ("oel_tagging.add_tag", "tag_all_orgs"), + ("oel_tagging.add_tag", "tag_both_orgs"), + ("oel_tagging.add_tag", "tag_disabled"), + ("oel_tagging.change_tag", "tag_all_orgs"), + ("oel_tagging.change_tag", "tag_both_orgs"), + ("oel_tagging.change_tag", "tag_disabled"), + ("oel_tagging.delete_tag", "tag_all_orgs"), + ("oel_tagging.delete_tag", "tag_both_orgs"), + ("oel_tagging.delete_tag", "tag_disabled"), + ) + @ddt.unpack + def test_change_tag_all_orgs(self, perm, tag_attr): + """Taxonomy administrators can modify tags on non-free-text taxonomies""" + tag = getattr(self, tag_attr) + self._expected_users_have_perm(perm, tag) + + @ddt.data( + ("oel_tagging.add_tag", "tag_one_org"), + ("oel_tagging.change_tag", "tag_one_org"), + ("oel_tagging.delete_tag", "tag_one_org"), + ) + @ddt.unpack + def test_change_tag_org1(self, perm, tag_attr): + """Taxonomy administrators can modify tags on non-free-text taxonomies""" + tag = getattr(self, tag_attr) + self._expected_users_have_perm(perm, tag, user_org2=False) + + @ddt.data( + "oel_tagging.add_tag", + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_tag_no_taxonomy(self, perm): + """Taxonomy administrators can modify any Tag, even those with no Taxonnmy.""" + tag = Tag() + + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm, tag) + assert self.staff.has_perm(perm, tag) + assert self.user_all_orgs.has_perm(perm, tag) + + # Org content creators are bound by a taxonomy's org restrictions, + # so if there's no taxonomy, they can't do anything to it. + assert not self.user_both_orgs.has_perm(perm, tag) + assert not self.user_org2.has_perm(perm, tag) + assert not self.learner.has_perm(perm, tag) + + @ddt.data( + "tag_all_orgs", + "tag_both_orgs", + "tag_one_org", + "tag_disabled", + "tag_no_orgs", + ) + def test_view_tag(self, tag_attr): + """Anyone can view any Tag""" + tag = getattr(self, tag_attr) + self._expected_users_have_perm( + "oel_tagging.view_tag", tag, learner_perm=True, learner_obj=True + ) + + # ObjectTag + + @ddt.data( + ("oel_tagging.add_object_tag", "disabled_course_tag"), + ("oel_tagging.change_object_tag", "disabled_course_tag"), + ("oel_tagging.delete_object_tag", "disabled_course_tag"), + ) + @ddt.unpack + def test_object_tag_disabled_taxonomy(self, perm, tag_attr): + """Taxonomy administrators cannot create/edit an ObjectTag with a disabled Taxonomy""" + object_tag = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, object_tag) + assert not self.staff.has_perm(perm, object_tag) + assert not self.user_all_orgs.has_perm(perm, object_tag) + assert not self.user_both_orgs.has_perm(perm, object_tag) + assert not self.user_org2.has_perm(perm, object_tag) + assert not self.learner.has_perm(perm, object_tag) + + @ddt.data( + ("oel_tagging.add_object_tag", "no_orgs_invalid_tag"), + ("oel_tagging.change_object_tag", "no_orgs_invalid_tag"), + ("oel_tagging.delete_object_tag", "no_orgs_invalid_tag"), + ) + @ddt.unpack + def test_object_tag_no_orgs(self, perm, tag_attr): + """Only staff & superusers can create/edit an ObjectTag with a no-org Taxonomy""" + object_tag = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, object_tag) + assert self.staff.has_perm(perm, object_tag) + assert not self.user_all_orgs.has_perm(perm, object_tag) + assert not self.user_both_orgs.has_perm(perm, object_tag) + assert not self.user_org2.has_perm(perm, object_tag) + assert not self.learner.has_perm(perm, object_tag) + + @ddt.data( + ("oel_tagging.add_object_tag", "all_orgs_course_tag"), + ("oel_tagging.add_object_tag", "all_orgs_block_tag"), + ("oel_tagging.add_object_tag", "both_orgs_course_tag"), + ("oel_tagging.add_object_tag", "both_orgs_block_tag"), + ("oel_tagging.add_object_tag", "all_orgs_invalid_tag"), + ("oel_tagging.change_object_tag", "all_orgs_course_tag"), + ("oel_tagging.change_object_tag", "all_orgs_block_tag"), + ("oel_tagging.change_object_tag", "both_orgs_course_tag"), + ("oel_tagging.change_object_tag", "both_orgs_block_tag"), + ("oel_tagging.change_object_tag", "all_orgs_invalid_tag"), + ("oel_tagging.delete_object_tag", "all_orgs_course_tag"), + ("oel_tagging.delete_object_tag", "all_orgs_block_tag"), + ("oel_tagging.delete_object_tag", "both_orgs_course_tag"), + ("oel_tagging.delete_object_tag", "both_orgs_block_tag"), + ("oel_tagging.delete_object_tag", "all_orgs_invalid_tag"), + ) + @ddt.unpack + def test_change_object_tag_all_orgs(self, perm, tag_attr): + """Taxonomy administrators can create/edit an ObjectTag on taxonomies in their org.""" + object_tag = getattr(self, tag_attr) + self._expected_users_have_perm(perm, object_tag) + + @ddt.data( + ("oel_tagging.add_object_tag", "one_org_block_tag"), + ("oel_tagging.add_object_tag", "one_org_invalid_org_tag"), + ("oel_tagging.change_object_tag", "one_org_block_tag"), + ("oel_tagging.change_object_tag", "one_org_invalid_org_tag"), + ("oel_tagging.delete_object_tag", "one_org_block_tag"), + ("oel_tagging.delete_object_tag", "one_org_invalid_org_tag"), + ) + @ddt.unpack + def test_change_object_tag_org1(self, perm, tag_attr): + """Taxonomy administrators can create/edit an ObjectTag on taxonomies in their org.""" + object_tag = getattr(self, tag_attr) + self._expected_users_have_perm(perm, object_tag, user_org2=False) + + @ddt.data( + "oel_tagging.add_object_tag", + "oel_tagging.change_object_tag", + "oel_tagging.delete_object_tag", + ) + def test_object_tag_no_taxonomy(self, perm): + """Taxonomy administrators can modify an ObjectTag with no Taxonomy""" + object_tag = ObjectTag() + + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm, object_tag) + assert self.staff.has_perm(perm, object_tag) + assert self.user_all_orgs.has_perm(perm, object_tag) + + # Org content creators are bound by a taxonomy's org restrictions, + # so if there's no taxonomy, they can't do anything to it. + assert not self.user_both_orgs.has_perm(perm, object_tag) + assert not self.user_org2.has_perm(perm, object_tag) + assert not self.learner.has_perm(perm, object_tag) + + @ddt.data( + "all_orgs_course_tag", + "all_orgs_block_tag", + "both_orgs_course_tag", + "both_orgs_block_tag", + "one_org_block_tag", + "all_orgs_invalid_tag", + "one_org_invalid_org_tag", + "no_orgs_invalid_tag", + "disabled_course_tag", + ) + def test_view_object_tag(self, tag_attr): + """Anyone can view any ObjectTag""" + object_tag = getattr(self, tag_attr) + self._expected_users_have_perm( + "oel_tagging.view_object_tag", + object_tag, + learner_perm=True, + learner_obj=True, + ) + + +@ddt.ddt +@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": False}) +class TestRulesTaxonomyNoCreatorGroup( + TestRulesTaxonomy +): # pylint: disable=test-inherits-tests + """ + Run the above tests with ENABLE_CREATOR_GROUP unset, to demonstrate that all users have course creator access for + all orgs, and therefore everyone is a Taxonomy Administrator. + + However, if there are no Organizations in the database, then nobody has access to the Tagging models. + """ + + def _expected_users_have_perm( + self, perm, obj, learner_perm=False, learner_obj=False, user_org2=True + ): + """ + When ENABLE_CREATOR_GROUP is disabled, all users have all permissions. + """ + super()._expected_users_have_perm( + perm=perm, + obj=obj, + learner_perm=True, + learner_obj=True, + user_org2=True, + ) + + @ddt.data( + "oel_tagging.add_tag", + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_tag_no_taxonomy(self, perm): + """Taxonomy administrators can modify any Tag, even those with no Taxonnmy.""" + tag = Tag() + + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm, tag) + assert self.staff.has_perm(perm, tag) + assert self.user_all_orgs.has_perm(perm, tag) + + # Org content creators are bound by a taxonomy's org restrictions, + # but since there's no org restrictions enabled, anyone has these permissions. + assert self.user_both_orgs.has_perm(perm, tag) + assert self.user_org2.has_perm(perm, tag) + assert self.learner.has_perm(perm, tag) + + @ddt.data( + "oel_tagging.add_object_tag", + "oel_tagging.change_object_tag", + "oel_tagging.delete_object_tag", + ) + def test_object_tag_no_taxonomy(self, perm): + """Taxonomy administrators can modify an ObjectTag with no Taxonomy""" + object_tag = ObjectTag() + + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm, object_tag) + assert self.staff.has_perm(perm, object_tag) + assert self.user_all_orgs.has_perm(perm, object_tag) + + # Org content creators are bound by a taxonomy's org restrictions, + # but since there's no org restrictions enabled, anyone has these permissions. + assert self.user_both_orgs.has_perm(perm, object_tag) + assert self.user_org2.has_perm(perm, object_tag) + assert self.learner.has_perm(perm, object_tag) + + # Taxonomy + + @ddt.data( + ("oel_tagging.add_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.add_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.add_taxonomy", "taxonomy_disabled"), + ("oel_tagging.add_taxonomy", "taxonomy_one_org"), + ("oel_tagging.add_taxonomy", "taxonomy_no_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_disabled"), + ("oel_tagging.change_taxonomy", "taxonomy_one_org"), + ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_disabled"), + ("oel_tagging.delete_taxonomy", "taxonomy_one_org"), + ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), + ) + @ddt.unpack + def test_no_orgs_no_perms(self, perm, taxonomy_attr): + """ + Org-level permissions are revoked when there are no orgs. + """ + Organization.objects.all().delete() + taxonomy = getattr(self, taxonomy_attr) + # Superusers & Staff always have access + assert self.superuser.has_perm(perm) + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm) + assert self.staff.has_perm(perm, taxonomy) + + # But everyone else's object-level access is removed + assert self.user_all_orgs.has_perm(perm) + assert not self.user_all_orgs.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm) + assert not self.user_org2.has_perm(perm, taxonomy) + assert self.learner.has_perm(perm) + assert not self.learner.has_perm(perm, taxonomy) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 39d23bc26982..0b703dbda1e5 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -242,6 +242,7 @@ django==3.2.20 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-learning # ora2 # skill-tagging # super-csv @@ -393,6 +394,7 @@ djangorestframework==3.14.0 # edx-proctoring # edx-submissions # openedx-blockstore + # openedx-learning # ora2 # super-csv djangorestframework-xml==2.0.0 @@ -768,6 +770,8 @@ openedx-filters==1.4.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # skill-tagging +openedx-learning==0.1.0 + # via -r requirements/edx/kernel.in openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 @@ -1004,6 +1008,7 @@ rules==3.3 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring + # openedx-learning s3transfer==0.1.13 # via boto3 sailthru-client==2.2.3 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 78114fb2e261..7d0bdaf1c9f6 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -402,6 +402,7 @@ django==3.2.20 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-learning # ora2 # skill-tagging # super-csv @@ -619,6 +620,7 @@ djangorestframework==3.14.0 # edx-proctoring # edx-submissions # openedx-blockstore + # openedx-learning # ora2 # super-csv djangorestframework-stubs==3.14.0 @@ -1300,6 +1302,10 @@ openedx-filters==1.4.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # skill-tagging +openedx-learning==0.1.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt openedx-mongodbproxy==0.2.0 # via # -r requirements/edx/doc.txt @@ -1762,6 +1768,7 @@ rules==3.3 # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring + # openedx-learning s3transfer==0.1.13 # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index e0978a161364..75db0a12d1bd 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -290,6 +290,7 @@ django==3.2.20 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-learning # ora2 # skill-tagging # super-csv @@ -457,6 +458,7 @@ djangorestframework==3.14.0 # edx-proctoring # edx-submissions # openedx-blockstore + # openedx-learning # ora2 # super-csv djangorestframework-xml==2.0.0 @@ -911,6 +913,8 @@ openedx-filters==1.4.0 # -r requirements/edx/base.txt # lti-consumer-xblock # skill-tagging +openedx-learning==0.1.0 + # via -r requirements/edx/base.txt openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 @@ -1195,6 +1199,7 @@ rules==3.3 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring + # openedx-learning s3transfer==0.1.13 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 10e9145dbd34..8929520ea819 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -115,6 +115,7 @@ openedx-calc # Library supporting mathematical calculatio openedx-django-require openedx-events>=8.3.0 # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) +openedx-learning<=0.1 openedx-mongodbproxy openedx-django-wiki openedx-blockstore diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index e0f841057e28..375fd0b72c6c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -322,6 +322,7 @@ django==3.2.20 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-learning # ora2 # skill-tagging # super-csv @@ -489,6 +490,7 @@ djangorestframework==3.14.0 # edx-proctoring # edx-submissions # openedx-blockstore + # openedx-learning # ora2 # super-csv djangorestframework-xml==2.0.0 @@ -980,6 +982,8 @@ openedx-filters==1.4.0 # -r requirements/edx/base.txt # lti-consumer-xblock # skill-tagging +openedx-learning==0.1.0 + # via -r requirements/edx/base.txt openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 @@ -1333,6 +1337,7 @@ rules==3.3 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring + # openedx-learning s3transfer==0.1.13 # via # -r requirements/edx/base.txt