Skip to content

Commit

Permalink
feat: adds content tagging app
Browse files Browse the repository at this point in the history
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 committed Jul 26, 2023
1 parent a78773c commit 6206316
Show file tree
Hide file tree
Showing 18 changed files with 1,290 additions and 0 deletions.
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
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 6206316

Please sign in to comment.