diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 28f5cf50..53cc11ec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ Changelog The previous behavior for this was that by default tag items were not ordered. In practice tag items often end up ordered by creation date anyways, just due to how databases work, but this was not a guarantee. If you wish to have the old behavior, set ``ordering=[]`` to your ``TaggableManager`` instance. We believe that this should not cause a noticable performance change, and the number of queries involved should not change. +* Added the ability to merge tags via the admin * Add Django 5.0 support (no code changes were needed, but now we test this release). * Add Python 3.12 support diff --git a/docs/admin.rst b/docs/admin.rst index ea4c0cf6..cb8084ca 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -39,3 +39,18 @@ method to the :class:`~django.contrib.admin.ModelAdmin`, using def tag_list(self, obj): return u", ".join(o.name for o in obj.tags.all()) + + +Merging tags in the admin +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Functionality has been added to the admin app to allow for tag "merging". Multiple tags can be selected, and all of their usages will be replaced by the tag that you choose to use. + +To merge your tags follow these steps: + +1. Navigate to the Tags page inside of the Taggit app +2. Select the tags that you want to merge +3. Use the dropdown action list and select `Merge selected tags` and then click `Go` +4. This will redirect you onto a new page where you can insert the new tag name. +5. Click `Merge Tags` +6. This will redirect you back to the tag list diff --git a/taggit/admin.py b/taggit/admin.py index a9339cfb..408a3d42 100644 --- a/taggit/admin.py +++ b/taggit/admin.py @@ -1,7 +1,12 @@ from django.contrib import admin +from django.db import transaction +from django.shortcuts import redirect, render +from django.urls import path from taggit.models import Tag, TaggedItem +from .forms import MergeTagsForm + class TaggedItemInline(admin.StackedInline): model = TaggedItem @@ -14,3 +19,68 @@ class TagAdmin(admin.ModelAdmin): ordering = ["name", "slug"] search_fields = ["name"] prepopulated_fields = {"slug": ["name"]} + actions = ["render_tag_form"] + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "merge-tags/", + self.admin_site.admin_view(self.merge_tags_view), + name="taggit_tag_merge_tags", + ), + ] + return custom_urls + urls + + @admin.action(description="Merge selected tags") + def render_tag_form(self, request, queryset): + selected = request.POST.getlist(admin.helpers.ACTION_CHECKBOX_NAME) + if not selected: + self.message_user(request, "Please select at least one tag.") + return redirect(request.get_full_path()) + + selected_tag_ids = ",".join(selected) + redirect_url = f"{request.get_full_path()}merge-tags/" + + request.session["selected_tag_ids"] = selected_tag_ids + + return redirect(redirect_url) + + def merge_tags_view(self, request): + selected_tag_ids = request.session.get("selected_tag_ids", "").split(",") + if request.method == "POST": + form = MergeTagsForm(request.POST) + if form.is_valid(): + new_tag_name = form.cleaned_data["new_tag_name"] + new_tag, created = Tag.objects.get_or_create(name=new_tag_name) + with transaction.atomic(): + for tag_id in selected_tag_ids: + tag = Tag.objects.get(id=tag_id) + tagged_items = TaggedItem.objects.filter(tag=tag) + for tagged_item in tagged_items: + if TaggedItem.objects.filter( + tag=new_tag, + content_type=tagged_item.content_type, + object_id=tagged_item.object_id, + ).exists(): + # we have the new tag as well, so we can just + # remove the tag association + tagged_item.delete() + else: + # point this taggedItem to the new one + tagged_item.tag = new_tag + tagged_item.save() + + self.message_user(request, "Tags have been merged", level="success") + # clear the selected_tag_ids from session after merge is complete + request.session.pop("selected_tag_ids", None) + + return redirect("..") + else: + self.message_user(request, "Form is invalid.", level="error") + + context = { + "form": MergeTagsForm(), + "selected_tag_ids": selected_tag_ids, + } + return render(request, "admin/taggit/merge_tags_form.html", context) diff --git a/taggit/forms.py b/taggit/forms.py index cd68db6d..863fe943 100644 --- a/taggit/forms.py +++ b/taggit/forms.py @@ -52,3 +52,12 @@ def has_changed(self, initial_value, data_value): initial_value.sort() return initial_value != data_value + + +class MergeTagsForm(forms.Form): + new_tag_name = forms.CharField( + label="New Tag Name", + max_length=100, + widget=forms.TextInput(attrs={"id": "id_new_tag_name"}), + help_text="Enter new or existing tag name", + ) diff --git a/taggit/templates/admin/taggit/merge_tags_form.html b/taggit/templates/admin/taggit/merge_tags_form.html new file mode 100644 index 00000000..3a57d4be --- /dev/null +++ b/taggit/templates/admin/taggit/merge_tags_form.html @@ -0,0 +1,31 @@ +{% extends "admin/base.html" %} {% block content %} +
+
+
+
+
+ {% csrf_token %} {% for field in form %} +
+ {{ field.label_tag }} {{ field }} {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +

Enter new or existing tag name

+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/tests/test_admin.py b/tests/test_admin.py index 3b33c829..d36a6249 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -2,6 +2,8 @@ from django.test import TestCase from django.urls import reverse +from taggit.models import Tag + from .models import Food @@ -10,6 +12,11 @@ def setUp(self): super().setUp() self.apple = Food.objects.create(name="apple") self.apple.tags.add("Red", "red") + self.pear = Food.objects.create(name="pear") + self.pear.tags.add("red", "RED") + self.peach = Food.objects.create(name="peach") + self.peach.tags.add("red", "Yellow") + user = User.objects.create_superuser( username="admin", email="admin@mailinator.com", password="password" ) @@ -40,3 +47,33 @@ def test_get_change(self): reverse("admin:tests_food_change", args=(self.apple.pk,)) ) self.assertEqual(response.status_code, 200) + + def test_tag_merging(self): + response = self.client.get(reverse("admin:taggit_tag_changelist")) + + # merging red and RED into Red + pks_to_select = [Tag.objects.get(name="red").pk, Tag.objects.get(name="RED").pk] + response = self.client.post( + reverse("admin:taggit_tag_changelist"), + data={"action": "render_tag_form", "_selected_action": pks_to_select}, + ) + # we're redirecting + self.assertEqual(response.status_code, 302) + # make sure what we expected got into the session keys + assert "selected_tag_ids" in self.client.session.keys() + self.assertEqual( + self.client.session["selected_tag_ids"], ",".join(map(str, pks_to_select)) + ) + + # let's do the actual merge operation + response = self.client.post( + reverse("admin:taggit_tag_merge_tags"), {"new_tag_name": "Red"} + ) + self.assertEqual(response.status_code, 302) + + # time to check the result of the merges + self.assertSetEqual({tag.name for tag in self.apple.tags.all()}, {"Red"}) + self.assertSetEqual({tag.name for tag in self.pear.tags.all()}, {"Red"}) + self.assertSetEqual( + {tag.name for tag in self.peach.tags.all()}, {"Yellow", "Red"} + )