add event organizer merge tool

This commit is contained in:
2026-07-05 17:28:00 +02:00
parent 7ac332e352
commit e57eb48c88
5 changed files with 619 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
from django import forms
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.models import ReferenceIndex, Revision
from dnscms.utils import slugify
from events.models import EventOrganizer, EventOrganizerLink, EventPage
from events.views import event_organizer_chooser_viewset
def _check_relations_are_handled():
"""Refuse to merge if EventOrganizer has gained a relation this module doesn't handle."""
for relation in EventOrganizer._meta.related_objects:
if relation.many_to_many and relation.through is EventOrganizerLink:
continue
if relation.related_model is EventOrganizerLink and relation.field.name == "organizer":
continue
raise NotImplementedError(
f"merge_event_organizers does not handle the {relation.name!r} relation"
)
def _repoint_links(survivor, loser):
loser_links = EventOrganizerLink.objects.filter(organizer=loser)
affected_event_ids = set(loser_links.values_list("event_id", flat=True))
# drop the loser's link where the event links both, to respect the unique constraint
doubly_linked = EventOrganizerLink.objects.filter(
organizer=survivor, event_id__in=affected_event_ids
).values_list("event_id", flat=True)
loser_links.filter(event_id__in=doubly_linked).delete()
loser_links.update(organizer=survivor)
return affected_event_ids
def _rewrite_revisions(survivor, loser):
"""Rewrite organizer ids in revision JSON, which would otherwise dangle after the delete."""
affected_event_ids = set()
event_content_type = ContentType.objects.get_for_model(EventPage)
for revision in Revision.objects.filter(content_type=event_content_type).iterator():
links = revision.content.get("organizer_links") or []
if not any(link.get("organizer") == loser.pk for link in links):
continue
seen_organizer_ids = set()
rewritten = []
for link in links:
if link.get("organizer") == loser.pk:
link = {**link, "organizer": survivor.pk}
if link.get("organizer") in seen_organizer_ids:
continue
seen_organizer_ids.add(link.get("organizer"))
rewritten.append(link)
revision.content["organizer_links"] = rewritten
revision.save(update_fields=["content"])
affected_event_ids.add(int(revision.object_id))
return affected_event_ids
@transaction.atomic
def merge_event_organizers(organizer_a, organizer_b, *, name=None, slug=None):
"""
Merge two organizers, keeping the one with the lowest primary key. Event
links and event page revisions are repointed before the other organizer is
deleted. ``name``/``slug`` override the survivor's fields; a blank
``association``/``external_url`` is filled in from the deleted organizer.
"""
if organizer_a.pk == organizer_b.pk:
raise ValueError("Cannot merge an organizer with itself.")
_check_relations_are_handled()
survivor, loser = sorted([organizer_a, organizer_b], key=lambda organizer: organizer.pk)
affected_event_ids = _repoint_links(survivor, loser)
affected_event_ids |= _rewrite_revisions(survivor, loser)
if name:
survivor.name = name
if slug:
survivor.slug = slug
if not survivor.association_id:
survivor.association_id = loser.association_id
if not survivor.external_url:
survivor.external_url = loser.external_url
survivor.save()
loser.delete()
# the bulk link update bypasses the save-time reference index maintenance
for event in EventPage.objects.filter(pk__in=affected_event_ids):
ReferenceIndex.create_or_update_for_object(event)
return survivor
CUSTOM_VALUE = "__custom__"
class MergeOrganizersSelectForm(forms.Form):
organizer_a = forms.ModelChoiceField(
label=_("First organizer"),
queryset=EventOrganizer.objects.all(),
widget=event_organizer_chooser_viewset.widget_class(),
)
organizer_b = forms.ModelChoiceField(
label=_("Second organizer"),
queryset=EventOrganizer.objects.all(),
widget=event_organizer_chooser_viewset.widget_class(),
)
def clean(self):
cleaned = super().clean()
organizer_a = cleaned.get("organizer_a")
organizer_b = cleaned.get("organizer_b")
if organizer_a and organizer_b and organizer_a.pk == organizer_b.pk:
raise ValidationError(_("Pick two different organizers."))
return cleaned
class MergeOrganizersConfirmForm(forms.Form):
name = forms.ChoiceField(label=_("Name to keep"), widget=forms.RadioSelect)
name_custom = forms.CharField(label=_("Custom name"), required=False, max_length=100)
slug = forms.ChoiceField(label=_("Slug to keep"), widget=forms.RadioSelect)
slug_custom = forms.CharField(label=_("Custom slug"), required=False, max_length=255)
def __init__(self, *args, survivor, loser, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].choices = self._choices(survivor.name, loser.name)
self.fields["name"].initial = survivor.name
self.fields["slug"].choices = self._choices(survivor.slug, loser.slug)
self.fields["slug"].initial = survivor.slug
@staticmethod
def _choices(*values):
unique_values = dict.fromkeys(values)
return [(value, value) for value in unique_values] + [
(CUSTOM_VALUE, _("Something else (enter below)"))
]
def _resolve(self, cleaned, field):
choice = cleaned.get(field)
custom = (cleaned.get(f"{field}_custom") or "").strip()
if choice == CUSTOM_VALUE:
if not custom:
self.add_error(
f"{field}_custom", _("Enter a value, or pick an existing one above.")
)
return custom
return choice
def clean(self):
cleaned = super().clean()
cleaned["final_name"] = self._resolve(cleaned, "name")
cleaned["final_slug"] = slugify(self._resolve(cleaned, "slug") or "")
return cleaned
class MergeOrganizersSelectView(WagtailAdminTemplateMixin, FormView):
template_name = "events/merge_organizers_select.html"
form_class = MergeOrganizersSelectForm
page_title = _("Merge organizers")
header_icon = "group"
def form_valid(self, form):
params = urlencode(
{
"organizer_a": form.cleaned_data["organizer_a"].pk,
"organizer_b": form.cleaned_data["organizer_b"].pk,
}
)
return redirect(f"{reverse('events_merge_organizers_confirm')}?{params}")
class MergeOrganizersConfirmView(WagtailAdminTemplateMixin, FormView):
template_name = "events/merge_organizers_confirm.html"
form_class = MergeOrganizersConfirmForm
page_title = _("Merge organizers")
header_icon = "group"
def dispatch(self, request, *args, **kwargs):
try:
pks = {int(request.GET.get(param, "")) for param in ("organizer_a", "organizer_b")}
except ValueError:
pks = set()
organizers = list(EventOrganizer.objects.filter(pk__in=pks).order_by("pk"))
if len(organizers) != 2:
messages.error(request, _("Pick two different organizers to merge."))
return redirect("events_merge_organizers")
self.survivor, self.loser = organizers
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
return {**super().get_form_kwargs(), "survivor": self.survivor, "loser": self.loser}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["survivor"] = self.survivor
context["loser"] = self.loser
context["survivor_event_count"] = self.survivor.organized_events.count()
context["loser_event_count"] = self.loser.organized_events.count()
return context
def form_valid(self, form):
survivor = merge_event_organizers(
self.survivor,
self.loser,
name=form.cleaned_data["final_name"],
slug=form.cleaned_data["final_slug"],
)
messages.success(
self.request,
_('Merged "%(loser)s" into "%(survivor)s".')
% {"loser": self.loser.name, "survivor": survivor.name},
)
return redirect(reverse("wagtailsnippets_events_eventorganizer:list"))
@@ -0,0 +1,55 @@
{% extends "wagtailadmin/generic/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block main_content %}
<table class="listing">
<thead>
<tr>
<th>{% trans "Outcome" %}</th>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Slug" %}</th>
<th>{% trans "Association" %}</th>
<th>{% trans "External URL" %}</th>
<th>{% trans "Events" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>{% trans "Survives" %}</strong></td>
<td>{{ survivor.pk }}</td>
<td>{{ survivor.name }}</td>
<td>{{ survivor.slug }}</td>
<td>{{ survivor.association|default:"—" }}</td>
<td>{{ survivor.external_url|default:"—" }}</td>
<td>{{ survivor_event_count }}</td>
</tr>
<tr>
<td><strong>{% trans "Is deleted" %}</strong></td>
<td>{{ loser.pk }}</td>
<td>{{ loser.name }}</td>
<td>{{ loser.slug }}</td>
<td>{{ loser.association|default:"—" }}</td>
<td>{{ loser.external_url|default:"—" }}</td>
<td>{{ loser_event_count }}</td>
</tr>
</tbody>
</table>
<p class="help-block help-warning">
{% blocktrans with loser_name=loser.name survivor_name=survivor.name trimmed %}
Events organized by “{{ loser_name }}” will be moved to “{{ survivor_name }}”,
and “{{ loser_name }}” will be deleted. This cannot be undone.
{% endblocktrans %}
</p>
<form method="post" novalidate>
{% csrf_token %}
<ul class="fields">
{% for field in form.visible_fields %}
<li>{% formattedfield field %}</li>
{% endfor %}
</ul>
<button type="submit" class="button serious">{% trans "Merge organizers" %}</button>
</form>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "wagtailadmin/generic/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block main_content %}
<p>
{% trans "Pick the two organizers to merge. The one with the lowest ID survives; the other one is deleted after its events have been moved over." %}
</p>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="help-block help-critical">{{ form.non_field_errors }}</div>
{% endif %}
<ul class="fields">
{% for field in form.visible_fields %}
<li>{% formattedfield field %}</li>
{% endfor %}
</ul>
<button type="submit" class="button">{% trans "Continue" %}</button>
</form>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}
{% block extra_css %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
+38
View File
@@ -1,8 +1,46 @@
from django.urls import path, reverse_lazy
from django.utils.translation import gettext_lazy as _
from wagtail import hooks from wagtail import hooks
from wagtail.admin.auth import user_passes_test
from wagtail.admin.menu import MenuItem
from .admin import event_sidebar_viewset, event_explorer_viewset from .admin import event_sidebar_viewset, event_explorer_viewset
from .merge_organizers import MergeOrganizersConfirmView, MergeOrganizersSelectView
from .views import event_organizer_chooser_viewset from .views import event_organizer_chooser_viewset
superuser_only = user_passes_test(lambda user: user.is_superuser)
class SuperuserMenuItem(MenuItem):
def is_shown(self, request):
return request.user.is_superuser
@hooks.register("register_admin_urls")
def register_merge_organizers_urls():
return [
path(
"merge-organizers/",
superuser_only(MergeOrganizersSelectView.as_view()),
name="events_merge_organizers",
),
path(
"merge-organizers/confirm/",
superuser_only(MergeOrganizersConfirmView.as_view()),
name="events_merge_organizers_confirm",
),
]
@hooks.register("register_settings_menu_item")
def register_merge_organizers_menu_item():
return SuperuserMenuItem(
_("Merge organizers"),
reverse_lazy("events_merge_organizers"),
icon_name="group",
order=1100,
)
@hooks.register("register_admin_viewset") @hooks.register("register_admin_viewset")
def register_viewset(): def register_viewset():
+275
View File
@@ -0,0 +1,275 @@
import pytest
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from wagtail.models import ReferenceIndex
from events.merge_organizers import CUSTOM_VALUE, merge_event_organizers
from events.models import EventOrganizer, EventOrganizerLink, EventPage
from tests.conftest import AssociationPageFactory, EventPageFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def organizers(db):
keeper = EventOrganizer.objects.create(name="Kulturutvalget", slug="kulturutvalget")
goner = EventOrganizer.objects.create(name="KU", slug="ku")
return keeper, goner
@pytest.fixture
def editor_client(client, django_user_model):
user = django_user_model.objects.create_user(username="editor", password="pw")
user.user_permissions.add(Permission.objects.get(codename="access_admin"))
client.force_login(user)
return client
def test_merge_repoints_event_links_and_deletes_loser(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
merged = merge_event_organizers(keeper, goner)
assert merged.pk == keeper.pk
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_lowest_pk_survives_regardless_of_argument_order(organizers):
keeper, goner = organizers
merged = merge_event_organizers(goner, keeper)
assert merged.pk == keeper.pk
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
def test_merge_applies_chosen_name_and_slug(organizers):
keeper, goner = organizers
merged = merge_event_organizers(keeper, goner, name="KU", slug="ku")
assert merged.name == "KU"
assert merged.slug == "ku"
def test_merge_keeps_survivor_name_and_slug_by_default(organizers):
keeper, goner = organizers
merged = merge_event_organizers(keeper, goner)
assert merged.name == "Kulturutvalget"
assert merged.slug == "kulturutvalget"
def test_merge_collapses_duplicate_links_on_same_event(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=keeper)
EventOrganizerLink.objects.create(event=event, organizer=goner)
merge_event_organizers(keeper, goner)
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_merge_fills_blank_fields_from_loser(association_index, organizers):
keeper, goner = organizers
association = AssociationPageFactory(parent=association_index)
goner.association = association
goner.external_url = "https://example.com"
goner.save()
merged = merge_event_organizers(keeper, goner)
assert merged.association_id == association.pk
assert merged.external_url == "https://example.com"
def test_merge_keeps_survivor_fields_when_set(organizers):
keeper, goner = organizers
keeper.external_url = "https://keeper.example.com"
keeper.save()
goner.external_url = "https://goner.example.com"
goner.save()
merged = merge_event_organizers(keeper, goner)
assert merged.external_url == "https://keeper.example.com"
def test_merge_rewrites_event_page_revisions(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
revision = EventPage.objects.get(pk=event.pk).save_revision()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [goner.pk]
merge_event_organizers(keeper, goner)
revision.refresh_from_db()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [keeper.pk]
def test_merge_dedupes_organizers_within_revisions(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=keeper)
EventOrganizerLink.objects.create(event=event, organizer=goner)
revision = EventPage.objects.get(pk=event.pk).save_revision()
merge_event_organizers(keeper, goner)
revision.refresh_from_db()
assert [link["organizer"] for link in revision.content["organizer_links"]] == [keeper.pk]
def test_merge_updates_reference_index(event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
ReferenceIndex.create_or_update_for_object(EventPage.objects.get(pk=event.pk))
organizer_ct = ContentType.objects.get_for_model(EventOrganizer)
assert ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(goner.pk)
).exists()
merge_event_organizers(keeper, goner)
assert not ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(goner.pk)
).exists()
assert ReferenceIndex.objects.filter(
to_content_type=organizer_ct, to_object_id=str(keeper.pk)
).exists()
def test_merge_refuses_same_organizer(organizers):
keeper, _ = organizers
with pytest.raises(ValueError):
merge_event_organizers(keeper, keeper)
def test_merge_views_require_superuser(editor_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
urls = [
reverse("events_merge_organizers"),
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
]
for url in urls:
response = editor_client.get(url)
assert response.status_code == 302
assert response.url == reverse("wagtailadmin_home")
assert EventOrganizer.objects.filter(pk=goner.pk).exists()
def test_select_view_redirects_to_confirm(admin_client, organizers):
keeper, goner = organizers
response = admin_client.post(
reverse("events_merge_organizers"),
{"organizer_a": keeper.pk, "organizer_b": goner.pk},
)
assert response.status_code == 302
confirm_url = reverse("events_merge_organizers_confirm")
assert response.url == f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}"
def test_select_view_rejects_same_organizer_twice(admin_client, organizers):
keeper, _ = organizers
response = admin_client.post(
reverse("events_merge_organizers"),
{"organizer_a": keeper.pk, "organizer_b": keeper.pk},
)
assert response.status_code == 200
assert "Pick two different organizers." in response.content.decode()
def test_confirm_view_shows_both_organizers(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.get(f"{confirm_url}?organizer_a={goner.pk}&organizer_b={keeper.pk}")
content = response.content.decode()
assert response.status_code == 200
assert "Kulturutvalget" in content
assert "KU" in content
def test_confirm_view_rejects_missing_or_equal_organizers(admin_client, organizers):
keeper, _ = organizers
confirm_url = reverse("events_merge_organizers_confirm")
queries = [
"",
f"?organizer_a={keeper.pk}&organizer_b={keeper.pk}",
f"?organizer_a=abc&organizer_b={keeper.pk}",
f"?organizer_a={keeper.pk}&organizer_b=999999",
]
for query in queries:
response = admin_client.get(confirm_url + query)
assert response.status_code == 302
assert response.url == reverse("events_merge_organizers")
def test_confirm_view_merges_with_picked_values(admin_client, event_index, organizers):
keeper, goner = organizers
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=goner)
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{"name": goner.name, "name_custom": "", "slug": goner.slug, "slug_custom": ""},
)
assert response.status_code == 302
assert response.url == reverse("wagtailsnippets_events_eventorganizer:list")
keeper.refresh_from_db()
assert keeper.name == "KU"
assert keeper.slug == "ku"
assert not EventOrganizer.objects.filter(pk=goner.pk).exists()
assert list(event.organizer_links.values_list("organizer_id", flat=True)) == [keeper.pk]
def test_confirm_view_merges_with_custom_values(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{
"name": CUSTOM_VALUE,
"name_custom": "Kulturutvalget (KU)",
"slug": CUSTOM_VALUE,
"slug_custom": "Kulturutvalget KU",
},
)
assert response.status_code == 302
keeper.refresh_from_db()
assert keeper.name == "Kulturutvalget (KU)"
assert keeper.slug == "kulturutvalget-ku"
def test_confirm_view_requires_custom_value_when_picked(admin_client, organizers):
keeper, goner = organizers
confirm_url = reverse("events_merge_organizers_confirm")
response = admin_client.post(
f"{confirm_url}?organizer_a={keeper.pk}&organizer_b={goner.pk}",
{"name": CUSTOM_VALUE, "name_custom": "", "slug": keeper.slug, "slug_custom": ""},
)
assert response.status_code == 200
assert EventOrganizer.objects.filter(pk=goner.pk).exists()