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 %}
+
+{% 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 }}
+
+
+
+
+ View all {{ relation_type.persondocumentrelation_set.count }} {{ relation_type }} relations
+
+ {% for rel in relation_type.persondocumentrelation_set.all %}
+
+
+
+ {% empty %}
+
+
+
+ {% 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;
+}