diff --git a/dnscms/events/merge_organizers.py b/dnscms/events/merge_organizers.py new file mode 100644 index 0000000..60b53bd --- /dev/null +++ b/dnscms/events/merge_organizers.py @@ -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")) diff --git a/dnscms/events/templates/events/merge_organizers_confirm.html b/dnscms/events/templates/events/merge_organizers_confirm.html new file mode 100644 index 0000000..b7b6a09 --- /dev/null +++ b/dnscms/events/templates/events/merge_organizers_confirm.html @@ -0,0 +1,55 @@ +{% extends "wagtailadmin/generic/base.html" %} +{% load i18n wagtailadmin_tags %} + +{% block main_content %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Outcome" %}{% trans "ID" %}{% trans "Name" %}{% trans "Slug" %}{% trans "Association" %}{% trans "External URL" %}{% trans "Events" %}
{% trans "Survives" %}{{ survivor.pk }}{{ survivor.name }}{{ survivor.slug }}{{ survivor.association|default:"—" }}{{ survivor.external_url|default:"—" }}{{ survivor_event_count }}
{% trans "Is deleted" %}{{ loser.pk }}{{ loser.name }}{{ loser.slug }}{{ loser.association|default:"—" }}{{ loser.external_url|default:"—" }}{{ loser_event_count }}
+ +

+ {% 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 %} +

+ +
+ {% csrf_token %} + + +
+{% endblock %} diff --git a/dnscms/events/templates/events/merge_organizers_select.html b/dnscms/events/templates/events/merge_organizers_select.html new file mode 100644 index 0000000..a3173c7 --- /dev/null +++ b/dnscms/events/templates/events/merge_organizers_select.html @@ -0,0 +1,30 @@ +{% extends "wagtailadmin/generic/base.html" %} +{% load i18n wagtailadmin_tags %} + +{% block main_content %} +

+ {% 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." %} +

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + + +
+{% endblock %} + +{% block extra_js %} + {{ block.super }} + {{ form.media.js }} +{% endblock %} + +{% block extra_css %} + {{ block.super }} + {{ form.media.css }} +{% endblock %} diff --git a/dnscms/events/wagtail_hooks.py b/dnscms/events/wagtail_hooks.py index a18b8fe..2039bba 100644 --- a/dnscms/events/wagtail_hooks.py +++ b/dnscms/events/wagtail_hooks.py @@ -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(): diff --git a/dnscms/tests/test_merge_organizers.py b/dnscms/tests/test_merge_organizers.py new file mode 100644 index 0000000..9b0b948 --- /dev/null +++ b/dnscms/tests/test_merge_organizers.py @@ -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()