Files
neuf-www/dnscms/events/merge_organizers.py
T

222 lines
8.7 KiB
Python

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"))