Skip to content

Commit 180a29c

Browse files
committed
refactor: allows Taxonomy to be subclassed
* adds ContentTaxonomy and ContentTag proxy models * updates api to create/return ContentTaxonomy and ContentTag models * other updates to align with changes to openedx/openedx-learning#62 * fixes linting & test coverage
1 parent ce6e42e commit 180a29c

File tree

6 files changed

+220
-149
lines changed

6 files changed

+220
-149
lines changed

openedx/features/content_tagging/api.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""
22
Content Tagging APIs
33
"""
4-
from typing import Generator, List, Type
4+
from typing import Iterator, List, Type
55

66
import openedx_tagging.core.tagging.api as oel_tagging
7-
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
7+
from openedx_tagging.core.tagging.models import Taxonomy
88
from organizations.models import Organization
99

10-
from .models import BlockObjectTag, CourseObjectTag, TaxonomyOrg
10+
from .models import ContentTag, ContentTaxonomy, TaxonomyOrg
1111

1212

1313
def create_taxonomy(
@@ -18,10 +18,13 @@ def create_taxonomy(
1818
required=False,
1919
allow_multiple=False,
2020
allow_free_text=False,
21+
taxonomy_class: Type = ContentTaxonomy,
2122
) -> Taxonomy:
2223
"""
2324
Creates, saves, and returns a new Taxonomy with the given attributes.
2425
26+
If `taxonomy_class` not provided, then uses ContentTaxonomy.
27+
2528
If `org_owners` is empty/None, then the returned taxonomy is enabled for all organizations.
2629
"""
2730
taxonomy = oel_tagging.create_taxonomy(
@@ -31,7 +34,8 @@ def create_taxonomy(
3134
required=required,
3235
allow_multiple=allow_multiple,
3336
allow_free_text=allow_free_text,
34-
)
37+
taxonomy_class=taxonomy_class,
38+
).cast()
3539
if org_owners:
3640
set_taxonomy_org_owners(taxonomy, org_owners)
3741
return taxonomy
@@ -62,7 +66,7 @@ def set_taxonomy_org_owners(
6266

6367
def get_taxonomies_for_org(
6468
org_owner: Organization = None, enabled=True
65-
) -> Generator[Taxonomy, None, None]:
69+
) -> Iterator[Taxonomy]:
6670
"""
6771
Generates a list of the enabled Taxonomies owned by the given org, sorted by name.
6872
@@ -73,42 +77,59 @@ def get_taxonomies_for_org(
7377
for taxonomy in taxonomies.all():
7478
org_short_name = org_owner.short_name if org_owner else None
7579
if TaxonomyOrg.is_owner(taxonomy, org_short_name):
76-
yield taxonomy
80+
yield taxonomy.cast()
81+
82+
83+
def get_object_tags(
84+
object_id: str, taxonomy: Taxonomy = None, valid_only=True
85+
) -> Iterator[ContentTag]:
86+
"""
87+
Generates a list of content tags for a given object.
88+
89+
Pass taxonomy to limit the returned object_tags to a specific taxonomy.
90+
91+
Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too.
92+
Invalid tags will (probably) be hidden from learners.
93+
"""
94+
for object_tag in oel_tagging.get_object_tags(
95+
object_id=object_id,
96+
taxonomy=taxonomy,
97+
valid_only=valid_only,
98+
):
99+
yield ContentTag.cast(object_tag)
77100

78101

79102
def tag_object(
80-
taxonomy: Taxonomy, tags: List, object_id: str, object_type: str, object_tag_class: Type=None,
81-
) -> List[ObjectTag]:
103+
taxonomy: Taxonomy,
104+
tags: List,
105+
object_id: str,
106+
) -> List[ContentTag]:
82107
"""
83-
Replaces the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags.
108+
Replace the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags.
84109
85110
If taxonomy.allows_free_text, then the list should be a list of tag values.
86111
Otherwise, it should be a list of existing Tag IDs.
87112
88-
Raised ValueError if the proposed tags are invalid for this taxonomy.
113+
Raises ValueError if the proposed tags are invalid for this taxonomy.
89114
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags.
90-
91-
Sets the object_tag_class based on the given ``object_type``.
92115
"""
93-
if not object_tag_class:
94-
if object_type == 'block':
95-
object_tag_class = BlockObjectTag
96-
elif object_type == 'course':
97-
object_tag_class = CourseObjectTag
98-
return oel_tagging.tag_object(
116+
# Check that this will create valid ContentTags
117+
content_tag = ContentTag(object_id=object_id)
118+
if not content_tag.object_key:
119+
raise ValueError(f"Invalid ContentTag.object_id: {object_id}")
120+
content_tags = []
121+
for object_tag in oel_tagging.tag_object(
99122
taxonomy=taxonomy,
100123
tags=tags,
101124
object_id=object_id,
102-
object_type=object_type,
103-
object_tag_class=object_tag_class,
104-
)
125+
):
126+
content_tags.append(ContentTag.cast(object_tag))
127+
return content_tags
105128

