-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat: adds content tagging app, models, and API #32518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2f70278
d27c18c
456979a
3ade910
d51e148
d0c446b
a0e33e1
7278da0
4f297f6
df61b49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """ Tagging app admin """ | ||
| from django.contrib import admin | ||
|
|
||
| from .models import ContentTaxonomy | ||
|
|
||
|
|
||
| class ContentTaxonomyOrgAdmin(admin.TabularInline): | ||
| model = ContentTaxonomy.org_owners.through | ||
|
|
||
|
|
||
| class ContentTaxonomyAdmin(admin.ModelAdmin): | ||
| """ | ||
| Admin form for the content taxonomy table. | ||
| """ | ||
|
|
||
| inlines = (ContentTaxonomyOrgAdmin,) | ||
|
|
||
|
|
||
| admin.site.register(ContentTaxonomy, ContentTaxonomyAdmin) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| """ | ||
| Content Tagging APIs | ||
| """ | ||
| from typing import List | ||
|
|
||
| import openedx_tagging.core.tagging.api as oel_tagging | ||
| from django.db.models import QuerySet | ||
| from organizations.models import Organization | ||
|
|
||
| from .models import ContentTaxonomy | ||
|
|
||
|
|
||
| def create_taxonomy( | ||
| name, | ||
| org_owners: List[Organization] = None, | ||
| description=None, | ||
| enabled=True, | ||
| required=False, | ||
| allow_multiple=False, | ||
| allow_free_text=False, | ||
| ) -> ContentTaxonomy: | ||
| """ | ||
| Creates, saves, and returns a new ContentTaxonomy with the given attributes. | ||
|
|
||
| If `org_owners` is empty/None, then the returned taxonomy is enabled for all organizations. | ||
| """ | ||
| taxonomy = ContentTaxonomy.objects.create( | ||
| name=name, | ||
| description=description, | ||
| enabled=enabled, | ||
| required=required, | ||
| allow_multiple=allow_multiple, | ||
| allow_free_text=allow_free_text, | ||
| ) | ||
| if org_owners: | ||
| set_taxonomy_org_owners(taxonomy, org_owners) | ||
| return taxonomy | ||
|
|
||
|
|
||
| def set_taxonomy_org_owners( | ||
| taxonomy: ContentTaxonomy, | ||
| org_owners: List[Organization] = None, | ||
| ) -> ContentTaxonomy: | ||
| """ | ||
| Updates the list of org_owners on the given taxonomy. | ||
|
|
||
| If `org_owners` is empty/None, then the returned taxonomy is enabled for all organizations. | ||
| """ | ||
| if org_owners: | ||
| taxonomy.org_owners.set(org_owners) | ||
| else: | ||
| taxonomy.org_owners.clear() | ||
| return taxonomy | ||
|
|
||
|
|
||
| def get_taxonomies_for_org(org_owner: Organization = None, enabled=True) -> QuerySet: | ||
| """ | ||
| Returns a queryset containing the enabled ContentTaxonomies owned by the given org, sorted by name. | ||
|
|
||
| If you want all Taxonomies, not just ContentTaxonomies, use the get_taxonomies API method, and use `enabled_for_org` | ||
| on any returned ContentTaxonomies to filter out by org ownership. | ||
|
|
||
| If you want the disabled ContentTaxonomies, pass enabled=False. | ||
| If you want all ContentTaxonomies (both enabled and disabled), pass enabled=None. | ||
| """ | ||
| return ( | ||
| ContentTaxonomy.objects.filter_enabled( | ||
| org_owner, | ||
| enabled, | ||
| ) | ||
| .order_by("name", "id") | ||
| .select_subclasses() | ||
| ) | ||
|
|
||
|
|
||
| # Expose the oel_tagging APIs that we haven't overridden here: | ||
|
|
||
| get_taxonomies = oel_tagging.get_taxonomies | ||
| get_taxonomy = oel_tagging.get_taxonomy | ||
| get_tags = oel_tagging.get_tags | ||
| resync_object_tags = oel_tagging.resync_object_tags | ||
| get_object_tags = oel_tagging.get_object_tags | ||
| tag_object = oel_tagging.tag_object |
| 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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # Generated by Django 3.2.19 on 2023-06-19 22:44 | ||
|
|
||
| from django.db import migrations, models | ||
| import django.db.models.deletion | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| initial = True | ||
|
|
||
| dependencies = [ | ||
| ("organizations", "0003_historicalorganizationcourse"), | ||
| ("oel_tagging", "0001_initial"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="ContentTaxonomy", | ||
| fields=[ | ||
| ( | ||
| "taxonomy_ptr", | ||
| models.OneToOneField( | ||
| auto_created=True, | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| parent_link=True, | ||
| primary_key=True, | ||
| serialize=False, | ||
| to="oel_tagging.taxonomy", | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "verbose_name_plural": "ContentTaxonomies", | ||
| }, | ||
| bases=("oel_tagging.taxonomy",), | ||
| ), | ||
| migrations.CreateModel( | ||
| name="ContentTaxonomyOrg", | ||
| fields=[ | ||
| ( | ||
| "id", | ||
| models.BigAutoField( | ||
| auto_created=True, | ||
| primary_key=True, | ||
| serialize=False, | ||
| verbose_name="ID", | ||
| ), | ||
| ), | ||
| ( | ||
| "org", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| to="organizations.organization", | ||
| ), | ||
| ), | ||
| ( | ||
| "taxonomy", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| to="content_tagging.contenttaxonomy", | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| migrations.AddField( | ||
| model_name="contenttaxonomy", | ||
| name="org_owners", | ||
| field=models.ManyToManyField( | ||
| through="content_tagging.ContentTaxonomyOrg", | ||
| to="organizations.Organization", | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| """ | ||
| Content Tagging models | ||
| """ | ||
| from django.db import models | ||
| from opaque_keys import InvalidKeyError | ||
| from opaque_keys.edx.keys import CourseKey, UsageKey | ||
| from openedx_tagging.core.tagging.models import Taxonomy, TaxonomyManager | ||
| from organizations.models import Organization | ||
|
|
||
|
|
||
| class ContentTaxonomyManager(TaxonomyManager): | ||
| """ | ||
| Manages ContentTaxonomy objects, providing custom utility methods. | ||
|
|
||
| Inherits from InheritanceManager so that subclasses of ContentTaxonomy can be selected in a queryset. | ||
| """ | ||
|
|
||
| def filter_enabled(self, org: Organization = None, enabled=True) -> models.QuerySet: | ||
| """ | ||
| Returns a query set filtered to return the enabled ContentTaxonomy objects owned by the given org. | ||
| """ | ||
| queryset = self | ||
| if enabled is not None: | ||
| queryset = queryset.filter(enabled=enabled) | ||
| org_filter = models.Q(org_owners=None) | ||
| if org: | ||
| org_filter |= models.Q(org_owners=org) | ||
| queryset = queryset.filter(org_filter) | ||
| return queryset | ||
|
|
||
|
|
||
| class ContentTaxonomy(Taxonomy): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not convinced that it makes sense to make this a subclass of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh bummer. You're absolutely right. I had fooled myself into thinking this was ok in my tests, because when I created the invalid tags, their taxonomies are ContentTaxonomy instances. But they're not after they're refetched from the database :( I guess I have to rethink this architecture then..
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I think we can have the same problem with system-defined taxonomies
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a way to check if a model is an instance of ContentTaxonomy? (with I remember the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The exact same thing has happened to me before... sorry I didn't spot this sooner. I didn't suggest any solutions because I didn't want to bias you one way or another, but I think there are still some nice ways to achieve a similar architecture without too many changes. One thing to think about for example would be if there is only one database/django model for
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bradenmacdonald @pomegranited
Adding more context with this in mind. For system-defined taxonomies, we were thinking of creating a subclass for each taxonomy, since each one had its special way of validating ( But, I've been thinking that at the system taxonomies level, the So far we will have two subclasses:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I haven't used it before, but as long as the number of subclasses is relatively small, I'm fine with a few joins.
What are the other types of validation that have to happen on Maybe it's
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the case of system-defined taxonomies, the validations that are added are more on the tag side than on the object.
Later we can add more taxonomies that have their own validation
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah, as @ChrisChV noted, there's going to be a lot of these.. so using InheritanceManager like I've done in openedx/openedx-learning#60 not a good idea.
I agree that In the design stage, we pushed the ObjectTag validation into the Taxonomy class so we could avoid having to subclass both Taxonomies and ObjectTags, and link them together somehow (see below for a proposed way to do this). This made Taxonomy.validate_object_tag() strange, but it was intended to keep the class complexity down. But that decision was clearly flawed.
Ok sure -- there weren't any requirements in the spec that made those particular distinctions necessary, however.. We could subclass ObjectTag to encapsulate the "closed taxonomy tags" vs "free text tags" distinction. To do this, we need a way to connect a Taxonomy with its appropriate ObjectTag subclass. To try this out, I drafted open-craft/openedx-learning#2; see Taxonony.object_tag_classes for an example of how this could work. There, I also allowed for custom ObjectTag classes stored with a Taxonomy that could be used to validate system tags for the different system taxonomy use cases listed above. We could replace the SystemTaxonomy subclasses and their validation with class methods on ObjectTag subclasses instead. Python is bad at polymorphism across class methods, but with care, we can do it, e.g
So, what do you think of something like open-craft/openedx-learning#2 instead? Also, if we do this, it'd be best to stop returning ObjectTag Django Models from the API altogether, and instead use the models as data to populate proper python classes and subclasses, as @bradenmacdonald originally suggested. If we found that we still need Taxonomy subclasses, we could add a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ormsbee @bradenmacdonald @ChrisChV Ok, this is looking really good. I'm going to close this PR in favor of #32661, so can we continue this conversation there? |
||
| """ | ||
| Taxonomy used for tagging content-related objects. | ||
|
|
||
| ContentTaxonomies can be owned by one or more organizations, which allows content authors from that organization to | ||
| create taxonomies, edit the taxonomy fields, and add/edit/remove Tags linked to the taxonomy. | ||
|
|
||
| .. no_pii: | ||
| """ | ||
|
|
||
| objects = ContentTaxonomyManager() | ||
|
|
||
| org_owners = models.ManyToManyField(Organization, through="ContentTaxonomyOrg") | ||
|
|
||
| class Meta: | ||
| verbose_name_plural = "ContentTaxonomies" | ||
|
|
||
| def validate_object_tag( | ||
| self, | ||
| object_tag: "ObjectTag", | ||
| check_taxonomy=True, | ||
| check_tag=True, | ||
| check_object=True, | ||
| ) -> bool: | ||
| """ | ||
| Returns True if the given object tag is valid for this ContentTaxonomy. | ||
|
|
||
| Extends the superclass method by adding its own object checks to ensure: | ||
|
|
||
| * object_tag.object_id is a valid UsageKey or CourseKey, and | ||
| * object_tag.object_id's "org" is enabled for this taxonomy. | ||
| """ | ||
| if check_object: | ||
| # ContentTaxonomies require object_id to be a valid CourseKey or UsageKey | ||
| try: | ||
| object_key = UsageKey.from_string(object_tag.object_id) | ||
| except InvalidKeyError: | ||
| try: | ||
| object_key = CourseKey.from_string(object_tag.object_id) | ||
| except InvalidKeyError: | ||
| return False | ||
|
|
||
| # ...and object to be in an org that is enabled for this taxonomy. | ||
| if not self.enabled_for_org(object_key.org): | ||
| return False | ||
|
|
||
| return super().validate_object_tag( | ||
| object_tag, | ||
| check_taxonomy=check_taxonomy, | ||
| check_tag=check_tag, | ||
| check_object=check_object, | ||
| ) | ||
|
|
||
| def enabled_for_org(self, org_short_name: str) -> bool: | ||
| """ | ||
| Returns True if this taxonomy is enabled for the given organization. | ||
| """ | ||
| enabled = self.enabled | ||
| if self.org_owners.count(): | ||
| enabled &= self.org_owners.filter( | ||
| contenttaxonomyorg__org__short_name=org_short_name, | ||
| ).exists() | ||
| return enabled | ||
|
|
||
|
|
||
| class ContentTaxonomyOrg(models.Model): | ||
| """ | ||
| Represents the many-to-many relationship between ContentTaxonomies and Organizations. | ||
| """ | ||
|
|
||
| taxonomy = models.ForeignKey(ContentTaxonomy, on_delete=models.CASCADE) | ||
| org = models.ForeignKey(Organization, on_delete=models.CASCADE) | ||
Uh oh!
There was an error while loading. Please reload this page.