Skip to content

Commit

Permalink
feat: adds Content Tagging (#32661)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
pomegranited authored Jul 26, 2023
1 parent 9d4163d commit 8098169
Show file tree
Hide file tree
Showing 22 changed files with 1,305 additions and 17 deletions.
14 changes: 0 additions & 14 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
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
Expand Down Expand Up @@ -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)))
4 changes: 2 additions & 2 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
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,
Expand Down Expand Up @@ -118,7 +119,6 @@
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,
)
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/views/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
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,
Expand Down
4 changes: 4 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,10 @@
# API Documentation
'drf_yasg',

# Tagging
'openedx_tagging.core.tagging.apps.TaggingConfig',
'openedx.features.content_tagging',

'openedx.features.course_duration_limits',
'openedx.features.content_type_gating',
'openedx.features.discounts',
Expand Down
12 changes: 12 additions & 0 deletions common/djangoapps/student/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3198,6 +3198,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# Course Goals
'lms.djangoapps.course_goals.apps.CourseGoalsConfig',

# Tagging
'openedx_tagging.core.tagging.apps.TaggingConfig',
'openedx.features.content_tagging',

# Features
'openedx.features.calendar_sync',
'openedx.features.course_bookmarks',
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions openedx/features/content_tagging/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
""" Tagging app admin """
from django.contrib import admin

from .models import TaxonomyOrg

admin.site.register(TaxonomyOrg)
157 changes: 157 additions & 0 deletions openedx/features/content_tagging/api.py
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions openedx/features/content_tagging/apps.py
Original file line number Diff line number Diff line change
@@ -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 changes: 86 additions & 0 deletions openedx/features/content_tagging/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Empty file.
Loading

0 comments on commit 8098169

Please sign in to comment.