feat: adds Content Tagging (#32661)

* refactor: moves is_content_creator

from cms.djangoapps.contentstore.helpers to common.djangoapps.student.auth

* feat: adds content tagging app

Adds models and APIs to support tagging content objects (e.g. XBlocks,
content libraries) by content authors. Content tags can be thought of as
"name:value" fields, though underneath they are a bit more complicated.

* adds dependency on openedx-learning<=0.1.0
* adds tagging app to LMS and CMS
* adds content tagging models, api, rules, admin, and tests.
* content taxonomies and tags can be maintained per organization by
  content creators for that organization.
This commit is contained in:
Jillian
2023-07-27 03:02:59 +09:30
committed by GitHub
parent 9d4163d31f
commit 8098169eca
22 changed files with 1305 additions and 17 deletions

View File

@@ -20,8 +20,6 @@ from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from common.djangoapps.student import auth
from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole
import openedx.core.djangoapps.content_staging.api as content_staging_api
from .utils import reverse_course_url, reverse_library_url, reverse_usage_url
@@ -377,15 +375,3 @@ def is_item_in_course_tree(item):
ancestor = ancestor.get_parent()
return ancestor is not None
def is_content_creator(user, org):
"""
Check if the user has the role to create content.
This function checks if the User has role to create content
or if the org is supplied, it checks for Org level course content
creator.
"""
return (auth.user_has_role(user, CourseCreatorRole()) or
auth.user_has_role(user, OrgContentCreatorRole(org=org)))

View File

@@ -45,7 +45,8 @@ from common.djangoapps.student.auth import (
has_course_author_access,
has_studio_read_access,
has_studio_write_access,
has_studio_advanced_settings_access
has_studio_advanced_settings_access,
is_content_creator,
)
from common.djangoapps.student.roles import (
CourseInstructorRole,
@@ -118,7 +119,6 @@ from ..utils import (
update_course_discussions_settings,
)
from .component import ADVANCED_COMPONENT_TYPES
from ..helpers import is_content_creator
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
create_xblock_info,
)

View File

@@ -30,7 +30,7 @@ from common.djangoapps.student.auth import (
STUDIO_VIEW_USERS,
get_user_permissions,
has_studio_read_access,
has_studio_write_access
has_studio_write_access,
)
from common.djangoapps.student.roles import (
CourseInstructorRole,

View File

@@ -1748,6 +1748,10 @@ INSTALLED_APPS = [
# 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',

View File

@@ -154,6 +154,18 @@ def has_studio_read_access(user, course_key):
return bool(STUDIO_VIEW_CONTENT & get_user_permissions(user, course_key))
def is_content_creator(user, org):
"""
Check if the user has the role to create content.
This function checks if the User has role to create content
or if the org is supplied, it checks for Org level course content
creator.
"""
return (user_has_role(user, CourseCreatorRole()) or
user_has_role(user, OrgContentCreatorRole(org=org)))
def add_users(caller, role, *users):
"""
The caller requests adding the given users to the role. Checks that the caller

View File

@@ -3198,6 +3198,10 @@ INSTALLED_APPS = [
# 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',

View File

@@ -0,0 +1,6 @@
""" Tagging app admin """
from django.contrib import admin
from .models import TaxonomyOrg
admin.site.register(TaxonomyOrg)

View File

@@ -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

View File

@@ -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"

View File

@@ -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",
),
),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)

View File

@@ -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
@@ -770,6 +772,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
@@ -1006,6 +1010,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

View File

@@ -403,6 +403,7 @@ django==3.2.20
# openedx-django-wiki
# openedx-events
# openedx-filters
# openedx-learning
# ora2
# skill-tagging
# super-csv
@@ -620,6 +621,7 @@ djangorestframework==3.14.0
# edx-proctoring
# edx-submissions
# openedx-blockstore
# openedx-learning
# ora2
# super-csv
djangorestframework-stubs==3.14.0
@@ -1304,6 +1306,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
@@ -1766,6 +1772,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

View File

@@ -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
@@ -913,6 +915,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
@@ -1197,6 +1201,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

View File

@@ -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

View File

@@ -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
@@ -982,6 +984,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
@@ -1335,6 +1339,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