Skip to content

Commit

Permalink
feat: features to enable import/export courses (#172)
Browse files Browse the repository at this point in the history
- Rename taxonomy._name to taxonomy._export_id
- Refactor all code about taxonomy._name
- Update resync object tags to update the taxonomy from _export_id
- Update tag_object to allow create object_id with invalid tags and taxonomies
- Add additional reserved characters used for input/output cases
  • Loading branch information
ChrisChV authored Mar 28, 2024
1 parent e37f36d commit a800b68
Show file tree
Hide file tree
Showing 11 changed files with 405 additions and 99 deletions.
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Open edX Learning ("Learning Core").
"""
__version__ = "0.7.0"
__version__ = "0.8.0"
2 changes: 1 addition & 1 deletion openedx_tagging/core/tagging/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class ObjectTagAdmin(admin.ModelAdmin):
"""
fields = ["object_id", "taxonomy", "tag", "_value"]
autocomplete_fields = ["tag"]
list_display = ["object_id", "name", "value"]
list_display = ["object_id", "export_id", "value"]
readonly_fields = ["object_id"]

def has_add_permission(self, request):
Expand Down
141 changes: 106 additions & 35 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def get_object_tags(
Value("\t"),
output_field=models.CharField(),
)))
.annotate(taxonomy_name=Coalesce(F("taxonomy__name"), F("_name")))
.annotate(taxonomy_name=Coalesce(F("taxonomy__name"), F("_export_id")))
# Sort first by taxonomy name, then by tag value in tree order:
.order_by("taxonomy_name", "sort_key")
)
Expand Down Expand Up @@ -274,11 +274,58 @@ def delete_object_tags(object_id: str):
tags.delete()


def _check_new_tag_count(
new_tag_count: int,
taxonomy: Taxonomy | None,
object_id: str,
taxonomy_export_id: str | None = None,
) -> None:
"""
Checks if the new count of tags for the object is equal or less than 100
"""
# Exclude to avoid counting the tags that are going to be updated
if taxonomy:
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(taxonomy_id=taxonomy.id).count()
else:
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(_export_id=taxonomy_export_id).count()

if current_count + new_tag_count > 100:
raise ValueError(
_("Cannot add more than 100 tags to ({object_id}).").format(object_id=object_id)
)


def _get_current_tags(
taxonomy: Taxonomy | None,
tags: list[str],
object_id: str,
object_tag_class: type[ObjectTag] = ObjectTag,
taxonomy_export_id: str | None = None,
) -> list[ObjectTag]:
"""
Returns the current object tags of the related object_id with taxonomy
"""
ObjectTagClass = object_tag_class
if taxonomy:
if not taxonomy.allow_multiple and len(tags) > 1:
raise ValueError(_("Taxonomy ({name}) only allows one tag per object.").format(name=taxonomy.name))
current_tags = list(
ObjectTagClass.objects.filter(taxonomy=taxonomy, object_id=object_id)
)
else:
current_tags = list(
ObjectTagClass.objects.filter(_export_id=taxonomy_export_id, object_id=object_id)
)
return current_tags


def tag_object(
object_id: str,
taxonomy: Taxonomy,
taxonomy: Taxonomy | None,
tags: list[str],
object_tag_class: type[ObjectTag] = ObjectTag,
create_invalid: bool = False,
taxonomy_export_id: str | None = None,
) -> None:
"""
Replaces the existing ObjectTag entries for the given taxonomy + object_id
Expand All @@ -292,37 +339,34 @@ def tag_object(
Raised Tag.DoesNotExist if the proposed tags are invalid for this taxonomy.
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted
(or invalid) tags.
"""

def _check_new_tag_count(new_tag_count: int) -> None:
"""
Checks if the new count of tags for the object is equal or less than 100
"""
# Exclude self.id to avoid counting the tags that are going to be updated
current_count = ObjectTag.objects.filter(object_id=object_id).exclude(taxonomy_id=taxonomy.id).count()