106129

107130
# Expose the oel_tagging APIs
108131

109132
get_taxonomy = oel_tagging.get_taxonomy
110133
get_taxonomies = oel_tagging.get_taxonomies
111134
get_tags = oel_tagging.get_tags
112-
cast_object_tag = oel_tagging.cast_object_tag
113135
resync_object_tags = oel_tagging.resync_object_tags
114-
get_object_tags = oel_tagging.get_object_tags

openedx/features/content_tagging/migrations/0001_initial.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 3.2.20 on 2023-07-17 07:42
1+
# Generated by Django 3.2.20 on 2023-07-18 03:06
22

33
from django.db import migrations, models
44
import django.db.models.deletion
@@ -9,12 +9,12 @@ class Migration(migrations.Migration):
99

1010
dependencies = [
1111
("oel_tagging", "__latest__"),
12-
("organizations", "0003_historicalorganizationcourse"),
12+
("organizations", "__latest__"),
1313
]
1414

1515
operations = [
1616
migrations.CreateModel(
17-
name="BlockObjectTag",
17+
name="ContentTag",
1818
fields=[],
1919
options={
2020
"proxy": True,
@@ -23,6 +23,16 @@ class Migration(migrations.Migration):
2323
},
2424
bases=("oel_tagging.objecttag",),
2525
),
26+
migrations.CreateModel(
27+
name="ContentTaxonomy",
28+
fields=[],
29+
options={
30+
"proxy": True,
31+
"indexes": [],
32+
"constraints": [],
33+
},
34+
bases=("oel_tagging.taxonomy",),
35+
),
2636
migrations.CreateModel(
2737
name="TaxonomyOrg",
2838
fields=[
@@ -57,16 +67,6 @@ class Migration(migrations.Migration):
5767
),
5868
],
5969
),
60-
migrations.CreateModel(
61-
name="CourseObjectTag",
62-
fields=[],
63-
options={
64-
"proxy": True,
65-
"indexes": [],
66-
"constraints": [],
67-
},
68-
bases=("content_tagging.blockobjecttag",),
69-
),
7070
migrations.AddIndex(
7171
model_name="taxonomyorg",
7272
index=models.Index(

openedx/features/content_tagging/models.py

Lines changed: 41 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
"""
44
from django.db import models
55
from django.utils.translation import gettext as _
6-
from opaque_keys import InvalidKeyError
6+
from opaque_keys import InvalidKeyError, OpaqueKey
77
from opaque_keys.edx.keys import CourseKey, UsageKey
8+
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
89
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
910
from organizations.models import Organization
1011

1112

1213
class TaxonomyOrg(models.Model):
1314
"""
1415
Represents the many-to-many relationship between Taxonomies and Organizations.
16+
17+
We keep this as a separate class from ContentTaxonomy so that class can remain a proxy for Taxonomy, keeping the
18+
data models and usage simple.
1519
"""
1620

1721
class RelType(models.TextChoices):
@@ -60,80 +64,58 @@ def is_owner(cls, taxonomy: Taxonomy, org_short_name: str) -> bool:
6064
return org_owners.exists()
6165

6266

63-
class OrgObjectTagMixin:
67+
class ContentTag(ObjectTag):
6468
"""
65-
Mixin for ObjectTag that checks the TaxonomyOrg owner relationship.
69+
ObjectTag that requires an opaque key as an object ID.
6670
"""
6771

68-
@property
69-
def org_short_name(self):
70-
"""
71-
Subclasses should override this to return the object tag's org short name.
72-
"""
73-
raise NotImplementedError
74-
75-
def _check_taxonomy(self):
76-
"""
77-
Returns True if this ObjectTag's taxonomy is owned by the org.
78-
"""
79-
if not super()._check_taxonomy():
80-
return False
81-
82-
if not TaxonomyOrg.is_owner(self.taxonomy, self.org_short_name):
83-
return False
84-
85-
return True
86-
87-
88-
class BlockObjectTag(ObjectTag):
89-
"""
90-
ObjectTag which requires object_id to be a valid UsageKey.
91-
"""
92-
93-
OBJECT_KEY_CLASS = UsageKey
94-
9572
class Meta:
9673
proxy = True
9774

75+
OPAQUE_KEY_TYPES = [
76+
UsageKey,
77+
CourseKey,
78+
LibraryLocatorV2,
79+
LibraryUsageLocatorV2,
80+
]
81+
9882
@property
99-
def object_key(self):
83+
def object_key(self) -> OpaqueKey:
10084
"""
10185
Returns the object ID parsed as an Opaque Key, or None if invalid.
10286
"""
10387
if self.object_id:
104-
try:
105-
return self.OBJECT_KEY_CLASS.from_string(str(self.object_id))
106-
except InvalidKeyError:
107-
pass
88+
for OpaqueKeyClass in (UsageKey, CourseKey):
89+
try:
90+
return OpaqueKeyClass.from_string(str(self.object_id))
91+
except InvalidKeyError:
92+
pass
10893
return None
10994

110-
@property
111-
def org_short_name(self):
112-
"""
113-
Returns the org short name, if one can be parsed from the object ID.
114-
"""
115-
object_key = self.object_key
116-
if object_key:
117-
return object_key.org
118-
return None
119-
120-
def _check_object(self):
121-
"""
122-
Returns True if this ObjectTag has a valid object_key.
123-
"""
124-
# TODO: do check object_type? Do we still need it?
125-
if not self.object_key:
126-
return False
127-
128-
return super()._check_object()
129-
13095

131-
class CourseObjectTag(BlockObjectTag):
96+
class ContentTaxonomy(Taxonomy):
13297
"""
133-
ObjectTag which requires object_id to be a valid CourseKey.
98+
Taxonomy that accepts ContentTags,
99+
and ensures a valid TaxonomyOrg owner relationship with the content object.
134100
"""
135101

136-
OBJECT_KEY_CLASS = CourseKey
137-
138102
class Meta:
139103
proxy = True
104+
105+
def _check_object(self, object_tag: ObjectTag) -> bool:
106+
"""
107+
Returns True if this ObjectTag has a valid object_id.
108+
"""
109+
content_tag = ContentTag.cast(object_tag)
110+
return super()._check_object(content_tag) and content_tag.object_key
111+
112+
def _check_taxonomy(self, object_tag: ObjectTag) -> bool:
113+
"""
114+
Returns True if this taxonomy is owned by the tag's org.
115+
"""
116+
object_key = ContentTag.cast(object_tag).object_key
117+
return (
118+
super()._check_taxonomy(object_tag)
119+
and object_key
120+
and TaxonomyOrg.is_owner(self, object_key.org)
121+
)

0 commit comments

Comments
 (0)