Skip to content

Commit

Permalink
Merge pull request #904 from jazzband/899-merge-tags
Browse files Browse the repository at this point in the history
Enable Tag Merging
  • Loading branch information
rtpg authored Jul 26, 2024
2 parents b2906a9 + ba48993 commit c02ffe5
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
* Add support for dumpdata/loaddata using natural keys
Expand Down
15 changes: 15 additions & 0 deletions docs/admin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions taggit/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
9 changes: 9 additions & 0 deletions taggit/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
31 changes: 31 additions & 0 deletions taggit/templates/admin/taggit/merge_tags_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% extends "admin/base.html" %} {% block content %}
<div id="mergeTagsModal">
<div>
<div>
<div>
<form
id="merge-tags-form"
method="post"
action="{% url 'admin:taggit_tag_merge_tags' %}"
>
{% csrf_token %} {% for field in form %}
<div>
{{ field.label_tag }} {{ field }} {% if field.errors %}
<ul class="errorlist">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
<p><i><strong>Enter new or existing tag name</strong></strong></i></p>
<div>
<input type="submit" class="btn btn-primary"></input>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
37 changes: 37 additions & 0 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.test import TestCase
from django.urls import reverse

from taggit.models import Tag

from .models import Food


Expand All @@ -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"
)
Expand Down Expand Up @@ -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"}
)

0 comments on commit c02ffe5

Please sign in to comment.