if current_count + new_tag_count > 100:
raise ValueError(
_("Cannot add more than 100 tags to ({object_id}).").format(object_id=object_id)
)
create_invalid: You can create invalid tags and avoid the previous behavior using.
taxonomy_export_id: You can create object tags without taxonomy using this param
and `taxonomy` as None. You need to use the taxonomy.export_id, so you can resync
this object tag if the taxonomy is created in the future.
"""
if not isinstance(tags, list):
raise ValueError(_("Tags must be a list, not {type}.").format(type=type(tags).__name__))

ObjectTagClass = object_tag_class
taxonomy = taxonomy.cast() # Make sure we're using the right subclass. This is a no-op if we are already.
tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order

_check_new_tag_count(len(tags))

if not taxonomy.allow_multiple and len(tags) > 1:
raise ValueError(_("Taxonomy ({name}) only allows one tag per object.").format(name=taxonomy.name))

current_tags = list(
ObjectTagClass.objects.filter(taxonomy=taxonomy, object_id=object_id)
if taxonomy:
taxonomy = taxonomy.cast() # Make sure we're using the right subclass. This is a no-op if we are already.
elif not taxonomy_export_id:
raise ValueError("`taxonomy_export_id` can't be None if `taxonomy` is None")

_check_new_tag_count(len(tags), taxonomy, object_id, taxonomy_export_id)
current_tags = _get_current_tags(
taxonomy,
tags,
object_id,
object_tag_class,
taxonomy_export_id
)

updated_tags = []
if taxonomy.allow_free_text:
if taxonomy and taxonomy.allow_free_text:
for tag_value in tags:
object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.value == tag_value), -1)
if object_tag_index >= 0:
Expand All @@ -334,19 +378,46 @@ def _check_new_tag_count(new_tag_count: int) -> None:
else:
# Handle closed taxonomies:
for tag_value in tags:
tag = taxonomy.tag_for_value(tag_value) # Will raise Tag.DoesNotExist if the value is invalid.
object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.tag_id == tag.id), -1)
if object_tag_index >= 0:
# This tag is already applied.
object_tag = current_tags.pop(object_tag_index)
if object_tag._value != tag.value: # pylint: disable=protected-access
# The ObjectTag's cached '_value' is out of sync with the Tag, so update it:
object_tag._value = tag.value # pylint: disable=protected-access
tag = None
# When export, sometimes, the value has a space at the beginning and end.
tag_value = tag_value.strip()
if taxonomy:
try:
tag = taxonomy.tag_for_value(tag_value) # Will raise Tag.DoesNotExist if the value is invalid.
except Tag.DoesNotExist as e:
if not create_invalid:
raise e

if tag:
# Tag exists in the taxonomy
object_tag_index = next((i for (i, t) in enumerate(current_tags) if t.tag_id == tag.id), -1)
if object_tag_index >= 0:
# This tag is already applied.
object_tag = current_tags.pop(object_tag_index)
if object_tag._value != tag.value: # pylint: disable=protected-access
# The ObjectTag's cached '_value' is out of sync with the Tag, so update it:
object_tag._value = tag.value # pylint: disable=protected-access
updated_tags.append(object_tag)
else:
# We are newly applying this tag:
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, tag=tag)
updated_tags.append(object_tag)
else:
# We are newly applying this tag:
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, tag=tag)
elif taxonomy:
# Tag doesn't exist in the taxonomy and `create_invalid` is True
object_tag = ObjectTagClass(taxonomy=taxonomy, object_id=object_id, _value=tag_value)
updated_tags.append(object_tag)
else:
# Taxonomy is None (also tag doesn't exist)
if taxonomy_export_id:
# This will always be true, since it is verified at the beginning of the function.
# This condition is placed by the type checks.
object_tag = ObjectTagClass(
taxonomy=None,
object_id=object_id,
_value=tag_value,
_export_id=taxonomy_export_id
)
updated_tags.append(object_tag)

# Save all updated tags at once to avoid partial updates
with transaction.atomic():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Generated by Django 3.2.22 on 2024-03-22 19:47

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

import openedx_learning.lib.fields


def migrate_export_id(apps, schema_editor):
ObjectTag = apps.get_model("oel_tagging", "ObjectTag")
for object_tag in ObjectTag.objects.all():
if object_tag.taxonomy:
object_tag.export_id = object_tag.taxonomy.export_id
object_tag.save(update_fields=["_export_id"])


def reverse_export_id(apps, schema_editor):
pass


def migrate_language_export_id(apps, schema_editor):
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
language_taxonomy = Taxonomy.objects.get(id=-1)
language_taxonomy.export_id = 'languages-v1'
language_taxonomy.save(update_fields=["export_id"])


def reverse_language_export_id(apps, schema_editor):
"""
Return to old export_id
"""
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
language_taxonomy = Taxonomy.objects.get(id=-1)
language_taxonomy.export_id = '-1-languages'
language_taxonomy.save(update_fields=["export_id"])


class Migration(migrations.Migration):

dependencies = [
('oel_tagging', '0015_taxonomy_export_id'),
]

operations = [
migrations.RenameField(
model_name='objecttag',
old_name='_name',
new_name='_export_id',
),
migrations.RunPython(migrate_export_id, reverse_export_id),
migrations.AlterField(
model_name='objecttag',
name='taxonomy',
field=models.ForeignKey(blank=True, default=None, help_text="Taxonomy that this object tag belongs to. Used for validating the tag and provides the tag's 'name' if set.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_tagging.taxonomy'),
),
migrations.AlterField(
model_name='objecttag',
name='_export_id',
field=openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text='User-facing label used for this tag, stored in case taxonomy is (or becomes) null. If the taxonomy field is set, then taxonomy.export_id takes precedence over this field.', max_length=255),
),
migrations.RunPython(migrate_language_export_id, reverse_language_export_id),
]
Loading

0 comments on commit a800b68

Please sign in to comment.