Skip to content

Commit 6206316

Browse files
committed
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.
1 parent a78773c commit 6206316

File tree

18 files changed

+1290
-0
lines changed

18 files changed

+1290
-0
lines changed

cms/envs/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,6 +1748,10 @@
17481748
# API Documentation
17491749
'drf_yasg',
17501750

1751+
# Tagging
1752+
'openedx_tagging.core.tagging.apps.TaggingConfig',
1753+
'openedx.features.content_tagging',
1754+
17511755
'openedx.features.course_duration_limits',
17521756
'openedx.features.content_type_gating',
17531757
'openedx.features.discounts',

lms/envs/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3198,6 +3198,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
31983198
# Course Goals
31993199
'lms.djangoapps.course_goals.apps.CourseGoalsConfig',
32003200

3201+
# Tagging
3202+
'openedx_tagging.core.tagging.apps.TaggingConfig',
3203+
'openedx.features.content_tagging',
3204+
32013205
# Features
32023206
'openedx.features.calendar_sync',
32033207
'openedx.features.course_bookmarks',

openedx/features/content_tagging/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
""" Tagging app admin """
2+
from django.contrib import admin
3+
4+
from .models import TaxonomyOrg
5+
6+
admin.site.register(TaxonomyOrg)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Content Tagging APIs
3+
"""
4+
from typing import Iterator, List, Type, Union
5+
6+
import openedx_tagging.core.tagging.api as oel_tagging
7+
from django.db.models import QuerySet
8+
from opaque_keys.edx.keys import LearningContextKey
9+
from opaque_keys.edx.locator import BlockUsageLocator
10+
from openedx_tagging.core.tagging.models import Taxonomy
11+
from organizations.models import Organization
12+
13+
from .models import ContentObjectTag, ContentTaxonomy, TaxonomyOrg
14+
15+
16+
def create_taxonomy(
17+
name: str,
18+
description: str = None,
19+
enabled=True,
20+
required=False,
21+
allow_multiple=False,
22+
allow_free_text=False,
23+
taxonomy_class: Type = ContentTaxonomy,
24+
) -> Taxonomy:
25+
"""
26+
Creates, saves, and returns a new Taxonomy with the given attributes.
27+
28+
If `taxonomy_class` not provided, then uses ContentTaxonomy.
29+
"""
30+
return oel_tagging.create_taxonomy(
31+
name=name,
32+
description=description,
33+
enabled=enabled,
34+
required=required,
35+
allow_multiple=allow_multiple,
36+
allow_free_text=allow_free_text,
37+
taxonomy_class=taxonomy_class,
38+
)
39+
40+
41+
def set_taxonomy_orgs(
42+
taxonomy: Taxonomy,
43+
all_orgs=False,
44+
orgs: List[Organization] = None,
45+
relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER,
46+
):
47+
"""
48+
Updates the list of orgs associated with the given taxonomy.
49+
50+
Currently, we only have an "owner" relationship, but there may be other types added in future.
51+
52+
When an org has an "owner" relationship with a taxonomy, that taxonomy is available for use by content in that org,
53+
mies
54+
55+
If `all_orgs`, then the taxonomy is associated with all organizations, and the `orgs` parameter is ignored.
56+
57+
If not `all_orgs`, the taxonomy is associated with each org in the `orgs` list. If that list is empty, the
58+
taxonomy is not associated with any orgs.
59+
"""
60+
TaxonomyOrg.objects.filter(
61+
taxonomy=taxonomy,
62+
rel_type=relationship,
63+
).delete()
64+
65+
# org=None means the relationship is with "all orgs"
66+
if all_orgs:
67+
orgs = [None]
68+
if orgs:
69+
TaxonomyOrg.objects.bulk_create(
70+
[
71+
TaxonomyOrg(
72+
taxonomy=taxonomy,
73+
org=org,
74+
rel_type=relationship,
75+
)
76+
for org in orgs
77+
]
78+
)
79+
80+
81+
def get_taxonomies_for_org(
82+
enabled=True,
83+
org_owner: Organization = None,
84+
) -> QuerySet:
85+
"""
86+
Generates a list of the enabled Taxonomies available for the given org, sorted by name.
87+
88+
We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases.
89+
So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use.
90+
91+
If no `org` is provided, then only Taxonomies which are available for _all_ Organizations are returned.
92+
93+
If you want the disabled Taxonomies, pass enabled=False.
94+
If you want all Taxonomies (both enabled and disabled), pass enabled=None.
95+
"""
96+
taxonomies = oel_tagging.get_taxonomies(enabled=enabled)
97+
return ContentTaxonomy.taxonomies_for_org(
98+
org=org_owner,
99+
queryset=taxonomies,
100+
)
101+
102+
103+
def get_content_tags(
104+
object_id: str, taxonomy: Taxonomy = None, valid_only=True
105+
) -> Iterator[ContentObjectTag]:
106+
"""
107+
Generates a list of content tags for a given object.
108+
109+
Pass taxonomy to limit the returned object_tags to a specific taxonomy.
110+
111+
Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too.
112+
Invalid tags will (probably) be hidden from learners.
113+
"""
114+
for object_tag in oel_tagging.get_object_tags(
115+
object_id=object_id,
116+
taxonomy=taxonomy,
117+
valid_only=valid_only,
118+
):
119+
yield ContentObjectTag.cast(object_tag)
120+
121+
122+
def tag_content_object(
123+
taxonomy: Taxonomy,
124+
tags: List,
125+
object_id: Union[BlockUsageLocator, LearningContextKey],
126+
) -> List[ContentObjectTag]:
127+
"""
128+
This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or
129+
course).
130+
131+
It works one "Taxonomy" at a time, i.e. one field at a time, so you can set call it with taxonomy=Keywords,
132+
tags=["gravity", "newton"] to replace any "Keywords" [Taxonomy] tags on the given content object with "gravity" and
133+
"newton". Doing so to change the "Keywords" Taxonomy won't affect other Taxonomy's tags (other fields) on the
134+
object, such as "Language: [en]" or "Difficulty: [hard]".
135+
136+
If it's a free-text taxonomy, then the list should be a list of tag values.
137+
Otherwise, it should be a list of existing Tag IDs.
138+
139+
Raises ValueError if the proposed tags are invalid for this taxonomy.
140+
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags.
141+
"""
142+
content_tags = []
143+
for object_tag in oel_tagging.tag_object(
144+
taxonomy=taxonomy,
145+
tags=tags,
146+
object_id=str(object_id),
147+
):
148+
content_tags.append(ContentObjectTag.cast(object_tag))
149+
return content_tags
150+
151+
152+
# Expose the oel_tagging APIs
153+
154+
get_taxonomy = oel_tagging.get_taxonomy
155+
get_taxonomies = oel_tagging.get_taxonomies
156+
get_tags = oel_tagging.get_tags
157+
resync_object_tags = oel_tagging.resync_object_tags
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""
2+
Define the content tagging Django App.
3+
"""
4+
5+
from django.apps import AppConfig
6+
7+
8+
class ContentTaggingConfig(AppConfig):
9+
"""App config for the content tagging feature"""
10+
11+
default_auto_field = "django.db.models.BigAutoField"
12+
name = "openedx.features.content_tagging"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Generated by Django 3.2.20 on 2023-07-25 06:17
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
initial = True
9+
10+
dependencies = [
11+
("oel_tagging", "0002_auto_20230718_2026"),
12+
("organizations", "0003_historicalorganizationcourse"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="ContentObjectTag",
18+
fields=[],
19+
options={
20+
"proxy": True,
21+
"indexes": [],
22+
"constraints": [],
23+
},
24+
bases=("oel_tagging.objecttag",),
25+
),
26+
migrations.CreateModel(
27+
name="ContentTaxonomy",
28+
fields=[],
29+
options={
30+
"proxy": True,
31+
"indexes": [],
32+
"constraints": [],
33+
},
34+
bases=("oel_tagging.taxonomy",),
35+
),
36+
migrations.CreateModel(
37+
name="TaxonomyOrg",
38+
fields=[
39+
(
40+
"id",
41+
models.BigAutoField(
42+
auto_created=True,
43+
primary_key=True,
44+
serialize=False,
45+
verbose_name="ID",
46+
),
47+
),
48+
(
49+
"rel_type",
50+
models.CharField(
51+
choices=[("OWN", "owner")], default="OWN", max_length=3
52+
),
53+
),
54+
(
55+
"org",
56+
models.ForeignKey(
57+
default=None,
58+
help_text="Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.",
59+
null=True,
60+
on_delete=django.db.models.deletion.CASCADE,
61+
to="organizations.organization",
62+
),
63+
),
64+
(
65+
"taxonomy",
66+
models.ForeignKey(
67+
on_delete=django.db.models.deletion.CASCADE,
68+
to="oel_tagging.taxonomy",
69+
),
70+
),
71+
],
72+
),
73+
migrations.AddIndex(
74+
model_name="taxonomyorg",
75+
index=models.Index(
76+
fields=["taxonomy", "rel_type"], name="content_tag_taxonom_b04dd1_idx"
77+
),
78+
),
79+
migrations.AddIndex(
80+
model_name="taxonomyorg",
81+
index=models.Index(
82+
fields=["taxonomy", "rel_type", "org"],
83+
name="content_tag_taxonom_70d60b_idx",
84+
),
85+
),
86+
]

openedx/features/content_tagging/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)