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

Enable Tag Merging #904

Merged
merged 18 commits into from
Jul 26, 2024
Merged
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
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

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("..")
guel-codes marked this conversation as resolved.
Show resolved Hide resolved
else:
self.message_user(request, "Form is invalid.", level="error")
guel-codes marked this conversation as resolved.
Show resolved Hide resolved

context = {
"form": MergeTagsForm(),
guel-codes marked this conversation as resolved.
Show resolved Hide resolved
"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">
guel-codes marked this conversation as resolved.
Show resolved Hide resolved
<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="[email protected]", 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"}
)
Loading