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