diff --git a/geniza/entities/admin.py b/geniza/entities/admin.py index 307595445..d2eb328ea 100644 --- a/geniza/entities/admin.py +++ b/geniza/entities/admin.py @@ -50,7 +50,7 @@ PlacePlaceRelation, PlacePlaceRelationType, ) -from geniza.entities.views import PersonMerge +from geniza.entities.views import PersonDocumentRelationTypeMerge, PersonMerge from geniza.footnotes.models import Footnote @@ -417,6 +417,43 @@ class PersonDocumentRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): search_fields = ("name",) ordering = ("name",) + @admin.display(description="Merge selected Person-Document relationships") + def merge_person_document_relation_types(self, request, queryset=None): + """Admin action to merge selected person-document relation types. This + action redirects to an intermediate page, which displays a form to + review for confirmation and choose the primary person before merging. + """ + selected = request.POST.getlist("_selected_action") + if len(selected) < 2: + messages.error( + request, + "You must select at least two person-document relationships to merge", + ) + return HttpResponseRedirect( + reverse("admin:entities_persondocumentrelationtype_changelist") + ) + return HttpResponseRedirect( + "%s?ids=%s" + % ( + reverse("admin:person-document-relation-type-merge"), + ",".join(selected), + ), + status=303, + ) # status code 303 means "See Other" + + def get_urls(self): + """Return admin urls; adds custom URL for merging""" + urls = [ + path( + "merge/", + PersonDocumentRelationTypeMerge.as_view(), + name="person-document-relation-type-merge", + ), + ] + return urls + super().get_urls() + + actions = (merge_person_document_relation_types,) + @admin.register(PersonPersonRelationType) class PersonPersonRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): diff --git a/geniza/entities/forms.py b/geniza/entities/forms.py index 86e7be2f1..c9ac5f253 100644 --- a/geniza/entities/forms.py +++ b/geniza/entities/forms.py @@ -54,6 +54,45 @@ def __init__(self, *args, **kwargs): ) +class PersonDocumentRelationTypeChoiceField(forms.ModelChoiceField): + """Add a summary of each PersonDocumentRelationType to a form (used for merging)""" + + label_template = get_template( + "entities/snippets/persondocumentrelationtype_option_label.html" + ) + + def label_from_instance(self, relation_type): + return self.label_template.render({"relation_type": relation_type}) + + +class PersonDocumentRelationTypeMergeForm(forms.Form): + primary_relation_type = PersonDocumentRelationTypeChoiceField( + label="Select primary person-document relationship", + queryset=None, + help_text=( + "Select the primary person-document relationship, which will be " + "used as the canonical entry. All associated relations and log " + "entries will be combined on the primary relationship." + ), + empty_label=None, + widget=forms.RadioSelect, + ) + + def __init__(self, *args, **kwargs): + ids = kwargs.get("ids", []) + + # Remove the added kwarg so that the super method doesn't error + try: + del kwargs["ids"] + except KeyError: + pass + + super().__init__(*args, **kwargs) + self.fields[ + "primary_relation_type" + ].queryset = PersonDocumentRelationType.objects.filter(id__in=ids) + + class PersonPersonForm(forms.ModelForm): class Meta: model = PersonPersonRelation diff --git a/geniza/entities/models.py b/geniza/entities/models.py index 73cedf4c3..e730f39ff 100644 --- a/geniza/entities/models.py +++ b/geniza/entities/models.py @@ -11,7 +11,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.validators import RegexValidator -from django.db import models +from django.db import IntegrityError, models from django.db.models import F, Q, Value from django.db.models.query import Prefetch from django.forms import ValidationError @@ -1025,6 +1025,9 @@ class PersonDocumentRelationType(models.Model): name = models.CharField(max_length=255, unique=True) objects = PersonDocumentRelationTypeManager() + log_entries = GenericRelation( + LogEntry, related_query_name="persondocumentrelationtype" + ) class Meta: verbose_name = "Person-Document relationship" @@ -1041,6 +1044,64 @@ def objects_by_label(cls): for obj in cls.objects.all() } + def merge_with(self, merge_relation_types, user=None): + """Merge the specified relation types into this one. Combines all + relationships into this relation type and creates a log entry + documenting the merge. + + Closely adapted from :class:`Person` merge.""" + + # if user is not specified, log entry will be associated with script + if user is None: + user = User.objects.get(username=settings.SCRIPT_USERNAME) + + for rel_type in merge_relation_types: + # combine log entries + for log_entry in rel_type.log_entries.all(): + # annotate and reassociate + # - modify change message to type which object this event applied to + log_entry.change_message = "%s [merged type %s (id = %d)]" % ( + log_entry.get_change_message(), + str(rel_type), + rel_type.pk, + ) + + # - associate with the primary relation type + log_entry.object_id = self.id + log_entry.content_type_id = ContentType.objects.get_for_model( + PersonDocumentRelationType + ) + log_entry.save() + + # combine person-document relationships + for relationship in rel_type.persondocumentrelation_set.all(): + relationship.type = self + # handle unique constraint violation (one relationship per type + # between doc and person): only reassign type if it doesn't + # create a duplicate, otherwise delete. + try: + relationship.save() + except IntegrityError: + relationship.delete() + + # save current relation type with changes; delete merged relation types + self.save() + merged_types = ", ".join([str(rel_type) for rel_type in merge_relation_types]) + for rel_type in merge_relation_types: + rel_type.delete() + # create log entry documenting the merge; include rationale + pdrtype_contenttype = ContentType.objects.get_for_model( + PersonDocumentRelationType + ) + LogEntry.objects.log_action( + user_id=user.id, + content_type_id=pdrtype_contenttype.pk, + object_id=self.pk, + object_repr=str(self), + change_message="merged with %s" % (merged_types,), + action_flag=CHANGE, + ) + class PersonDocumentRelation(models.Model): """A relationship between a person and a document.""" diff --git a/geniza/entities/templates/admin/entities/persondocumentrelationtype/merge.html b/geniza/entities/templates/admin/entities/persondocumentrelationtype/merge.html new file mode 100644 index 000000000..e1bd360c8 --- /dev/null +++ b/geniza/entities/templates/admin/entities/persondocumentrelationtype/merge.html @@ -0,0 +1,53 @@ +{% extends 'admin/base_site.html' %} + +{% load admin_urls static %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} + +{% block title %} Merge selected person-document relationships {% endblock %} + +{% block breadcrumbs %} + {% if not is_popup %} + + {% endif %} +{% endblock %} + + +{% block content_title %} +

