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:
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
0
openedx/features/content_tagging/__init__.py
Normal file
0
openedx/features/content_tagging/__init__.py
Normal file
6
openedx/features/content_tagging/admin.py
Normal file
6
openedx/features/content_tagging/admin.py
Normal file
@@ -0,0 +1,6 @@
|
||||
""" Tagging app admin """
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import TaxonomyOrg
|
||||
|
||||
admin.site.register(TaxonomyOrg)
|
||||
157
openedx/features/content_tagging/api.py
Normal file
157
openedx/features/content_tagging/api.py
Normal 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
|
||||
12
openedx/features/content_tagging/apps.py
Normal file
12
openedx/features/content_tagging/apps.py
Normal 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"
|
||||
86
openedx/features/content_tagging/migrations/0001_initial.py
Normal file
86
openedx/features/content_tagging/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
166
openedx/features/content_tagging/models.py
Normal file
166
openedx/features/content_tagging/models.py
Normal 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)
|
||||
106
openedx/features/content_tagging/rules.py
Normal file
106
openedx/features/content_tagging/rules.py
Normal 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)
|
||||
0
openedx/features/content_tagging/tests/__init__.py
Normal file
0
openedx/features/content_tagging/tests/__init__.py
Normal file
245
openedx/features/content_tagging/tests/test_api.py
Normal file
245
openedx/features/content_tagging/tests/test_api.py
Normal 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]
|
||||
481
openedx/features/content_tagging/tests/test_rules.py
Normal file
481
openedx/features/content_tagging/tests/test_rules.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user