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.admin.auth import user_passes_test
from wagtail.admin.menu import MenuItem
from .admin import event_sidebar_viewset, event_explorer_viewset
from .merge_organizers import MergeOrganizersConfirmView, MergeOrganizersSelectView
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")
def register_viewset():