Merge selected person-document relationships

+

Note: there is no automated way to unmerge! Please review to make sure these relationships should be merged before submitting the form.

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% if form.errors|length > 0 %} +

+ Please correct the error below. +

+ {% endif %} +
+
+ {{ form.primary_relation_type.label_tag }} + {{ form.primary_relation_type }} +

{{ form.primary_relation_type.help_text|safe }}

+
+ +
+ +
+ + +{% endblock %} diff --git a/geniza/entities/templates/entities/snippets/persondocumentrelationtype_option_label.html b/geniza/entities/templates/entities/snippets/persondocumentrelationtype_option_label.html new file mode 100644 index 000000000..c691ea08b --- /dev/null +++ b/geniza/entities/templates/entities/snippets/persondocumentrelationtype_option_label.html @@ -0,0 +1,33 @@ +{# template snippet for displaying person-document relationship label on a form #} +{# used on merge form to provide enough information to merge accurately #} + +
+

{{ relation_type }} + + Change +

+
+ View all {{ relation_type.persondocumentrelation_set.count }} {{ relation_type }} relations +
    + {% for rel in relation_type.persondocumentrelation_set.all %} +
  1. +
    + + +
    +
  2. + {% empty %} +
  3. +
    +
    +
    +
  4. + {% endfor %} +
+
+
diff --git a/geniza/entities/tests/test_entities_models.py b/geniza/entities/tests/test_entities_models.py index aaed8df00..c7c4b7ec6 100644 --- a/geniza/entities/tests/test_entities_models.py +++ b/geniza/entities/tests/test_entities_models.py @@ -7,7 +7,6 @@ from django.contrib.contenttypes.models import ContentType from django.forms import ValidationError from django.utils import timezone -from modeltranslation.manager import MultilingualQuerySet from parasolr.django.indexing import ModelIndexable from slugify import slugify from unidecode import unidecode @@ -706,6 +705,82 @@ def test_str(self): assert str(relation) == f"{recipient} relation: {goitein} and {doc}" +@pytest.mark.django_db(transaction=True) +class TestPersonDocumentRelationType: + def test_merge_with(self, person, person_multiname, document, join): + # create two PersonDocumentRelationTypes and some associations + rel_type = PersonDocumentRelationType.objects.create(name="test") + type_2 = PersonDocumentRelationType.objects.create(name="to be merged") + type_3 = PersonDocumentRelationType.objects.create(name="also merge me") + PersonDocumentRelation.objects.create( + type=rel_type, person=person, document=document + ) + PersonDocumentRelation.objects.create( + type=type_2, person=person, document=document + ) + PersonDocumentRelation.objects.create( + type=type_2, person=person_multiname, document=document + ) + PersonDocumentRelation.objects.create(type=type_3, person=person, document=join) + + # create some log entries + pdrtype_contenttype = ContentType.objects.get_for_model( + PersonDocumentRelationType + ) + creation_date = timezone.make_aware(datetime(2023, 10, 12)) + creator = User.objects.get_or_create(username="editor")[0] + type_2_str = str(type_2) + type_2_pk = type_2.pk + LogEntry.objects.bulk_create( + [ + LogEntry( + user_id=creator.id, + content_type_id=pdrtype_contenttype.pk, + object_id=type_2_pk, + object_repr=type_2_str, + change_message="first input", + action_flag=ADDITION, + action_time=creation_date, + ), + LogEntry( + user_id=creator.id, + content_type_id=pdrtype_contenttype.pk, + object_id=type_2_pk, + object_repr=type_2_str, + change_message="major revision", + action_flag=CHANGE, + action_time=timezone.now(), + ), + ] + ) + assert rel_type.persondocumentrelation_set.count() == 1 + rel_type.merge_with([type_2, type_3]) + # should skip the duplicate and add the others + assert rel_type.persondocumentrelation_set.count() == 3 + # should delete other types and create merge log entry + assert not type_2.pk + assert not type_3.pk + assert LogEntry.objects.filter( + object_id=rel_type.pk, + change_message__contains=f"merged with {type_2}, {type_3}", + ).exists() + assert rel_type.log_entries.count() == 3 + # based on default sorting, most recent log entry will be first + # - should document the merge event + merge_log = rel_type.log_entries.first() + assert merge_log.action_flag == CHANGE + + # reassociated log entries should include merged type's name, id + assert ( + " [merged type %s (id = %s)]" % (type_2_str, type_2_pk) + in rel_type.log_entries.all()[1].change_message + ) + assert ( + " [merged type %s (id = %s)]" % (type_2_str, type_2_pk) + in rel_type.log_entries.all()[2].change_message + ) + + @pytest.mark.django_db class TestPlace: def test_str(self): diff --git a/geniza/entities/views.py b/geniza/entities/views.py index 38813dcc5..12432aba7 100644 --- a/geniza/entities/views.py +++ b/geniza/entities/views.py @@ -19,11 +19,17 @@ from geniza.corpus.dates import PartialDate from geniza.corpus.views import SolrDateRangeMixin -from geniza.entities.forms import PersonListForm, PersonMergeForm, PlaceListForm +from geniza.entities.forms import ( + PersonDocumentRelationTypeMergeForm, + PersonListForm, + PersonMergeForm, + PlaceListForm, +) from geniza.entities.models import ( PastPersonSlug, PastPlaceSlug, Person, + PersonDocumentRelationType, PersonPersonRelationType, PersonSolrQuerySet, Place, @@ -95,6 +101,81 @@ def form_valid(self, form): return super().form_valid(form) +class PersonDocumentRelationTypeMerge(PermissionRequiredMixin, FormView): + permission_required = ( + "entities.change_persondocumentrelationtype", + "entities.delete_persondocumentrelationtype", + ) + form_class = PersonDocumentRelationTypeMergeForm + template_name = "admin/entities/persondocumentrelationtype/merge.html" + + def get_success_url(self): + return reverse( + "admin:entities_persondocumentrelationtype_change", + args=[self.primary_relation_type.pk], + ) + + def get_form_kwargs(self): + form_kwargs = super().get_form_kwargs() + form_kwargs["ids"] = self.ids + return form_kwargs + + def get_initial(self): + # Default to first relation type selected + ids = self.request.GET.get("ids", None) + if ids: + self.ids = [int(id) for id in ids.split(",")] + # by default, prefer the first record created + return {"primary_relation_type": sorted(self.ids)[0]} + else: + self.ids = [] + + def form_valid(self, form): + """Merge the selected person-document relation types into the primary one.""" + primary_relation_type = form.cleaned_data["primary_relation_type"] + self.primary_relation_type = primary_relation_type + + secondary_ids = [id for id in self.ids if id != primary_relation_type.pk] + secondary_relation_types = PersonDocumentRelationType.objects.filter( + pk__in=secondary_ids + ) + + # Get string representations before they are merged + primary_relation_str = ( + f"{str(primary_relation_type)} (id = {primary_relation_type.pk})" + ) + secondary_relation_str = ", ".join( + [f"{str(rel)} (id = {rel.pk})" for rel in secondary_relation_types] + ) + + # Merge secondary relation types into the selected primary relation type + user = getattr(self.request, "user", None) + + try: + primary_relation_type.merge_with(secondary_relation_types, user=user) + except ValidationError as err: + # in case the merge resulted in an error, display error to user + messages.error(self.request, err.message) + # redirect to this form page instead of one of the items + return HttpResponseRedirect( + "%s?ids=%s" + % ( + reverse("admin:person-document-relation-type-merge"), + self.request.GET.get("ids", ""), + ), + ) + + # Display info about the merge to the user + messages.success( + self.request, + mark_safe( + f"Successfully merged {secondary_relation_str} with {primary_relation_str}." + ), + ) + + return super().form_valid(form) + + class UnaccentedNameAutocompleteView(autocomplete.Select2QuerySetView): def get_queryset(self): """entities filtered by entered query, or all entities, ordered by name""" diff --git a/sitemedia/css/admin-local.css b/sitemedia/css/admin-local.css index c38dff87e..2c5132a6a 100644 --- a/sitemedia/css/admin-local.css +++ b/sitemedia/css/admin-local.css @@ -380,3 +380,13 @@ div.form-row.field-doc_date_original .inline-group { grid-template-columns: 1fr 1fr; } } + +.merge-relationtype input[type="radio"] { + margin-top: 0.5rem; + margin-bottom: auto; + vertical-align: top; +} +.merge-relationtype details summary { + cursor: pointer; + margin-top: 0.5rem; +}