Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
from typing import List, Type
from typing import List, Type, Union

from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -45,12 +45,19 @@ def get_taxonomies(enabled=True) -> QuerySet:
If you want the disabled taxonomies, pass enabled=False.
If you want all taxonomies (both enabled and disabled), pass enabled=None.
"""
queryset = Taxonomy.objects.order_by("name", "id")
queryset = Taxonomy.objects.order_by("name", "id").select_subclasses()
if enabled is None:
return queryset.all()
return queryset.filter(enabled=enabled)


def get_taxonomy(id: int) -> Union[Taxonomy, None]:
"""
Returns a Taxonomy of the appropriate subclass which has the given ID.
"""
return Taxonomy.objects.filter(id=id).select_subclasses().first()


def get_tags(taxonomy: Taxonomy) -> List[Tag]:
"""
Returns a list of predefined tags for the given taxonomy.
Expand Down Expand Up @@ -87,8 +94,8 @@ def get_object_tags(
Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too.
Invalid tags will likely be hidden from learners.
"""
tags = ObjectTag.objects.filter(
taxonomy=taxonomy, object_id=object_id, object_type=object_type
tags = taxonomy.objecttag_set.filter(
object_id=object_id, object_type=object_type
).order_by("id")
return [tag for tag in tags if not valid_only or taxonomy.validate_object_tag(tag)]

Expand Down
30 changes: 30 additions & 0 deletions openedx_tagging/core/tagging/migrations/0002_systemtaxonomy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.19 on 2023-06-28 10:19

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("oel_tagging", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="SystemTaxonomy",
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",
),
),
],
bases=("oel_tagging.taxonomy",),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 3.2.19 on 2023-07-04 02:20

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("oel_tagging", "0002_systemtaxonomy"),
]

operations = [
migrations.RemoveIndex(
model_name="objecttag",
name="oel_tagging_taxonom_3668ec_idx",
),
migrations.RenameField(
model_name="objecttag",
old_name="taxonomy",
new_name="_taxonomy",
),
migrations.AddIndex(
model_name="objecttag",
index=models.Index(
fields=["_taxonomy", "_value"], name="oel_tagging__taxono_af4c8a_idx"
),
),
]
106 changes: 96 additions & 10 deletions openedx_tagging/core/tagging/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

from django.db import models
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager

from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field


# Maximum depth allowed for a hierarchical taxonomy's tree of tags.
TAXONOMY_MAX_DEPTH = 3

Expand Down Expand Up @@ -93,11 +93,19 @@ def get_lineage(self) -> Lineage:
return lineage


class TaxonomyManager(InheritanceManager):
"""
Base Taxonomy class uses InheritanceManager to help instantiate subclasses during queries.
"""


class Taxonomy(models.Model):
"""
Represents a namespace and rules for a group of tags which can be applied to a particular Open edX object.
"""

objects = TaxonomyManager()

id = models.BigAutoField(primary_key=True)
name = case_insensitive_char_field(
null=False,
Expand Down Expand Up @@ -140,6 +148,18 @@ class Taxonomy(models.Model):
class Meta:
verbose_name_plural = "Taxonomies"

def __repr__(self):
"""
Developer-facing representation of a Taxonomy.
"""
return str(self)

def __str__(self):
"""
User-facing string representation of a Taxonomy.
"""
return f"<{self.__class__.__name__}> {self.name}"

@property
def system_defined(self) -> bool:
"""
Expand Down Expand Up @@ -204,7 +224,7 @@ def validate_object_tag(
"""
# Must be linked to this taxonomy
if check_taxonomy and (
not object_tag.taxonomy_id or object_tag.taxonomy_id != self.id
not object_tag.taxonomy or object_tag.taxonomy.id != self.id
):
return False

Expand Down Expand Up @@ -241,8 +261,8 @@ def tag_object(

current_tags = {
tag.tag_ref: tag
for tag in ObjectTag.objects.filter(
taxonomy=self, object_id=object_id, object_type=object_type
for tag in self.objecttag_set.filter(
object_id=object_id, object_type=object_type
)
}
updated_tags = []
Expand Down Expand Up @@ -310,7 +330,7 @@ class ObjectTag(models.Model):
max_length=255,
help_text=_("Type of object being tagged"),
)
taxonomy = models.ForeignKey(
_taxonomy = models.ForeignKey(
Taxonomy,
null=True,
default=None,
Expand Down Expand Up @@ -348,9 +368,57 @@ class ObjectTag(models.Model):

class Meta:
indexes = [
models.Index(fields=["taxonomy", "_value"]),
models.Index(fields=["_taxonomy", "_value"]),
]

def __init__(self, *args, **kwargs):
"""
Initializes the cached taxonomy instance.
"""
super().__init__(*args, **kwargs)
self._cached_taxonomy = None

def __repr__(self):
"""
Developer-facing representation of an ObjectTag.
"""
return str(self)

def __str__(self):
"""
User-facing string representation of an ObjectTag.
"""
return f"{self.object_id} ({self.object_type}): {self.name}={self.value}"

@property
def taxonomy(self) -> str:
"""
Returns this tag's taxonomy object, instantiated as the correct Taxonomy subclass.

This instance is cached so that subsequent calls to self.taxonomy don't re-fetch the value.

Returns None if taxonomy_id is not set.
"""
if not self._taxonomy_id:
self._cached_taxonomy = None

elif not self._cached_taxonomy:
self._cached_taxonomy = (
Taxonomy.objects.filter(id=self._taxonomy_id)
.select_subclasses()
.first()
)

return self._cached_taxonomy

@taxonomy.setter
def taxonomy(self, taxonomy: Taxonomy):
"""
Stores to the _taxonomy field.
"""
self._taxonomy = taxonomy
self._cached_taxonomy = taxonomy

@property
def name(self) -> str:
"""
Expand All @@ -359,7 +427,7 @@ def name(self) -> str:
If taxonomy is set, then returns its name.
Otherwise, returns the cached _name field.
"""
return self.taxonomy.name if self.taxonomy_id else self._name
return self.taxonomy.name if self._taxonomy_id else self._name

@name.setter
def name(self, name: str):
Expand Down Expand Up @@ -402,7 +470,9 @@ def is_valid(self) -> bool:

A valid ObjectTag must be linked to a Taxonomy, and be a valid tag in that taxonomy.
"""
return self.taxonomy_id and self.taxonomy.validate_object_tag(self)
if self._taxonomy_id:
return self.taxonomy.validate_object_tag(self)
return False

def get_lineage(self) -> Lineage:
"""
Expand All @@ -427,8 +497,10 @@ def resync(self) -> bool:
changed = False

# Locate a taxonomy matching _name
if not self.taxonomy_id:
for taxonomy in Taxonomy.objects.filter(name=self.name, enabled=True):
if not self._taxonomy_id:
for taxonomy in Taxonomy.objects.filter(
name=self.name, enabled=True
).select_subclasses():
# Make sure this taxonomy will accept object tags like this.
self.taxonomy = taxonomy
if taxonomy.validate_object_tag(self, check_tag=False):
Expand Down Expand Up @@ -456,3 +528,17 @@ def resync(self) -> bool:
changed = True

return changed


class SystemTaxonomy(Taxonomy):
"""
System-defined taxonomies are not editable by normal users; they're defined by fixtures/migrations, and may have
dynamically-determined Tags and ObjectTags.
"""

@property
def system_defined(self) -> bool:
"""
This is a system-defined taxonomy.
"""
return True
14 changes: 10 additions & 4 deletions openedx_tagging/core/tagging/rules.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
"""Django rules-based permissions for tagging"""

import rules
from django.contrib.auth import get_user_model

from .models import ObjectTag, Tag, Taxonomy

User = get_user_model()


# Global staff are taxonomy admins.
# (Superusers can already do anything)
is_taxonomy_admin = rules.is_staff


@rules.predicate
def can_view_taxonomy(user, taxonomy=None):
def can_view_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool:
"""
Anyone can view an enabled taxonomy,
but only taxonomy admins can view a disabled taxonomy.
Expand All @@ -17,7 +23,7 @@ def can_view_taxonomy(user, taxonomy=None):


@rules.predicate
def can_change_taxonomy(user, taxonomy=None):
def can_change_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool:
"""
Even taxonomy admins cannot change system taxonomies.
"""
Expand All @@ -27,7 +33,7 @@ def can_change_taxonomy(user, taxonomy=None):


@rules.predicate
def can_change_taxonomy_tag(user, tag=None):
def can_change_taxonomy_tag(user: User, tag: Tag = None) -> bool:
"""
Even taxonomy admins cannot add tags to system taxonomies (their tags are system-defined), or free-text taxonomies
(these don't have predefined tags).
Expand All @@ -44,7 +50,7 @@ def can_change_taxonomy_tag(user, tag=None):


@rules.predicate
def can_change_object_tag(user, object_tag=None):
def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool:
"""
Taxonomy admins can create or modify object tags on enabled taxonomies.
"""
Expand Down
2 changes: 2 additions & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ Django<5.0 # Web application framework
djangorestframework<4.0 # REST API

rules<4.0 # Django extension for rules-based authorization checks

django-model-utils # InheritanceManager for openedx-tagging models
3 changes: 3 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ django==3.2.19
# via
# -c requirements/constraints.txt
# -r requirements/base.in
# django-model-utils
# djangorestframework
django-model-utils==4.3.1
# via -r requirements/base.in
djangorestframework==3.14.0
# via -r requirements/base.in
pytz==2023.3
Expand Down
3 changes: 3 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ django==3.2.19
# -c requirements/constraints.txt
# -r requirements/quality.txt
# django-debug-toolbar
# django-model-utils
# djangorestframework
# edx-i18n-tools
django-debug-toolbar==4.1.0
# via -r requirements/dev.in
django-model-utils==4.3.1
# via -r requirements/quality.txt
djangorestframework==3.14.0
# via -r requirements/quality.txt
docutils==0.20.1
Expand Down
3 changes: 3 additions & 0 deletions requirements/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ django==3.2.19
# via
# -c requirements/constraints.txt
# -r requirements/test.txt
# django-model-utils
# djangorestframework
# sphinxcontrib-django
django-model-utils==4.3.1
# via -r requirements/test.txt
djangorestframework==3.14.0
# via -r requirements/test.txt
doc8==1.1.1
Expand Down
3 changes: 3 additions & 0 deletions requirements/quality.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ django==3.2.19
# via
# -c requirements/constraints.txt
# -r requirements/test.txt
# django-model-utils
# djangorestframework
django-model-utils==4.3.1
# via -r requirements/test.txt
djangorestframework==3.14.0
# via -r requirements/test.txt
docutils==0.20.1
Expand Down
Loading