Skip to content
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

[FC- 0049] feat: Features to enable import/export courses #172

Merged
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
Loading