- Hold shift and then click and drag over the image to select a rectangular - region for the block of text you want to transcribe (this is an annotation - zone). + When using the rectangle tool, hold shift and then click and drag over the + image to select a rectangular region for the block of text you want to + transcribe (this is an annotation zone).
When an annotation zone is active on the image, use the red square in the thumbnail to move around the image when zoomed in.
++ To use the polygon tool, hold shift and then click and drag over the image + to begin creating a polygonal annotation zone. Then, release shift and + click once more for each point on the polygon. To complete the polygon, + double-click on the final point. +
Drag blocks of text to reorder or move to a different image.
diff --git a/geniza/corpus/templatetags/admin_extras.py b/geniza/corpus/templatetags/admin_extras.py new file mode 100644 index 000000000..2a8bfc0b4 --- /dev/null +++ b/geniza/corpus/templatetags/admin_extras.py @@ -0,0 +1,36 @@ +from django import template + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def get_fieldsets_and_inlines(context): + """ + Template tag to render inlines mixed with fieldsets, based on the ModelAdmin's + fieldsets_and_inlines_order property. + Adapted from code by Bertrand Bordage: https://github.com/dezede/dezede/commit/ed13cc + """ + adminform = context["adminform"] + model_admin = adminform.model_admin + adminform = list(adminform) + inlines = list(context["inline_admin_formsets"]) + + fieldsets_and_inlines = [] + for choice in getattr(model_admin, "fieldsets_and_inlines_order", ()): + if choice == "f": + if adminform: + fieldsets_and_inlines.append(("f", adminform.pop(0))) + elif choice == "i": + if inlines: + fieldsets_and_inlines.append(("i", inlines.pop(0))) + elif choice == "itt": + # special case for itt panel on document + fieldsets_and_inlines.append(("itt", None)) + + # render any remaining ones in the normal order: fieldsets, then inlines + for fieldset in adminform: + fieldsets_and_inlines.append(("f", fieldset)) + for inline in inlines: + fieldsets_and_inlines.append(("i", inline)) + + return fieldsets_and_inlines diff --git a/geniza/corpus/templatetags/corpus_extras.py b/geniza/corpus/templatetags/corpus_extras.py index 2984364ac..aaa0a0af5 100644 --- a/geniza/corpus/templatetags/corpus_extras.py +++ b/geniza/corpus/templatetags/corpus_extras.py @@ -158,3 +158,17 @@ def translate_url(context, lang_code): # thanks to https://stackoverflow.com/a/51974042 path = context.get("request").get_full_path() return django_translate_url(path, lang_code) + + +@register.filter +def has_location_or_url(footnotes): + """For scholarship records list: return True if any footnote in the list + has a URL or location.""" + return any(fn.url or fn.location for fn in footnotes) + + +@register.filter +def all_doc_relations(footnotes): + """For scholarship records list: join doc relations for all footnotes + by a comma.""" + return ", ".join(sorted(set([str(fn.doc_relation) for fn in footnotes]))) diff --git a/geniza/corpus/tests/conftest.py b/geniza/corpus/tests/conftest.py index c306d89b6..9aa727ecd 100644 --- a/geniza/corpus/tests/conftest.py +++ b/geniza/corpus/tests/conftest.py @@ -57,8 +57,8 @@ def make_document(fragment): (Information from Mediterranean Society, IV, p. 281)""", doctype=DocumentType.objects.get_or_create(name_en="Legal")[0], ) - TextBlock.objects.create(document=doc, fragment=fragment) doc.tags.add("bill of sale", "real estate") + TextBlock.objects.create(document=doc, fragment=fragment) dctype = ContentType.objects.get_for_model(Document) script_user = User.objects.get(username=settings.SCRIPT_USERNAME) team_user = User.objects.get(username=settings.TEAM_USERNAME) diff --git a/geniza/corpus/tests/test_corpus_admin.py b/geniza/corpus/tests/test_corpus_admin.py index 2f94b7d2a..e6cbf991f 100644 --- a/geniza/corpus/tests/test_corpus_admin.py +++ b/geniza/corpus/tests/test_corpus_admin.py @@ -20,8 +20,11 @@ from pytest_django.asserts import assertContains, assertNotContains from geniza.corpus.admin import ( + DateAfterListFilter, + DateBeforeListFilter, DocumentAdmin, DocumentForm, + DocumentPersonInline, FragmentAdmin, FragmentTextBlockInline, HasTranscriptionListFilter, @@ -31,12 +34,12 @@ from geniza.corpus.models import ( Collection, Document, - DocumentType, Fragment, LanguageScript, TextBlock, ) -from geniza.footnotes.models import Creator, Footnote, Source, SourceType +from geniza.entities.models import Person, PersonDocumentRelation +from geniza.footnotes.models import Footnote, Source, SourceLanguage, SourceType @pytest.mark.django_db @@ -463,3 +466,139 @@ class TestHasTranslationListFilter(TestHasTranscriptionListFilter): doc_relation = Footnote.DIGITAL_TRANSLATION model = HasTranslationListFilter name = "translation" + + def test_queryset_by_language(self, document): + filter = self.init_filter() + all_docs = Document.objects.all() + + # create an english translation + english = SourceLanguage.objects.get(name="English") + book = SourceType.objects.get(type="Book") + source_en = Source.objects.create(source_type=book) + source_en.languages.add(english) + Footnote.objects.create( + source=source_en, + content_object=document, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + # document should show up in queryset for "Has English translation" + with patch.object(filter, "value", return_value="yes_en"): + assert filter.queryset(Mock(), all_docs).filter(pk=document.pk).exists() + # document should NOT show up in queryset for "Has Hebrew translation" + with patch.object(filter, "value", return_value="yes_he"): + assert not filter.queryset(Mock(), all_docs).filter(pk=document.pk).exists() + + # create a hebrew translation + hebrew = SourceLanguage.objects.get(name="Hebrew") + source_he = Source.objects.create(source_type=book) + source_he.languages.add(hebrew) + Footnote.objects.create( + source=source_he, + content_object=document, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + # document should still show up in queryset for "Has English translation" + with patch.object(filter, "value", return_value="yes_en"): + assert filter.queryset(Mock(), all_docs).filter(pk=document.pk).exists() + # document should now also show up in queryset for "Has Hebrew translation" + with patch.object(filter, "value", return_value="yes_he"): + assert filter.queryset(Mock(), all_docs).filter(pk=document.pk).exists() + + def test_lookups(self): + assert self.init_filter().lookups(Mock(), Mock()) == ( + ("yes", f"Has {self.name}"), + ("yes_en", f"Has English {self.name}"), + ("yes_he", f"Has Hebrew {self.name}"), + ("no", f"No {self.name}"), + ) + + +@pytest.mark.django_db +class TestDateListFilter: + def test_choices(self): + # choices() should always just return "All" option + filter = DateAfterListFilter( + request=Mock(), params={}, model=Mock(), model_admin=Mock() + ) + changelist = Mock() + changelist.get_filters_params.return_value = {} + all_option = next(filter.choices(changelist)) + assert all_option["display"] == "All" + + @patch("geniza.corpus.admin.DocumentSolrQuerySet") + def test_get_queryset_date_after(self, mock_dqs): + Document.objects.create(doc_date_standard="2000") + Document.objects.create(doc_date_standard="1240") + queryset = Document.objects.all() + + # include all but one document in results + pks = [{"pgpid": doc.pk} for doc in queryset][1:] + mock_dqs.return_value.filter.return_value.only.return_value.get_results.return_value = ( + pks + ) + + date_after_filter = DateAfterListFilter( + request=Mock(), + params={DateAfterListFilter.parameter_name: "1900"}, + model=Document, + model_admin=DocumentAdmin, + ) + # call the queryset method + result_qs = date_after_filter.queryset(Mock(), queryset) + + # should filter a DocumentSolrQuerySet by date after 1900 + mock_dqs.assert_called_with() + mock_sqs = mock_dqs.return_value + mock_sqs.filter.assert_called_with(document_date_dr="[1900 TO *]") + + # since we included all but one in the result set, the resulting count should be 1 less + assert result_qs.count() == queryset.count() - 1 + + @patch("geniza.corpus.admin.DocumentSolrQuerySet") + def test_get_queryset_date_before(self, mock_dqs): + date_before_filter = DateBeforeListFilter( + request=Mock(), + params={DateBeforeListFilter.parameter_name: "1900"}, + model=Document, + model_admin=DocumentAdmin, + ) + queryset = Document.objects.all() + # call the queryset method + date_before_filter.queryset(Mock(), queryset) + # should filter a DocumentSolrQuerySet by date before 1900 + mock_dqs.assert_called_with() + mock_sqs = mock_dqs.return_value + mock_sqs.filter.assert_called_with(document_date_dr="[* TO 1900]") + + @patch("geniza.corpus.admin.messages") + def test_get_queryset_invalid_date(self, mock_messages): + # format the date incorrectly + date_after_filter = DateAfterListFilter( + request=Mock(), + params={DateAfterListFilter.parameter_name: "01/01/1900"}, + model=Document, + model_admin=DocumentAdmin, + ) + queryset = Document.objects.all() + # call the queryset method + date_after_filter.queryset(Mock(), queryset) + # error message should be displayed + mock_messages.error.assert_called() + + @patch("geniza.corpus.admin.DocumentSolrQuerySet") + def test_get_queryset_empty_result(self, mock_dqs): + # set the result to empty list [] + mock_dqs.return_value.filter.return_value.only.return_value.get_results.return_value = ( + [] + ) + date_before_filter = DateBeforeListFilter( + request=Mock(), + params={DateBeforeListFilter.parameter_name: "1900"}, + model=Document, + model_admin=DocumentAdmin, + ) + # call the queryset method + mock_qs = Mock() + result_qs = date_before_filter.queryset(Mock(), queryset=mock_qs) + # should call exclude() and then return queryset.none() + assert result_qs == mock_qs.exclude.return_value.none.return_value diff --git a/geniza/corpus/tests/test_corpus_models.py b/geniza/corpus/tests/test_corpus_models.py index 746b98cf8..e55e7bb9e 100644 --- a/geniza/corpus/tests/test_corpus_models.py +++ b/geniza/corpus/tests/test_corpus_models.py @@ -25,13 +25,14 @@ from geniza.corpus.dates import Calendar from geniza.corpus.models import ( Collection, + Dating, Document, DocumentType, Fragment, LanguageScript, TextBlock, ) -from geniza.footnotes.models import Footnote +from geniza.footnotes.models import Footnote, Source, SourceLanguage, SourceType class TestCollection: @@ -1106,6 +1107,15 @@ def test_index_data_old_shelfmarks(self, join): all_old_shelfmarks.append(fragment2.old_shelfmarks) assert index_data["fragment_old_shelfmark_ss"] == all_old_shelfmarks + def test_index_data_input_date(self): + doc = Document.objects.create() + # when no logentry exists, should still get the year from created attr + assert not LogEntry.objects.filter( + object_id=doc.pk, + content_type_id=ContentType.objects.get_for_model(doc).id, + ).exists() + assert doc.index_data()["input_year_i"] == doc.created.year + def test_editions(self, document, source): # create multiple footnotes to test filtering and sorting @@ -1214,9 +1224,61 @@ def test_digital_translations(self, document, source, twoauthor_source): # DIGITAL_EDITION, should appear in digital editions assert digital_translation.pk in digital_translation_pks assert digital_translation2.pk in digital_translation_pks - # Translation 2 should be alphabetically first based on its source + # Translation 1 should be alphabetically first based on its source title assert digital_translation.pk == digital_translation_pks[0] + def test_default_translation(self, document, source, twoauthor_source): + book = SourceType.objects.create(type="Book") + hebrew = SourceLanguage.objects.get(name="Hebrew") + hebrew_source = Source.objects.create( + title_en="Some Translations", source_type=book + ) + hebrew_source.languages.add(hebrew) + h = Footnote.objects.create( + content_object=document, + source=hebrew_source, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + eng1 = Footnote.objects.create( + content_object=document, + source=source, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + eng2 = Footnote.objects.create( + content_object=document, + source=twoauthor_source, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + + current_lang = get_language() + activate("he") + # should choose the first translation in the user's selected language + assert document.default_translation.pk == h.pk + # should be ordered alphabetically, by source title + activate("en") + assert document.default_translation.pk == eng1.pk + + # if there are none in the selected language, should choose first of all translations + # alphabetically, by source title + doc2 = Document.objects.create() + h1 = Footnote.objects.create( + content_object=doc2, + source=hebrew_source, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + hebrew_source_2 = Source.objects.create( + title_en="All Translations", source_type=book + ) + hebrew_source_2.languages.add(hebrew) + h2 = Footnote.objects.create( + content_object=doc2, + source=hebrew_source_2, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + assert doc2.default_translation.pk == h2.pk + + activate(current_lang) + def test_digital_footnotes(self, document, source): # no digital edition or digital translation, count should be 0 regular_translation = Footnote.objects.create( @@ -1422,6 +1484,114 @@ def test_document_merge_with_footnotes(document, join, source): assert document.footnotes.count() == 2 +@pytest.mark.django_db +def test_document_merge_with_annotations(document, join, source): + # create two footnotes, one with annotations and one without, on the same source + Footnote.objects.create( + content_object=document, + source=source, + location="p. 3", + doc_relation=Footnote.DIGITAL_EDITION, + ) + join_fn = Footnote.objects.create( + content_object=join, + source=source, + location="p. 3", + notes="with emendations", + doc_relation=Footnote.DIGITAL_EDITION, + ) + anno = Annotation.objects.create( + footnote=join_fn, content={"body": [{"value": "foo bar baz"}]} + ) + + assert document.footnotes.count() == 1 + assert document.footnotes.first().annotation_set.count() == 0 + assert join.footnotes.count() == 1 + assert join.footnotes.first().annotation_set.count() == 1 + document.merge_with([join], "combine footnotes/annotations") + # should still only have one footnote after merge, but now with an annotation + assert document.footnotes.count() == 1 + assert document.footnotes.first().annotation_set.count() == 1 + # it should be the above annotation but reassigned + anno.refresh_from_db() + assert anno.footnote.object_id == document.pk + # should have copied the notes over from the join fn + assert document.footnotes.first().notes == "with emendations" + + +@pytest.mark.django_db +def test_document_merge_with_annotations_no_match(document, join, source): + # create two footnotes, one digital edition and one digital translation, on the same source + Footnote.objects.create( + content_object=document, + source=source, + location="p. 3", + doc_relation=Footnote.DIGITAL_EDITION, + ) + join_fn = Footnote.objects.create( + content_object=join, + source=source, + location="p. 3", + notes="with emendations", + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + anno = Annotation.objects.create( + footnote=join_fn, content={"body": [{"value": "foo bar baz"}]} + ) + + assert document.footnotes.count() == 1 + assert document.footnotes.first().annotation_set.count() == 0 + assert join.footnotes.count() == 1 + assert join.footnotes.first().annotation_set.count() == 1 + document.merge_with([join], "combine footnotes/annotations") + # should now have two footnotes after merge + assert document.footnotes.count() == 2 + # the above annotation should be reassigned + anno.refresh_from_db() + assert anno.footnote.object_id == document.pk + + +def test_document_merge_with_empty_digital_footnote(document, join, source): + # create two digital edition footnotes on the same doc/source without annotations + Footnote.objects.create( + content_object=document, + source=source, + location="p. 3", + doc_relation=Footnote.DIGITAL_EDITION, + ) + new_footnote = Footnote.objects.create( + content_object=join, + source=source, + location="new", + doc_relation=Footnote.DIGITAL_EDITION, + ) + + assert document.footnotes.count() == 1 + assert document.digital_editions().count() == 1 + assert join.footnotes.count() == 1 + document.merge_with([join], "combine footnotes") + # should have two footnotes after the merge, since location differs + assert document.footnotes.count() == 2 + # added footnote should not be a digital edition, to prevent unique violation + assert document.digital_editions().count() == 1 + + # same should be true for a digital translation + new_footnote.refresh_from_db() + new_footnote.doc_relation = [Footnote.DIGITAL_TRANSLATION] + new_footnote.save() + assert document.digital_translations().count() == 1 + other_doc = Document.objects.create() + Footnote.objects.create( + content_object=other_doc, + source=source, + location="example", + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + document.merge_with([other_doc], "combine translations") + # added footnote should not be a digital translation, to prevent unique violation + assert document.digital_translations().count() == 1 + + def test_document_merge_with_log_entries(document, join): # create some log entries document_contenttype = ContentType.objects.get_for_model(Document) @@ -1484,6 +1654,105 @@ def test_document_merge_with_log_entries(document, join): assert " [PGPID %s]" % join_pk in moved_log.change_message +def test_document_merge_with_dates(document, join): + editor = User.objects.get_or_create(username="editor")[0] + + # clone join for additional merges + join_clones = [] + for _ in range(4): + join_clone = Document.objects.get(pk=join.pk) + join_clone.pk = None + join_clone.save() + join_clones.append(join_clone) + + # create some datings; doesn't matter that they are identical, as cleaning + # up post-merge dupes is a manual data cleanup task. unit test will make + # sure that doesn't cause errors! + dating_1 = Dating.objects.create( + document=document, + display_date="1000 CE", + standard_date="1000", + rationale=Dating.PALEOGRAPHY, + notes="a note", + ) + dating_2 = Dating.objects.create( + document=join_clone, + display_date="1000 CE", + standard_date="1000", + rationale=Dating.PALEOGRAPHY, + notes="a note", + ) + + # should raise ValidationError on conflicting dates + document.doc_date_standard = "1230-01-01" + join.doc_date_standard = "1234-01-01" + with pytest.raises(ValidationError): + document.merge_with([join], "test", editor) + + # should use any existing dates if one of the merged documents has one + join.doc_date_standard = "" + document.merge_with([join], "test", editor) + assert document.doc_date_standard == "1230-01-01" + + document.doc_date_standard = "" + document.doc_date_original = "" + document.doc_date_calendar = "" + join_clones[0].doc_date_original = "15 Tevet 4990" + join_clones[0].doc_date_calendar = Calendar.ANNO_MUNDI + document.merge_with([join_clones[0]], "test", editor) + assert document.doc_date_original == "15 Tevet 4990" + assert document.doc_date_calendar == Calendar.ANNO_MUNDI + + # should raise error if one document's standard date conflicts with other document's + # original date + document.doc_date_original = "" + document.doc_date_standard = "1230-01-01" + join_clones[1].doc_date_original = "1 Tevet 5000" + join_clones[1].doc_date_calendar = Calendar.ANNO_MUNDI + with pytest.raises(ValidationError): + document.merge_with([join_clones[1]], "test", editor) + + document.doc_date_standard = "" + document.doc_date_original = "1 Tevet 5000" + document.doc_date_calendar = Calendar.ANNO_MUNDI + join_clones[1].doc_date_original = "" + join_clones[1].doc_date_standard = "1230-01-01" + with pytest.raises(ValidationError): + document.merge_with([join_clones[1]], "test", editor) + + # should not raise error on identical dates + document.doc_date_standard = "1230-01-01" + document.doc_date_original = "15 Tevet 4990" + document.doc_date_calendar = Calendar.ANNO_MUNDI + join_clones[1].doc_date_standard = "1230-01-01" + join_clones[1].doc_date_original = "15 Tevet 4990" + join_clones[1].doc_date_calendar = Calendar.ANNO_MUNDI + document.merge_with([join_clones[1]], "test", editor) + + # should consider identical if one doc's standardized original date = other doc's standard date + document.doc_date_standard = "1230-01-01" + document.doc_date_original = "" + document.doc_date_calendar = "" + join_clones[2].doc_date_standard = "" + join_clones[2].doc_date_original = "15 Tevet 4990" + join_clones[2].doc_date_calendar = Calendar.ANNO_MUNDI + document.merge_with([join_clones[2]], "test", editor) + assert document.doc_date_original == "15 Tevet 4990" + assert document.doc_date_calendar == Calendar.ANNO_MUNDI + + document.doc_date_standard = "" + join_clones[3].doc_date_standard = "1230-01-01" + join_clones[3].doc_date_original = "" + join_clones[3].doc_date_calendar = "" + document.merge_with([join_clones[3]], "test", editor) + assert document.doc_date_standard == "1230-01-01" + + # should carry over all inferred datings without error, even if they are identical + assert document.dating_set.count() == 2 + result_pks = [dating.pk for dating in document.dating_set.all()] + assert dating_1.pk in result_pks and dating_2.pk in result_pks + + def test_document_get_by_any_pgpid(document): # get by current pk assert Document.get_by_any_pgpid(document.pk) == document diff --git a/geniza/corpus/tests/test_corpus_signals.py b/geniza/corpus/tests/test_corpus_signals.py index ef618c5f1..8547c3af5 100644 --- a/geniza/corpus/tests/test_corpus_signals.py +++ b/geniza/corpus/tests/test_corpus_signals.py @@ -103,3 +103,19 @@ def test_unidecode_tags(): # pre_save signal should strip diacritics from tag and convert to ASCII tag = Tag.objects.create(name="mu'ālim", slug="mualim") assert tag.name == "mu'alim" + + +@pytest.mark.django_db +@patch.object(ModelIndexable, "index_items") +def test_tagged_item_change(mock_indexitems, document): + tag_count = document.tags.count() + tag = Tag.objects.create(name="mu'ālim", slug="mualim") + tag2 = Tag.objects.create(name="tag2", slug="tag2") + # should reindex document with the updated set of tags on save + document.tags.add(tag) + document.tags.add(tag2) + document.save() + # should be called at least once for the document post-save & once for the tags M2M change + assert mock_indexitems.call_count >= 2 + # most recent call should have the full updated set of tags + assert mock_indexitems.call_args.args[0][0].tags.count() == tag_count + 2 diff --git a/geniza/corpus/tests/test_corpus_solrqueryset.py b/geniza/corpus/tests/test_corpus_solrqueryset.py index 0b2cdcef3..e2cd7d776 100644 --- a/geniza/corpus/tests/test_corpus_solrqueryset.py +++ b/geniza/corpus/tests/test_corpus_solrqueryset.py @@ -1,5 +1,6 @@ from unittest.mock import patch +import pytest from parasolr.django import AliasedSolrQuerySet, SolrClient from piffle.image import IIIFImageClient @@ -64,6 +65,7 @@ def test_keyword_search_exact_match(self): } ) + @pytest.mark.django_db def test_get_result_document_images(self): dqs = DocumentSolrQuerySet() mock_doc = { diff --git a/geniza/corpus/tests/test_corpus_templates.py b/geniza/corpus/tests/test_corpus_templates.py index 2472143d5..430cc2be7 100644 --- a/geniza/corpus/tests/test_corpus_templates.py +++ b/geniza/corpus/tests/test_corpus_templates.py @@ -469,7 +469,6 @@ def test_with_related_docs(self, client, document, join, empty_solr): class TestDocumentResult: - template = get_template("corpus/snippets/document_result.html") page_obj = Paginator([1], 1).page(1) @@ -637,7 +636,6 @@ def test_transcription_highlighting(self, document): class TestSearchPagination: - template = get_template("corpus/snippets/pagination.html") def test_one_page(self): @@ -705,10 +703,10 @@ def test_related_list(self, client, document, join, empty_solr): class TestFieldsetSnippet: - """Unit tests for the override of django admin/includes/fieldset.html, which allows + """Unit tests for the override of django admin/includes/mixed_inlines_fieldsets.html, which allows inclusion of inline formsets between model form fields""" - template = "admin/corpus/document/snippets/fieldset.html" + template = "admin/snippets/mixed_inlines_fieldsets.html" def test_inlines_included(self, admin_client, document): # the snippet should be included on the admin document change page @@ -724,15 +722,14 @@ def test_inlines_included(self, admin_client, document): 'div class="js-inline-admin-formset inline-group" id="dating_set-group"', ) - # Dating inline should be immediately after standard_date field (which is the value of - # DocumentDatingInline.insert_after) + # Dating inline should be immediately after fieldset containing standard_date soup = BeautifulSoup(response.content) - standard_date_field = soup.find( - "div", class_=f"fieldBox field-{DocumentDatingInline.insert_after}" + date_fieldset = soup.find("div", class_="field-standard_date").find_parent( + "fieldset" ) - assert standard_date_field.find_next_sibling("div")["id"] == "dating_set-group" + assert date_fieldset.find_next_sibling("div")["id"] == "dating_set-group" dating_inline = soup.find("div", id="dating_set-group") - assert dating_inline.find_parent("fieldset") is not None + assert not dating_inline.find_parent("fieldset") # should include other inlines outside of form fieldsets assertContains( diff --git a/geniza/corpus/tests/test_corpus_templatetags.py b/geniza/corpus/tests/test_corpus_templatetags.py index c0e746c9a..00bf0f0c6 100644 --- a/geniza/corpus/tests/test_corpus_templatetags.py +++ b/geniza/corpus/tests/test_corpus_templatetags.py @@ -1,11 +1,12 @@ -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest from django.http.request import HttpRequest, QueryDict from piffle.iiif import IIIFImageClient from geniza.common.utils import absolutize_url -from geniza.corpus.templatetags import corpus_extras +from geniza.corpus.templatetags import admin_extras, corpus_extras +from geniza.footnotes.models import Footnote class TestCorpusExtrasTemplateTags: @@ -38,6 +39,43 @@ def test_alphabetize_empty_list(self): alphabetized = corpus_extras.alphabetize(lst) assert alphabetized == [] + def test_has_location_or_url(self, document, footnote): + # footnote has a location + assert corpus_extras.has_location_or_url([footnote]) == True + footnote_2 = Footnote.objects.create( + object_id=document.pk, + content_type=footnote.content_type, + source=footnote.source, + ) + # footnote has no location or url + assert corpus_extras.has_location_or_url([footnote_2]) == False + # one of the document's footnotes has a location + assert corpus_extras.has_location_or_url(list(document.footnotes.all())) == True + + def test_all_doc_relations(self, document, footnote): + Footnote.objects.create( + object_id=document.pk, + content_type=footnote.content_type, + source=footnote.source, + doc_relation=Footnote.DIGITAL_EDITION, + ) + assert ( + corpus_extras.all_doc_relations(list(document.footnotes.all())) + == "Digital Edition, Edition" + ) + # should not repeat doc relations even if multiple of the same type appear + Footnote.objects.create( + object_id=document.pk, + content_type=footnote.content_type, + source=footnote.source, + doc_relation=Footnote.EDITION, + location="other place", + ) + assert ( + corpus_extras.all_doc_relations(list(document.footnotes.all())) + == "Digital Edition, Edition" + ) + def test_dict_item(): # no error on non-dict first argument @@ -171,3 +209,29 @@ def test_translate_url(document): # if a Hebrew version cannot be determined, should return the original URL ctx["request"].path = "https://example.com" assert corpus_extras.translate_url(ctx, "he") == "https://example.com" + + +class TestAdminExtrasTemplateTags: + def test_get_fieldsets_and_inlines(self): + # mock admin form with fieldsets + adminform = MagicMock() + adminform.__iter__.return_value = ("fieldset1", "fieldset2") + # mock inlines + inlines = ("inline1", "inline2") + + # mock fieldsets_and_inlines_order + adminform.model_admin.fieldsets_and_inlines_order = ("f", "i", "f", "itt") + + # should return the first fieldset, then inline, then second fieldset + fieldsets_and_inlines = admin_extras.get_fieldsets_and_inlines( + {"adminform": adminform, "inline_admin_formsets": inlines} + ) + assert fieldsets_and_inlines[0] == ("f", "fieldset1") + assert fieldsets_and_inlines[1] == ("i", "inline1") + assert fieldsets_and_inlines[2] == ("f", "fieldset2") + + # should include itt panel entry with None as its second value + assert fieldsets_and_inlines[3] == ("itt", None) + + # should append the remaining inline at the end + assert fieldsets_and_inlines[4] == ("i", "inline2") diff --git a/geniza/corpus/tests/test_corpus_views.py b/geniza/corpus/tests/test_corpus_views.py index 810b8c037..a37684306 100644 --- a/geniza/corpus/tests/test_corpus_views.py +++ b/geniza/corpus/tests/test_corpus_views.py @@ -1,4 +1,3 @@ -import re from datetime import datetime from time import sleep from unittest.mock import ANY, MagicMock, Mock, patch @@ -8,6 +7,7 @@ from django.contrib.admin.models import ADDITION, LogEntry from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import resolve, reverse from django.utils.text import Truncator, slugify @@ -134,6 +134,48 @@ def test_placeholder_images(self, client, document): assert list(placeholders.keys()) == ["canvas_1", "canvas_2"] assert placeholders["canvas_1"] == Document.PLACEHOLDER_CANVAS + def test_get_context_data(self, client, document, source): + # test default shown/disabled behavior in context data + response = client.get(reverse("corpus:document", args=(document.pk,))) + + # document has image (via fragment.iiif_url) but no transcription or translation + assert response.context["default_shown"] == ["images"] + assert "images" not in response.context["disabled"] + assert "transcription" in response.context["disabled"] + assert "translation" in response.context["disabled"] + + # add a transcription + Footnote.objects.create( + content_object=document, + source=source, + doc_relation=Footnote.DIGITAL_EDITION, + ) + response = client.get(reverse("corpus:document", args=(document.pk,))) + # document has image (via fragment.iiif_url) and transcription, so should show those + assert "transcription" in response.context["default_shown"] + assert "images" in response.context["default_shown"] + assert "transcription" not in response.context["disabled"] + assert "images" not in response.context["disabled"] + # should not show translation + assert "translation" not in response.context["default_shown"] + assert "translation" in response.context["disabled"] + + # add a translation + Footnote.objects.create( + content_object=document, + source=source, + doc_relation=Footnote.DIGITAL_TRANSLATION, + ) + response = client.get(reverse("corpus:document", args=(document.pk,))) + # document has image (via fragment.iiif_url) and translation, so should show those + assert "translation" in response.context["default_shown"] + assert "images" in response.context["default_shown"] + assert "translation" not in response.context["disabled"] + assert "images" not in response.context["disabled"] + # should not show OR disable transcription + assert "transcription" not in response.context["default_shown"] + assert "transcription" not in response.context["disabled"] + @pytest.mark.django_db def test_old_pgp_tabulate_data(): @@ -327,7 +369,6 @@ def test_get_queryset(self, mock_solr_queryset): DocumentSolrQuerySet, extra_methods=["admin_search", "keyword_search"] ), ) as mock_queryset_cls: - docsearch_view = DocumentSearchView() docsearch_view.request = Mock() @@ -469,7 +510,6 @@ def test_get_context_data(self, mock_get_queryset, rf, mock_solr_queryset): DocumentSolrQuerySet, extra_methods=["admin_search", "keyword_search"] ), ) as mock_queryset_cls: - mock_qs = mock_queryset_cls.return_value mock_qs.count.return_value = 22 mock_qs.get_facets.return_value.facet_fields = {} @@ -829,7 +869,6 @@ def test_search_shelfmark_override(self, empty_solr, document): docsearch_view.request = Mock() for shelfmark in [document.shelfmark_override, orig_shelfmark]: - # keyword search should work docsearch_view.request.GET = {"q": shelfmark} qs = docsearch_view.get_queryset() @@ -1445,8 +1484,9 @@ def test_document_merge(self, admin_client, client): assert response.status_code == 200 # POST should merge + merge_url = "%s?ids=%s" % (reverse("admin:document-merge"), idstring) response = admin_client.post( - "%s?ids=%s" % (reverse("admin:document-merge"), idstring), + merge_url, {"primary_document": doc1.id, "rationale": "duplicate"}, follow=True, ) @@ -1461,7 +1501,7 @@ def test_document_merge(self, admin_client, client): with patch.object(Document, "merge_with") as mock_merge_with: # should pick up rationale notes as parenthetical response = admin_client.post( - "%s?ids=%s" % (reverse("admin:document-merge"), idstring), + merge_url, { "primary_document": doc1.id, "rationale": "duplicate", @@ -1473,7 +1513,7 @@ def test_document_merge(self, admin_client, client): # with "other", should use rationale notes as rationale string response = admin_client.post( - "%s?ids=%s" % (reverse("admin:document-merge"), idstring), + merge_url, { "primary_document": doc1.id, "rationale": "other", @@ -1483,6 +1523,20 @@ def test_document_merge(self, admin_client, client): ) mock_merge_with.assert_called_with(ANY, "test", user=ANY) + # should catch ValidationError and send back to form with error msg + mock_merge_with.side_effect = ValidationError("test message") + response = admin_client.post( + merge_url, + { + "primary_document": doc1.id, + "rationale": "duplicate", + }, + follow=True, + ) + TestCase().assertRedirects(response, merge_url) + messages = [str(msg) for msg in list(response.context["messages"])] + assert "test message" in messages + class TestTagMergeView: # adapted from TestDocumentMergeView @@ -1674,6 +1728,13 @@ def test_get_context_data(self, document, source, admin_client): # should include text direction assert response.context["annotation_config"]["text_direction"] == "rtl" + # should show transcription and images by default + assert "transcription" in response.context["default_shown"] + assert "images" in response.context["default_shown"] + assert "transcription" not in response.context["disabled"] + # make sure placeholder can be seen! + assert "images" not in response.context["disabled"] + # non-existent source_pk should 404 response = admin_client.get( reverse("corpus:document-transcribe", args=(document.id, 123456789)) @@ -1695,6 +1756,11 @@ def test_get_context_data(self, document, source, admin_client): response.context["annotation_config"]["text_direction"] == source.languages.first().direction ) + # should show translation and images by default + assert "translation" in response.context["default_shown"] + assert "images" in response.context["default_shown"] + assert "translation" not in response.context["disabled"] + assert "images" not in response.context["disabled"] class TestSourceAutocompleteView: diff --git a/geniza/corpus/tests/test_metadata_export.py b/geniza/corpus/tests/test_metadata_export.py index 96474ae40..33096b5c4 100644 --- a/geniza/corpus/tests/test_metadata_export.py +++ b/geniza/corpus/tests/test_metadata_export.py @@ -37,6 +37,7 @@ ) from geniza.corpus.models import ( Collection, + Dating, Document, DocumentType, Fragment, @@ -127,7 +128,6 @@ def test_iter_dicts(document): exporter = AdminDocumentExporter(queryset=doc_qs) for doc, doc_data in zip(doc_qs, exporter.iter_dicts()): - # test some properties assert doc.id == doc_data.get("pgpid") assert doc.shelfmark == doc_data.get("shelfmark") @@ -160,6 +160,38 @@ def test_iter_dicts(document): ) +@pytest.mark.django_db +def test_dating(document): + # should include dating info in export + Dating.objects.create( + document=document, + display_date="1000 CE example", + standard_date="1000", + rationale=Dating.PALEOGRAPHY, + notes="a note", + ) + doc_qs = Document.objects.all().order_by("id") + exporter = AdminDocumentExporter(queryset=doc_qs) + data_dict = exporter.get_export_data_dict(document) + assert "1000 CE example" in data_dict["inferred_date_display"] + assert "1000" in data_dict["inferred_date_standard"] + assert "a note" in data_dict["inferred_date_notes"] + + # should handle multiple values properly, with separators + Dating.objects.create( + document=document, + display_date="1005-1010", + standard_date="1005/1010", + rationale=Dating.PERSON, + notes="othernote", + ) + doc_qs = Document.objects.all().order_by("id") + data_dict = exporter.get_export_data_dict(document) + assert exporter.sep_within_cells.join( + [Dating.PALEOGRAPHY_LABEL, Dating.PERSON_LABEL] + ) in exporter.serialize_value(data_dict["inferred_date_rationale"]) + + @pytest.mark.django_db def test_http_export_data_csv(document): exporter = AdminDocumentExporter() diff --git a/geniza/corpus/views.py b/geniza/corpus/views.py index 5b1b25255..db629eba0 100644 --- a/geniza/corpus/views.py +++ b/geniza/corpus/views.py @@ -8,6 +8,7 @@ from django.contrib.admin.models import CHANGE, LogEntry from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models import Q from django.db.models.query import Prefetch from django.http import Http404, HttpResponse, JsonResponse @@ -318,6 +319,10 @@ def get_context_data(self, **kwargs): """extend context data to add page metadata""" context_data = super().get_context_data(**kwargs) images = self.object.iiif_images(with_placeholders=True) + + # collect available panels + available_panels = self.object.available_digital_content + context_data.update( { "page_title": self.page_title(), @@ -342,6 +347,14 @@ def get_context_data(self, **kwargs): "images": images, # first image for twitter/opengraph meta tags "meta_image": list(images.values())[0]["image"] if images else None, + # show the first two available panels by default (in order of priority) + "default_shown": available_panels[:2], + # disable any fully unavailable panels + "disabled": [ + panel + for panel in ["images", "translation", "transcription"] + if panel not in available_panels + ], } ) return context_data @@ -679,7 +692,17 @@ def form_valid(self, form): # Merge secondary documents into the selected primary document user = getattr(self.request, "user", None) - primary_doc.merge_with(secondary_docs, rationale, user=user) + + try: + primary_doc.merge_with(secondary_docs, rationale, 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 documents + return HttpResponseRedirect( + "%s?ids=%s" + % (reverse("admin:document-merge"), self.request.GET.get("ids", "")), + ) # Display info about the merge to the user new_doc_link = reverse("admin:corpus_document_change", args=[primary_doc.id]) @@ -810,6 +833,12 @@ def get_context_data(self, **kwargs): # transcription always rtl text_direction = "rtl" + # override show default/disabled logic from document detail view. + # always show images and the panel we are editing, even if unavailable + default_shown = ["images", self.doc_relation] + # the third panel can still be disabled (e.g. transcription when editing translation) + disabled = [p for p in context_data["disabled"] if p not in default_shown] + context_data.update( { "annotation_config": { @@ -834,6 +863,8 @@ def get_context_data(self, **kwargs): else "", "source_label": source_label if source_label else "", "page_type": "document annotating", + "disabled": disabled, + "default_shown": default_shown, } ) return context_data diff --git a/geniza/entities/__init__.py b/geniza/entities/__init__.py new file mode 100644 index 000000000..f6ca61578 --- /dev/null +++ b/geniza/entities/__init__.py @@ -0,0 +1,3 @@ +"""The :mod:`geniza.entities` application provides models for entities such as +people and places found within the PGP corpus, in order to describe and +link their apperances within and across documents.""" diff --git a/geniza/entities/admin.py b/geniza/entities/admin.py new file mode 100644 index 000000000..e8372bed7 --- /dev/null +++ b/geniza/entities/admin.py @@ -0,0 +1,362 @@ +from itertools import groupby + +from adminsortable2.admin import SortableAdminBase +from django.contrib import admin +from django.contrib.contenttypes.admin import GenericTabularInline +from django.contrib.contenttypes.forms import BaseGenericInlineFormSet +from django.db.models.fields import CharField, TextField +from django.forms import ModelChoiceField, ValidationError +from django.forms.models import ModelChoiceIterator +from django.forms.widgets import Textarea, TextInput +from django.urls import reverse +from django.utils.html import format_html +from modeltranslation.admin import TabbedTranslationAdmin + +from geniza.entities.models import ( + DocumentPlaceRelation, + DocumentPlaceRelationType, + Name, + Person, + PersonDocumentRelation, + PersonDocumentRelationType, + PersonPersonRelation, + PersonPersonRelationType, + PersonPlaceRelation, + PersonPlaceRelationType, + PersonRole, + Place, +) +from geniza.footnotes.models import Footnote + + +class NameInlineFormSet(BaseGenericInlineFormSet): + """Override of the Name inline formset to require exactly one primary name.""" + + DISPLAY_NAME_ERROR = "This entity must have exactly one display name." + + def clean(self): + """Execute inherited clean method, then validate to ensure exactly one primary name.""" + super().clean() + cleaned_data = [form.cleaned_data for form in self.forms if form.is_valid()] + if cleaned_data: + primary_names_count = len( + [name for name in cleaned_data if name.get("primary") == True] + ) + if primary_names_count == 0 or primary_names_count > 1: + raise ValidationError(self.DISPLAY_NAME_ERROR, code="invalid") + + +class NameInline(GenericTabularInline): + """Name inline for the Person and Place admins""" + + model = Name + formset = NameInlineFormSet + autocomplete_fields = ["language"] + fields = ( + "name", + "primary", + "language", + "notes", + "transliteration_style", + ) + min_num = 1 + extra = 0 + formfield_overrides = { + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + + +class PersonInline(admin.TabularInline): + """Generic inline for people related to other objects""" + + verbose_name = "Related Person" + verbose_name_plural = "Related People" + autocomplete_fields = ["person", "type"] + fields = ( + "person", + "type", + "notes", + ) + formfield_overrides = { + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + extra = 1 + + +class FootnoteInline(GenericTabularInline): + """Footnote inline for the Person/Place admins""" + + model = Footnote + autocomplete_fields = ["source"] + fields = ( + "source", + "location", + "notes", + "url", + ) + extra = 1 + formfield_overrides = { + CharField: {"widget": TextInput(attrs={"size": "10"})}, + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + # enable link from inline to edit footnote + show_change_link = True + + +class DocumentInline(admin.TabularInline): + """Generic related documents inline""" + + verbose_name = "Related Document" + verbose_name_plural = "Related Documents" + autocomplete_fields = ["type"] + fields = ( + "document_link", + "document_description", + "type", + "notes", + ) + readonly_fields = ("document_link", "document_description") + formfield_overrides = { + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + extra = 0 + max_num = 0 + + def document_link(self, obj): + document_path = reverse("admin:corpus_document_change", args=[obj.document.id]) + return format_html(f'{str(obj.document)}') + + document_link.short_description = "Document" + + def document_description(self, obj): + return obj.document.description + + +class PersonDocumentInline(DocumentInline): + """Related documents inline for the Person admin""" + + model = PersonDocumentRelation + + +class PlaceInline(admin.TabularInline): + """Generic inline for places related to other objects""" + + verbose_name = "Related Place" + verbose_name_plural = "Related Places" + autocomplete_fields = ["place", "type"] + fields = ( + "place", + "place_link", + "type", + "notes", + ) + readonly_fields = ("place_link",) + formfield_overrides = { + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + extra = 1 + + def place_link(self, obj): + """Get the link to a related place""" + place_path = reverse("admin:entities_place_change", args=[obj.place.id]) + return format_html(f'{str(obj.place)}') + + +class PersonPlaceInline(PlaceInline): + """Inline for places related to people""" + + model = PersonPlaceRelation + + +class PersonPersonRelationTypeChoiceIterator(ModelChoiceIterator): + """Override ModelChoiceIterator in order to group Person-Person + relationship types by category""" + + def __iter__(self): + """Override the iterator to group type by category""" + # first, display empty label if applicable + if self.field.empty_label is not None: + yield ("", self.field.empty_label) + # then group the queryset (ordered by category, then name) by category + groups = groupby( + self.queryset.order_by("category", "name"), key=lambda x: x.category + ) + # map category keys to their full names for display + category_names = dict(PersonPersonRelationType.CATEGORY_CHOICES) + # return the groups in the format expected by ModelChoiceField + for category, types in groups: + yield (category_names[category], [(type.id, type.name) for type in types]) + + +class PersonPersonRelationTypeChoiceField(ModelChoiceField): + """Override ModelChoiceField's iterator property to use our ModelChoiceIterator + override""" + + iterator = PersonPersonRelationTypeChoiceIterator + + +class PersonPersonInline(admin.TabularInline): + """Person-Person relationships inline for the Person admin""" + + model = PersonPersonRelation + verbose_name = "Related Person" + verbose_name_plural = "Related People (input manually)" + autocomplete_fields = ("to_person",) + fields = ( + "to_person", + "type", + "notes", + ) + fk_name = "from_person" + formfield_overrides = { + TextField: {"widget": Textarea(attrs={"rows": 4})}, + } + extra = 1 + + def get_formset(self, request, obj=None, **kwargs): + """Override 'type' field for PersonPersonRelation, change ModelChoiceField + to our new PersonPersonRelationTypeChoiceField""" + formset = super().get_formset(request, obj=None, **kwargs) + formset.form.base_fields["type"] = PersonPersonRelationTypeChoiceField( + queryset=PersonPersonRelationType.objects.all() + ) + return formset + + +class PersonPersonReverseInline(admin.TabularInline): + """Person-Person reverse relationships inline for the Person admin""" + + model = PersonPersonRelation + verbose_name = "Related Person" + verbose_name_plural = "Related People (automatically populated)" + fields = ( + "from_person", + "relation", + "notes", + ) + fk_name = "to_person" + readonly_fields = ("from_person", "relation", "notes") + extra = 0 + max_num = 0 + + def relation(self, obj=None): + """Get the relationship type's converse name, if it exists, or else the type name""" + return (obj.type.converse_name or str(obj.type)) if obj else None + + +@admin.register(Person) +class PersonAdmin(TabbedTranslationAdmin, SortableAdminBase, admin.ModelAdmin): + """Admin for Person entities in the PGP""" + + search_fields = ("names__name",) + fields = ("gender", "role", "has_page", "description") + inlines = ( + NameInline, + FootnoteInline, + PersonDocumentInline, + PersonPersonInline, + PersonPersonReverseInline, + PersonPlaceInline, + ) + # mixed fieldsets and inlines: /templates/admin/snippets/mixed_inlines_fieldsets.html + fieldsets_and_inlines_order = ( + "i", # NameInline + "f", # all Person fields + "i", # PersonDocumentInline + "i", # PersonPersonInline + "i", # PersonPersonReverseInline + "i", # PersonPlaceInline + ) + own_pk = None + + def get_form(self, request, obj=None, **kwargs): + """For Person-Person autocomplete on the PersonAdmin form, keep track of own pk""" + if obj: + self.own_pk = obj.pk + else: + # reset own_pk to None if we are creating a new person + self.own_pk = None + return super().get_form(request, obj, **kwargs) + + def get_queryset(self, request): + """For autocomplete ONLY, remove self from queryset, so that Person-Person autocomplete + does not include self in the list of options""" + qs = super().get_queryset(request) + if self.own_pk and request and request.path == "/admin/autocomplete/": + # exclude self from queryset + return qs.exclude(pk=int(self.own_pk)) + # otherwise, return normal queryset + return qs + + +@admin.register(PersonRole) +class RoleAdmin(TabbedTranslationAdmin, admin.ModelAdmin): + """Admin for managing the controlled vocabulary of people's roles""" + + fields = ("name", "display_label") + search_fields = ("name", "display_label") + ordering = ("display_label", "name") + + +@admin.register(PersonDocumentRelationType) +class PersonDocumentRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): + """Admin for managing the controlled vocabulary of people's relationships to documents""" + + fields = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(PersonPersonRelationType) +class PersonPersonRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): + """Admin for managing the controlled vocabulary of people's relationships to other people""" + + fields = ("name", "converse_name", "category") + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(PersonPlaceRelationType) +class PersonPlaceRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): + """Admin for managing the controlled vocabulary of people's relationships to places""" + + fields = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(DocumentPlaceRelationType) +class DocumentPlaceRelationTypeAdmin(TabbedTranslationAdmin, admin.ModelAdmin): + """Admin for managing the controlled vocabulary of documents' relationships to places""" + + fields = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +class DocumentPlaceInline(DocumentInline): + """Related documents inline for the Person admin""" + + model = DocumentPlaceRelation + + +class PlacePersonInline(PersonInline): + """Inline for people related to a place""" + + model = PersonPlaceRelation + + +@admin.register(Place) +class PlaceAdmin(SortableAdminBase, admin.ModelAdmin): + """Admin for Place entities in the PGP""" + + search_fields = ("names__name",) + fields = ("latitude", "longitude") + inlines = (NameInline, DocumentPlaceInline, PlacePersonInline, FootnoteInline) + fieldsets_and_inlines_order = ( + "i", # NameInline + "f", # lat/long fieldset + "i", # DocumentPlaceInline + "i", # PlacePersonInline + "i", # FootnoteInline + ) diff --git a/geniza/entities/apps.py b/geniza/entities/apps.py new file mode 100644 index 000000000..2711a079f --- /dev/null +++ b/geniza/entities/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EntitiesConfig(AppConfig): + name = "geniza.entities" diff --git a/geniza/entities/migrations/0001_initial.py b/geniza/entities/migrations/0001_initial.py new file mode 100644 index 000000000..a11cdc2f5 --- /dev/null +++ b/geniza/entities/migrations/0001_initial.py @@ -0,0 +1,369 @@ +# Generated by Django 3.2.16 on 2023-06-12 20:55 + +import django.db.models.deletion +import django.db.models.expressions +import gfklookupwidget.fields +from django.db import migrations, models + +import geniza.common.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("footnotes", "0028_sourcelanguage_direction"), + ("corpus", "0039_document_ce_date"), + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="Person", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("description", models.TextField(blank=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("description_he", models.TextField(blank=True, null=True)), + ("description_ar", models.TextField(blank=True, null=True)), + ( + "has_page", + models.BooleanField( + default=False, + help_text="True if this person should have a dedicated, public Person page on the PGP", + ), + ), + ( + "gender", + models.CharField( + choices=[("M", "Male"), ("F", "Female"), ("U", "Unknown")], + max_length=1, + ), + ), + ], + options={ + "verbose_name_plural": "People", + }, + ), + migrations.CreateModel( + name="PersonDocumentRelationType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("name_en", models.CharField(max_length=255, null=True, unique=True)), + ("name_he", models.CharField(max_length=255, null=True, unique=True)), + ("name_ar", models.CharField(max_length=255, null=True, unique=True)), + ], + options={ + "verbose_name": "Person-Document relationship", + "verbose_name_plural": "Person-Document relationships", + }, + ), + migrations.CreateModel( + name="PersonPersonRelationType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("name_en", models.CharField(max_length=255, null=True, unique=True)), + ("name_he", models.CharField(max_length=255, null=True, unique=True)), + ("name_ar", models.CharField(max_length=255, null=True, unique=True)), + ( + "category", + models.CharField( + choices=[ + ("I", "Immediate family relations"), + ("E", "Extended family"), + ("M", "Relatives by marriage"), + ("B", "Business and property relationships"), + ], + max_length=1, + ), + ), + ], + options={ + "verbose_name": "Person-Person relationship", + "verbose_name_plural": "Person-Person relationships", + }, + ), + migrations.CreateModel( + name="PersonRole", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("name_en", models.CharField(max_length=255, null=True, unique=True)), + ("name_he", models.CharField(max_length=255, null=True, unique=True)), + ("name_ar", models.CharField(max_length=255, null=True, unique=True)), + ( + "display_label", + models.CharField( + blank=True, + help_text="Optional label for display on the public site", + max_length=255, + ), + ), + ( + "display_label_en", + models.CharField( + blank=True, + help_text="Optional label for display on the public site", + max_length=255, + null=True, + ), + ), + ( + "display_label_he", + models.CharField( + blank=True, + help_text="Optional label for display on the public site", + max_length=255, + null=True, + ), + ), + ( + "display_label_ar", + models.CharField( + blank=True, + help_text="Optional label for display on the public site", + max_length=255, + null=True, + ), + ), + ], + options={ + "verbose_name": "Person social role", + "verbose_name_plural": "Person social roles", + }, + bases=(geniza.common.models.DisplayLabelMixin, models.Model), + ), + migrations.CreateModel( + name="PersonPersonRelation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.TextField(blank=True)), + ( + "from_person", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="to_person", + to="entities.person", + ), + ), + ( + "to_person", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="from_person", + to="entities.person", + verbose_name="Person", + ), + ), + ( + "type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="entities.personpersonrelationtype", + verbose_name="Relation", + ), + ), + ], + ), + migrations.CreateModel( + name="PersonDocumentRelation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.TextField(blank=True)), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="corpus.document", + ), + ), + ( + "person", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="entities.person", + ), + ), + ( + "type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="entities.persondocumentrelationtype", + verbose_name="Relation", + ), + ), + ], + ), + migrations.AddField( + model_name="person", + name="documents", + field=models.ManyToManyField( + related_name="people", + through="entities.PersonDocumentRelation", + to="corpus.Document", + verbose_name="Related Documents", + ), + ), + migrations.AddField( + model_name="person", + name="footnotes", + field=models.ManyToManyField( + blank=True, related_name="people", to="footnotes.Footnote" + ), + ), + migrations.AddField( + model_name="person", + name="relationships", + field=models.ManyToManyField( + related_name="related_to", + through="entities.PersonPersonRelation", + to="entities.Person", + verbose_name="Related People", + ), + ), + migrations.AddField( + model_name="person", + name="role", + field=models.ForeignKey( + blank=True, + help_text="Social role", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="entities.personrole", + verbose_name="Role", + ), + ), + migrations.CreateModel( + name="Name", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "primary", + models.BooleanField( + default=False, + help_text="True if this is the primary name that should be displayed on the site.", + ), + ), + ("notes", models.TextField(blank=True)), + ("object_id", gfklookupwidget.fields.GfkLookupField()), + ( + "transliteration_style", + models.CharField( + choices=[("N", "N/A"), ("P", "PGP"), ("C", "Cambridge")], + default="N", + max_length=1, + ), + ), + ( + "content_type", + models.ForeignKey( + limit_choices_to=models.Q(("app_label", "entities")), + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "language", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="corpus.languagescript", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="personpersonrelation", + constraint=models.UniqueConstraint( + fields=("type", "from_person", "to_person"), + name="unique_person_person_relation_by_type", + ), + ), + migrations.AddConstraint( + model_name="personpersonrelation", + constraint=models.CheckConstraint( + check=models.Q( + ("from_person", django.db.models.expressions.F("to_person")), + _negated=True, + ), + name="entities_personpersonrelation_prevent_self_relationship", + ), + ), + migrations.AddConstraint( + model_name="persondocumentrelation", + constraint=models.UniqueConstraint( + fields=("type", "person", "document"), + name="unique_person_document_relation_by_type", + ), + ), + migrations.AddConstraint( + model_name="name", + constraint=models.UniqueConstraint( + condition=models.Q(("primary", True)), + fields=("content_type", "object_id"), + name="one_primary_name_per_entity", + ), + ), + ] diff --git a/geniza/entities/migrations/0002_populate_types.py b/geniza/entities/migrations/0002_populate_types.py new file mode 100644 index 000000000..9e457d08e --- /dev/null +++ b/geniza/entities/migrations/0002_populate_types.py @@ -0,0 +1,114 @@ +# Generated by Django 3.2.16 on 2023-06-12 20:55 + +from django.db import migrations + + +def populate_types(apps, schema_editor): + """Populate the English names for predetermined types and roles.""" + PersonRole = apps.get_model("entities", "PersonRole") + PersonDocumentRelationType = apps.get_model( + "entities", "PersonDocumentRelationType" + ) + PersonPersonRelationType = apps.get_model("entities", "PersonPersonRelationType") + + social_roles = [ + "State official", + "Jewish communal official", + "Muslim judicial official", + "Enslaved person", + "Jewish community member", + "Other", + ] + for role in social_roles: + PersonRole.objects.create(name=role, name_en=role) + + person_document_relations = [ + "Sender", + "Recipient", + "Protagonist", + "Legal and state personnel", + "Witness", + "Petitioner", + "Author", + "Scribe", + "Other person mentioned", + ] + for relation_type in person_document_relations: + PersonDocumentRelationType.objects.create( + name=relation_type, + name_en=relation_type, + ) + + # these constants are also defined on the model, but we can't get them here + IMMEDIATE_FAMILY = "I" + EXTENDED_FAMILY = "E" + BY_MARRIAGE = "M" + BUSINESS = "B" + person_person_relations = [ + ( + IMMEDIATE_FAMILY, + [ + "Child", + "Sibling", + "Half-sibling", + "Parent", + "Slave", + "Owner", + "Spouse", + ], + ), + ( + EXTENDED_FAMILY, + [ + "Maternal aunt or uncle", + "Maternal cousin", + "Paternal aunt or uncle", + "Paternal cousin", + "Grandchild", + "Great aunt or uncle", + "Great grandparent", + "Great grandchild", + ], + ), + ( + BY_MARRIAGE, + [ + "Daughter-in-law or son-in-law", + "Sister-in-law or brother-in-law", + "Mother-in-law or father-in-law", + "Stepparent", + "Stepchild", + ], + ), + ( + BUSINESS, + [ + "Partner", + "Debtor", + "Lender", + "Beneficiary (of will or gift)", + "Testator/grantor (of will or gift)", + "Seller", + "Buyer", + ], + ), + ] + for grouping in person_person_relations: + (category, relation_types) = grouping + for relation_type in relation_types: + PersonPersonRelationType.objects.create( + name=relation_type, + name_en=relation_type, + category=category, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("entities", "0001_initial"), + ] + + operations = [ + migrations.RunPython(populate_types, reverse_code=migrations.RunPython.noop), + ] diff --git a/geniza/entities/migrations/0003_help_text_and_verbose_names.py b/geniza/entities/migrations/0003_help_text_and_verbose_names.py new file mode 100644 index 000000000..3094bc68a --- /dev/null +++ b/geniza/entities/migrations/0003_help_text_and_verbose_names.py @@ -0,0 +1,77 @@ +# Generated by Django 3.2.16 on 2023-07-05 18:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("corpus", "0040_tag_merge_permissions"), + ("entities", "0002_populate_types"), + ] + + operations = [ + migrations.AlterField( + model_name="name", + name="language", + field=models.ForeignKey( + help_text="Please indicate the language of most components of the name as written here.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="corpus.languagescript", + ), + ), + migrations.AlterField( + model_name="name", + name="primary", + field=models.BooleanField( + default=False, + help_text="Check box if this is the primary name that should be displayed on the site.", + verbose_name="Display name", + ), + ), + migrations.AlterField( + model_name="person", + name="description", + field=models.TextField( + blank=True, + help_text="A description that will appear on the public Person page if 'Person page' box is checked.", + ), + ), + migrations.AlterField( + model_name="person", + name="description_ar", + field=models.TextField( + blank=True, + help_text="A description that will appear on the public Person page if 'Person page' box is checked.", + null=True, + ), + ), + migrations.AlterField( + model_name="person", + name="description_en", + field=models.TextField( + blank=True, + help_text="A description that will appear on the public Person page if 'Person page' box is checked.", + null=True, + ), + ), + migrations.AlterField( + model_name="person", + name="description_he", + field=models.TextField( + blank=True, + help_text="A description that will appear on the public Person page if 'Person page' box is checked.", + null=True, + ), + ), + migrations.AlterField( + model_name="person", + name="has_page", + field=models.BooleanField( + default=False, + help_text="Check box if this person should have a dedicated, public Person page on the PGP. If checked, please draft a public description below.", + verbose_name="Person page", + ), + ), + ] diff --git a/geniza/entities/migrations/0004_entities_permissions.py b/geniza/entities/migrations/0004_entities_permissions.py new file mode 100644 index 000000000..1948cf42d --- /dev/null +++ b/geniza/entities/migrations/0004_entities_permissions.py @@ -0,0 +1,84 @@ +# Generated by Django 3.2.16 on 2023-08-16 17:33 + +from django.contrib.auth.management import create_permissions +from django.db import migrations + +CONTENT_EDITOR = "Content Editor" +# new permissions for content editor +content_editor_perms = [ + "add_name", + "change_name", + "delete_name", + "view_name", + "add_person", + "change_person", + "delete_person", + "view_person", + "add_persondocumentrelation", + "change_persondocumentrelation", + "delete_persondocumentrelation", + "view_persondocumentrelation", + "view_persondocumentrelationtype", + "add_personpersonrelation", + "change_personpersonrelation", + "delete_personpersonrelation", + "view_personpersonrelation", + "view_personpersonrelationtype", + "view_personrole", +] + + +CONTENT_ADMIN = "Content Admin" +# additional new permissions for content admin: add, change, delete relation types and person roles +content_admin_perms = [ + "add_personpersonrelationtype", + "change_personpersonrelationtype", + "delete_personpersonrelationtype", + "add_persondocumentrelationtype", + "change_persondocumentrelationtype", + "delete_persondocumentrelationtype", + "add_personrole", + "change_personrole", + "delete_personrole", +] + + +def set_entities_permissions(apps, schema_editor): + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + + # make sure permissions are created before loading the fixture + # which references them + # (when running migrations all at once, permissions may not yet exist) + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + editor_group = Group.objects.get(name=CONTENT_EDITOR) + permissions = [] + for codename in content_editor_perms: + # using explicit get so that there will be an error if an + # expected permission is not found + permissions.append(Permission.objects.get(codename=codename)) + editor_group.permissions.add(*permissions) + + # update content admin group; add to content edit permissions + admin_group = Group.objects.get(name=CONTENT_ADMIN) + for codename in content_admin_perms: + permissions.append(Permission.objects.get(codename=codename)) + admin_group.permissions.add(*permissions) + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0003_help_text_and_verbose_names"), + # relies on common for Groups to be created + ("common", "0008_preload_github_coauthors"), + ] + + operations = [ + migrations.RunPython( + set_entities_permissions, reverse_code=migrations.RunPython.noop + ) + ] diff --git a/geniza/entities/migrations/0005_places.py b/geniza/entities/migrations/0005_places.py new file mode 100644 index 000000000..24f215e5c --- /dev/null +++ b/geniza/entities/migrations/0005_places.py @@ -0,0 +1,165 @@ +# Generated by Django 3.2.16 on 2023-07-11 20:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("footnotes", "0028_sourcelanguage_direction"), + ("corpus", "0040_tag_merge_permissions"), + ("entities", "0004_entities_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentPlaceRelationType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("name_en", models.CharField(max_length=255, null=True, unique=True)), + ("name_he", models.CharField(max_length=255, null=True, unique=True)), + ("name_ar", models.CharField(max_length=255, null=True, unique=True)), + ], + options={ + "verbose_name": "Document-Place relationship", + "verbose_name_plural": "Document-Place relationships", + }, + ), + migrations.CreateModel( + name="PersonPlaceRelationType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("name_en", models.CharField(max_length=255, null=True, unique=True)), + ("name_he", models.CharField(max_length=255, null=True, unique=True)), + ("name_ar", models.CharField(max_length=255, null=True, unique=True)), + ], + options={ + "verbose_name": "Person-Place relationship", + "verbose_name_plural": "Person-Place relationships", + }, + ), + migrations.CreateModel( + name="Place", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "latitude", + models.DecimalField( + blank=True, decimal_places=4, max_digits=6, null=True + ), + ), + ( + "longitude", + models.DecimalField( + blank=True, decimal_places=4, max_digits=7, null=True + ), + ), + ( + "footnotes", + models.ManyToManyField( + blank=True, related_name="places", to="footnotes.Footnote" + ), + ), + ], + ), + migrations.CreateModel( + name="PersonPlaceRelation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.TextField(blank=True)), + ( + "person", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="entities.person", + ), + ), + ( + "place", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="entities.place" + ), + ), + ( + "type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="entities.personplacerelationtype", + verbose_name="Relation", + ), + ), + ], + ), + migrations.CreateModel( + name="DocumentPlaceRelation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("notes", models.TextField(blank=True)), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="corpus.document", + ), + ), + ( + "place", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="entities.place" + ), + ), + ( + "type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="entities.documentplacerelationtype", + verbose_name="Relation", + ), + ), + ], + ), + ] diff --git a/geniza/entities/migrations/0006_populate_place_types.py b/geniza/entities/migrations/0006_populate_place_types.py new file mode 100644 index 000000000..37c82e508 --- /dev/null +++ b/geniza/entities/migrations/0006_populate_place_types.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.16 on 2023-07-11 20:48 + +from django.db import migrations + + +def populate_place_types(apps, schema_editor): + """Populate the English names for predetermined types of relationships.""" + PersonPlaceRelationType = apps.get_model("entities", "PersonPlaceRelationType") + DocumentPlaceRelationType = apps.get_model("entities", "DocumentPlaceRelationType") + + person_place_relations = [ + "Home base", + "Occasional trips to", + "Family traces roots to", + ] + for relation in person_place_relations: + PersonPlaceRelationType.objects.create(name=relation, name_en=relation) + + document_place_relations = [ + "Origin", + "Destination", + "Mentioned", + "Possibly mentioned", + "Formerly believed to be mentioned", + ] + for relation in document_place_relations: + DocumentPlaceRelationType.objects.create(name=relation, name_en=relation) + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0005_places"), + ] + + operations = [ + migrations.RunPython( + populate_place_types, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/geniza/entities/migrations/0007_remove_name_one_primary_name_per_entity.py b/geniza/entities/migrations/0007_remove_name_one_primary_name_per_entity.py new file mode 100644 index 000000000..e9f74ed6c --- /dev/null +++ b/geniza/entities/migrations/0007_remove_name_one_primary_name_per_entity.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.16 on 2023-08-09 17:52 + +from django.db import migrations + +# remove the "one primary name per entity" constraint as this could cause +# unique constraint violations mid-transaction (e.g. an entity has two names +# and the primary name is switched from one name to the other) + +# enforced at the admin form level instead + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0006_populate_place_types"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="name", + name="one_primary_name_per_entity", + ), + ] diff --git a/geniza/entities/migrations/0008_place_permissions.py b/geniza/entities/migrations/0008_place_permissions.py new file mode 100644 index 000000000..7adb9e694 --- /dev/null +++ b/geniza/entities/migrations/0008_place_permissions.py @@ -0,0 +1,76 @@ +# Generated by Django 3.2.16 on 2023-08-16 19:46 + +from django.contrib.auth.management import create_permissions +from django.db import migrations + +CONTENT_EDITOR = "Content Editor" +# new permissions for content editor +content_editor_perms = [ + "add_documentplacerelation", + "change_documentplacerelation", + "delete_documentplacerelation", + "view_documentplacerelation", + "view_documentplacerelationtype", + "add_personplacerelation", + "change_personplacerelation", + "delete_personplacerelation", + "view_personplacerelation", + "view_personplacerelationtype", + "add_place", + "change_place", + "delete_place", + "view_place", +] + + +CONTENT_ADMIN = "Content Admin" +# additional new permissions for content admin: add, change, delete relation types +content_admin_perms = [ + "add_documentplacerelationtype", + "change_documentplacerelationtype", + "delete_documentplacerelationtype", + "add_personplacerelationtype", + "change_personplacerelationtype", + "delete_personplacerelationtype", +] + + +def set_place_permissions(apps, schema_editor): + Group = apps.get_model("auth", "Group") + Permission = apps.get_model("auth", "Permission") + + # make sure permissions are created before loading the fixture + # which references them + # (when running migrations all at once, permissions may not yet exist) + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + editor_group = Group.objects.get(name=CONTENT_EDITOR) + permissions = [] + for codename in content_editor_perms: + # using explicit get so that there will be an error if an + # expected permission is not found + permissions.append(Permission.objects.get(codename=codename)) + editor_group.permissions.add(*permissions) + + # update content admin group; add to content edit permissions + admin_group = Group.objects.get(name=CONTENT_ADMIN) + for codename in content_admin_perms: + permissions.append(Permission.objects.get(codename=codename)) + admin_group.permissions.add(*permissions) + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0007_remove_name_one_primary_name_per_entity"), + # relies on common for Groups to be created + ("common", "0008_preload_github_coauthors"), + ] + + operations = [ + migrations.RunPython( + set_place_permissions, reverse_code=migrations.RunPython.noop + ) + ] diff --git a/geniza/entities/migrations/0009_personpersonrelationtype_converse_name.py b/geniza/entities/migrations/0009_personpersonrelationtype_converse_name.py new file mode 100644 index 000000000..c9fff6b04 --- /dev/null +++ b/geniza/entities/migrations/0009_personpersonrelationtype_converse_name.py @@ -0,0 +1,118 @@ +# Generated by Django 3.2.16 on 2023-09-12 16:41 + +from django.db import migrations, models + + +def populate_converse_names(apps, schema_editor): + PersonPersonRelationType = apps.get_model("entities", "PersonPersonRelationType") + + # these did not get created in the original migration, oops + PersonPersonRelationType.objects.get_or_create( + name="Grandparent", + name_en="Grandparent", + category="E", # E = extended family + ) + PersonPersonRelationType.objects.get_or_create( + name="Nephew or niece on sister's side", + name_en="Nephew or niece on sister's side", + category="E", # E = extended family + ) + PersonPersonRelationType.objects.get_or_create( + name="Nephew or niece on brother's side", + name_en="Nephew or niece on brother's side", + category="E", # E = extended family + ) + PersonPersonRelationType.objects.get_or_create( + name="Great nephew or niece", + name_en="Great nephew or niece", + category="E", # E = extended family + ) + + # first: converse names where both sides of the relation exist as types + bidirectional_converse_names = { + "Child": "Parent", + "Slave": "Owner", + "Grandparent": "Grandchild", + "Great grandparent": "Great grandchild", + "Daughter-in-law or son-in-law": "Mother-in-law or father-in-law", + "Stepparent": "Stepchild", + "Debtor": "Lender", + "Beneficiary (of will or gift)": "Testator/grantor (of will or gift)", + "Seller": "Buyer", + "Great aunt or uncle": "Great nephew or niece", + "Maternal aunt or uncle": "Nephew or niece on sister's side", + "Paternal aunt or uncle": "Nephew or niece on brother's side", + } + for left_name, right_name in bidirectional_converse_names.items(): + left_type = PersonPersonRelationType.objects.get(name=left_name) + left_type.converse_name_en = right_name + left_type.save() + right_type = PersonPersonRelationType.objects.get(name=right_name) + right_type.converse_name_en = left_name + right_type.save() + + # second: converse names where only one side of the relation exists as a type, and the other + # only exists as a converse_name + unidirectional_converse_names = { + "Maternal cousin": "Cousin", + "Paternal cousin": "Cousin", + } + for type_name, converse_name in unidirectional_converse_names.items(): + relation_type = PersonPersonRelationType.objects.get(name=type_name) + relation_type.converse_name_en = converse_name + relation_type.save() + + # finally, for the third type of reverse relationships--where it is the same on both sides + # (e.g. "Spouse": "Spouse"), we just simply do not enter anything in the converse_name, to + # avoid duplicating anything. + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0008_place_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="personpersonrelationtype", + name="converse_name", + field=models.CharField( + blank=True, + help_text="The converse of the relationship, for example, 'Child' when Name is 'Parent'.\n May leave blank if the converse is identical (for example, 'Spouse' and 'Spouse').", + max_length=255, + ), + ), + migrations.AddField( + model_name="personpersonrelationtype", + name="converse_name_ar", + field=models.CharField( + blank=True, + help_text="The converse of the relationship, for example, 'Child' when Name is 'Parent'.\n May leave blank if the converse is identical (for example, 'Spouse' and 'Spouse').", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="personpersonrelationtype", + name="converse_name_en", + field=models.CharField( + blank=True, + help_text="The converse of the relationship, for example, 'Child' when Name is 'Parent'.\n May leave blank if the converse is identical (for example, 'Spouse' and 'Spouse').", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="personpersonrelationtype", + name="converse_name_he", + field=models.CharField( + blank=True, + help_text="The converse of the relationship, for example, 'Child' when Name is 'Parent'.\n May leave blank if the converse is identical (for example, 'Spouse' and 'Spouse').", + max_length=255, + null=True, + ), + ), + migrations.RunPython( + code=populate_converse_names, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/geniza/entities/migrations/0010_alter_personpersonrelationtype_category.py b/geniza/entities/migrations/0010_alter_personpersonrelationtype_category.py new file mode 100644 index 000000000..69a3b6fbc --- /dev/null +++ b/geniza/entities/migrations/0010_alter_personpersonrelationtype_category.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.16 on 2023-09-12 18:30 + +from django.db import migrations, models + + +def populate_ambiguous_types(apps, schema_editor): + PersonPersonRelationType = apps.get_model("entities", "PersonPersonRelationType") + new_types = ["Possibly the same person", "Not to be confused with"] + for type_name in new_types: + PersonPersonRelationType.objects.get_or_create( + name=type_name, name_en=type_name, category="A" # A = ambiguity + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("entities", "0009_personpersonrelationtype_converse_name"), + ] + + operations = [ + migrations.AlterField( + model_name="personpersonrelationtype", + name="category", + field=models.CharField( + choices=[ + ("I", "Immediate family relations"), + ("E", "Extended family"), + ("M", "Relatives by marriage"), + ("B", "Business and property relationships"), + ("A", "Ambiguity"), + ], + max_length=1, + ), + ), + migrations.RunPython( + code=populate_ambiguous_types, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/geniza/entities/migrations/__init__.py b/geniza/entities/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/geniza/entities/models.py b/geniza/entities/models.py new file mode 100644 index 000000000..1eb5eed08 --- /dev/null +++ b/geniza/entities/models.py @@ -0,0 +1,396 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext as _ +from gfklookupwidget.fields import GfkLookupField + +from geniza.common.models import cached_class_property +from geniza.corpus.models import DisplayLabelMixin, Document, LanguageScript +from geniza.footnotes.models import Footnote + + +class Name(models.Model): + """A name for an entity, such as a person or a place.""" + + name = models.CharField(max_length=255) + primary = models.BooleanField( + default=False, + help_text="Check box if this is the primary name that should be displayed on the site.", + verbose_name="Display name", + ) + language = models.ForeignKey( + LanguageScript, + on_delete=models.SET_NULL, + null=True, + help_text="Please indicate the language of most components of the name as written here.", + ) + notes = models.TextField(blank=True) + + # Generic relationship with named entities + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + limit_choices_to=models.Q(app_label="entities"), + ) + object_id = GfkLookupField("content_type") + content_object = GenericForeignKey() + + # transliteration style + PGP = "P" + CAMBRIDGE = "C" + NONE = "N" + TRANSLITERATION_CHOICES = ( + (NONE, "N/A"), + (PGP, "PGP"), + (CAMBRIDGE, "Cambridge"), + ) + transliteration_style = models.CharField( + max_length=1, + choices=TRANSLITERATION_CHOICES, + default=NONE, + ) + + def __str__(self): + return self.name + + +class PersonRoleManager(models.Manager): + """Custom manager for :class:`PersonRole` with natural key lookup""" + + def get_by_natural_key(self, name): + "natural key lookup, based on name" + return self.get(name_en=name) + + +class PersonRole(DisplayLabelMixin, models.Model): + """Controlled vocabulary of person roles.""" + + name = models.CharField(max_length=255, unique=True) + display_label = models.CharField( + max_length=255, + blank=True, + help_text="Optional label for display on the public site", + ) + objects = PersonRoleManager() + + @cached_class_property + def objects_by_label(cls): + return super().objects_by_label() + + class Meta: + verbose_name = "Person social role" + verbose_name_plural = "Person social roles" + + +class Person(models.Model): + """A person entity that appears within the PGP corpus.""" + + names = GenericRelation(Name, related_query_name="person") + description = models.TextField( + blank=True, + help_text="A description that will appear on the public Person page if 'Person page' box is checked.", + ) + has_page = models.BooleanField( + help_text="Check box if this person should have a dedicated, public Person page on the PGP. If checked, please draft a public description below.", + default=False, + verbose_name="Person page", + ) + documents = models.ManyToManyField( + Document, + related_name="people", + through="PersonDocumentRelation", + verbose_name="Related Documents", + ) + relationships = models.ManyToManyField( + "self", + related_name="related_to", + # asymmetrical because the reverse relation would have a different type + symmetrical=False, + through="PersonPersonRelation", + verbose_name="Related People", + ) + role = models.ForeignKey( + PersonRole, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Role", + help_text="Social role", + ) + # sources for the information gathered here + footnotes = models.ManyToManyField(Footnote, blank=True, related_name="people") + + # gender options + MALE = "M" + FEMALE = "F" + UNKNOWN = "U" + GENDER_CHOICES = ( + (MALE, _("Male")), + (FEMALE, _("Female")), + (UNKNOWN, _("Unknown")), + ) + gender = models.CharField(max_length=1, choices=GENDER_CHOICES) + + class Meta: + verbose_name_plural = "People" + + def __str__(self): + """ + Display the person using their primary name, if one is designated, + otherwise display the first name. + """ + try: + return str(self.names.get(primary=True)) + except Name.MultipleObjectsReturned: + return str(self.names.filter(primary=True).first()) + except Name.DoesNotExist: + return str(self.names.first() or super().__str__()) + + +class PersonDocumentRelationTypeManager(models.Manager): + """Custom manager for :class:`PersonDocumentRelationType` with natural key lookup""" + + def get_by_natural_key(self, name): + "natural key lookup, based on name" + return self.get(name_en=name) + + +class PersonDocumentRelationType(models.Model): + """Controlled vocabulary of people's relationships to documents.""" + + name = models.CharField(max_length=255, unique=True) + objects = PersonDocumentRelationTypeManager() + + class Meta: + verbose_name = "Person-Document relationship" + verbose_name_plural = "Person-Document relationships" + + def __str__(self): + return self.name + + +class PersonDocumentRelation(models.Model): + """A relationship between a person and a document.""" + + person = models.ForeignKey(Person, on_delete=models.CASCADE) + document = models.ForeignKey(Document, on_delete=models.CASCADE) + type = models.ForeignKey( + PersonDocumentRelationType, + on_delete=models.SET_NULL, + null=True, + verbose_name="Relation", + ) + notes = models.TextField(blank=True) + + class Meta: + constraints = [ + # only allow one relationship per type between person and document + models.UniqueConstraint( + fields=("type", "person", "document"), + name="unique_person_document_relation_by_type", + ), + ] + + def __str__(self): + return f"{self.type} relation: {self.person} and {self.document}" + + +class PersonPersonRelationTypeManager(models.Manager): + """Custom manager for :class:`PersonPersonRelationType` with natural key lookup""" + + def get_by_natural_key(self, name): + "natural key lookup, based on name" + return self.get(name_en=name) + + +class PersonPersonRelationType(models.Model): + """Controlled vocabulary of people's relationships to other people.""" + + # name of the relationship + name = models.CharField(max_length=255, unique=True) + # converse_name is the relationship in the reverse direction (the semantic converse) + # (example: name = "Child", converse_name = "Parent") + converse_name = models.CharField( + max_length=255, + blank=True, + help_text="""The converse of the relationship, for example, 'Child' when Name is 'Parent'. + May leave blank if the converse is identical (for example, 'Spouse' and 'Spouse').""", + ) + # categories for interpersonal relations: + IMMEDIATE_FAMILY = "I" + EXTENDED_FAMILY = "E" + BY_MARRIAGE = "M" + BUSINESS = "B" + AMBIGUITY = "A" + CATEGORY_CHOICES = ( + (IMMEDIATE_FAMILY, _("Immediate family relations")), + (EXTENDED_FAMILY, _("Extended family")), + (BY_MARRIAGE, _("Relatives by marriage")), + (BUSINESS, _("Business and property relationships")), + (AMBIGUITY, _("Ambiguity")), + ) + category = models.CharField( + max_length=1, + choices=CATEGORY_CHOICES, + ) + objects = PersonPersonRelationTypeManager() + + class Meta: + verbose_name = "Person-Person relationship" + verbose_name_plural = "Person-Person relationships" + + def __str__(self): + return self.name + + +class PersonPersonRelation(models.Model): + """A relationship between two people.""" + + from_person = models.ForeignKey( + Person, on_delete=models.CASCADE, related_name="to_person" + ) + to_person = models.ForeignKey( + Person, + on_delete=models.CASCADE, + related_name="from_person", + verbose_name="Person", + ) + type = models.ForeignKey( + PersonPersonRelationType, + on_delete=models.SET_NULL, + null=True, + verbose_name="Relation", + ) + notes = models.TextField(blank=True) + + class Meta: + constraints = [ + # only allow one relationship per type between person and person + models.UniqueConstraint( + fields=("type", "from_person", "to_person"), + name="unique_person_person_relation_by_type", + ), + # do not allow from_person and to_person to be the same person + models.CheckConstraint( + name="%(app_label)s_%(class)s_prevent_self_relationship", + check=~models.Q(from_person=models.F("to_person")), + ), + ] + + def __str__(self): + relation_type = ( + f"{self.type}-{self.type.converse_name}" + if self.type.converse_name + else self.type + ) + return f"{relation_type} relation: {self.to_person} and {self.from_person}" + + +class Place(models.Model): + """A named geographical location, which may be associated with documents or people.""" + + names = GenericRelation(Name, related_query_name="place") + latitude = models.DecimalField( + max_digits=6, + decimal_places=4, + blank=True, + null=True, + ) + longitude = models.DecimalField( + max_digits=7, + decimal_places=4, + blank=True, + null=True, + ) + # sources for the information gathered here + footnotes = models.ManyToManyField(Footnote, blank=True, related_name="places") + + def __str__(self): + """ + Display the place using its display name, if one is designated, + otherwise display the first name. + """ + try: + return str(self.names.get(primary=True)) + except Name.MultipleObjectsReturned: + return str(self.names.filter(primary=True).first()) + except Name.DoesNotExist: + return str(self.names.first() or super().__str__()) + + +class PersonPlaceRelationTypeManager(models.Manager): + """Custom manager for :class:`PersonPlaceRelationType` with natural key lookup""" + + def get_by_natural_key(self, name): + "natural key lookup, based on name" + return self.get(name_en=name) + + +class PersonPlaceRelationType(models.Model): + """Controlled vocabulary of people's relationships to places.""" + + name = models.CharField(max_length=255, unique=True) + objects = PersonPlaceRelationTypeManager() + + class Meta: + verbose_name = "Person-Place relationship" + verbose_name_plural = "Person-Place relationships" + + def __str__(self): + return self.name + + +class PersonPlaceRelation(models.Model): + """A relationship between a person and a place.""" + + person = models.ForeignKey(Person, on_delete=models.CASCADE) + place = models.ForeignKey(Place, on_delete=models.CASCADE) + type = models.ForeignKey( + PersonPlaceRelationType, + on_delete=models.SET_NULL, + null=True, + verbose_name="Relation", + ) + notes = models.TextField(blank=True) + + def __str__(self): + return f"{self.type} relation: {self.person} and {self.place}" + + +class DocumentPlaceRelationTypeManager(models.Manager): + """Custom manager for :class:`DocumentPlaceRelationType` with natural key lookup""" + + def get_by_natural_key(self, name): + "natural key lookup, based on name" + return self.get(name_en=name) + + +class DocumentPlaceRelationType(models.Model): + """Controlled vocabulary of documents' relationships to places.""" + + name = models.CharField(max_length=255, unique=True) + objects = DocumentPlaceRelationTypeManager() + + class Meta: + verbose_name = "Document-Place relationship" + verbose_name_plural = "Document-Place relationships" + + def __str__(self): + return self.name + + +class DocumentPlaceRelation(models.Model): + """A relationship between a document and a place.""" + + document = models.ForeignKey(Document, on_delete=models.CASCADE) + place = models.ForeignKey(Place, on_delete=models.CASCADE) + type = models.ForeignKey( + DocumentPlaceRelationType, + on_delete=models.SET_NULL, + null=True, + verbose_name="Relation", + ) + notes = models.TextField(blank=True) + + def __str__(self): + return f"{self.type} relation: {self.document} and {self.place}" diff --git a/geniza/entities/templates/admin/entities/person/change_form.html b/geniza/entities/templates/admin/entities/person/change_form.html new file mode 100644 index 000000000..baf0a3993 --- /dev/null +++ b/geniza/entities/templates/admin/entities/person/change_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} + +{# Render mixed normal and inline fieldsets #} +{% block field_sets %} + {% include "admin/snippets/mixed_inlines_fieldsets.html" %} +{% endblock %} + +{# Remove standard inline rendering #} +{% block inline_field_sets %} +{% endblock %} diff --git a/geniza/entities/templates/admin/entities/place/change_form.html b/geniza/entities/templates/admin/entities/place/change_form.html new file mode 100644 index 000000000..baf0a3993 --- /dev/null +++ b/geniza/entities/templates/admin/entities/place/change_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} + +{# Render mixed normal and inline fieldsets #} +{% block field_sets %} + {% include "admin/snippets/mixed_inlines_fieldsets.html" %} +{% endblock %} + +{# Remove standard inline rendering #} +{% block inline_field_sets %} +{% endblock %} diff --git a/geniza/entities/tests/test_entities_admin.py b/geniza/entities/tests/test_entities_admin.py new file mode 100644 index 000000000..fbd375802 --- /dev/null +++ b/geniza/entities/tests/test_entities_admin.py @@ -0,0 +1,242 @@ +from unittest.mock import Mock + +import pytest +from django.contrib import admin +from django.test import RequestFactory +from django.urls import reverse +from pytest_django.asserts import assertContains, assertNotContains + +from geniza.corpus.models import Document, LanguageScript +from geniza.entities.admin import ( + NameInlineFormSet, + PersonAdmin, + PersonDocumentInline, + PersonPersonInline, + PersonPersonRelationTypeChoiceField, + PersonPersonReverseInline, + PersonPlaceInline, +) +from geniza.entities.models import ( + Name, + Person, + PersonDocumentRelation, + PersonPersonRelation, + PersonPersonRelationType, + PersonPlaceRelation, + Place, +) + + +@pytest.mark.django_db +class TestPersonDocumentInline: + def test_document_link(self): + goitein = Person.objects.create() + doc = Document.objects.create() + relation = PersonDocumentRelation.objects.create(person=goitein, document=doc) + inline = PersonDocumentInline(goitein, admin_site=admin.site) + + doc_link = inline.document_link(relation) + + assert str(doc.id) in doc_link + assert str(doc) in doc_link + + def test_document_description(self): + goitein = Person.objects.create() + test_description = "A medieval poem" + doc = Document.objects.create(description_en=test_description) + relation = PersonDocumentRelation.objects.create(person=goitein, document=doc) + inline = PersonDocumentInline(goitein, admin_site=admin.site) + + assert test_description == inline.document_description(relation) + + +@pytest.mark.django_db +class TestPersonPersonInline: + def test_get_formset(self): + # should set "type" field to PersonPersonRelationTypeChoiceField + inline = PersonPersonInline(Person, admin_site=admin.site) + formset = inline.get_formset(request=Mock()) + assert isinstance( + formset.form.base_fields["type"], PersonPersonRelationTypeChoiceField + ) + + +@pytest.mark.django_db +class TestPersonPersonReverseInline: + def test_relation(self): + # should show converse relationship type when available + (parent, _) = PersonPersonRelationType.objects.get_or_create( + name="Parent", + converse_name="Child", + category=PersonPersonRelationType.IMMEDIATE_FAMILY, + ) + ayala_gordon = Person.objects.create() + sd_goitein = Person.objects.create() + goitein_ayala = PersonPersonRelation.objects.create( + from_person=ayala_gordon, + to_person=sd_goitein, + type=parent, + ) + reverse_inline = PersonPersonReverseInline(Person, admin_site=admin.site) + assert reverse_inline.relation(goitein_ayala) == "Child" + + # otherwise should just show relationship type + (sibilng, _) = PersonPersonRelationType.objects.get_or_create( + name="Sibling", + category=PersonPersonRelationType.IMMEDIATE_FAMILY, + ) + elon_goitein = Person.objects.create() + goitein_siblings = PersonPersonRelation.objects.create( + from_person=ayala_gordon, + to_person=elon_goitein, + type=sibilng, + ) + assert reverse_inline.relation(goitein_siblings) == "Sibling" + + +@pytest.mark.django_db +class TestPersonPlaceInline: + def test_place_link(self): + goitein = Person.objects.create() + place = Place.objects.create() + relation = PersonPlaceRelation.objects.create(person=goitein, place=place) + inline = PersonPlaceInline(Person, admin_site=admin.site) + # should link to Place object + place_link = inline.place_link(relation) + assert str(place.id) in place_link + assert str(place) in place_link + + +@pytest.mark.django_db +class TestPersonAdmin: + def test_get_form(self): + # should set own_pk property if obj exists + goitein = Person.objects.create() + person_admin = PersonAdmin(model=Person, admin_site=admin.site) + mockrequest = Mock() + person_admin.get_form(mockrequest, obj=goitein) + assert person_admin.own_pk == goitein.pk + + # create new person, should be reset to None + person_admin.get_form(mockrequest, obj=None) + assert person_admin.own_pk == None + + def test_get_queryset(self): + goitein = Person.objects.create() + Person.objects.create() + Person.objects.create() + + person_admin = PersonAdmin(model=Person, admin_site=admin.site) + + request_factory = RequestFactory() + + # simulate request for person list page + request = request_factory.post("/admin/entities/person/") + qs = person_admin.get_queryset(request) + assert qs.count() == 3 + + # simulate get_form setting own_pk + person_admin.own_pk = goitein.pk + + # simulate autocomplete request + request = request_factory.post("/admin/autocomplete/") + qs = person_admin.get_queryset(request) + # should exclude Person with pk=own_pk + assert qs.count() == 2 + assert not qs.filter(pk=goitein.pk).exists() + + +@pytest.mark.django_db +class TestNameInlineFormSet: + def test_clean(self, admin_client): + english = LanguageScript.objects.create(language="English", script="Latin") + # should raise validation error if zero primary names + response = admin_client.post( + reverse("admin:entities_person_add"), + data={ + "entities-name-content_type-object_id-INITIAL_FORMS": ["0"], + "entities-name-content_type-object_id-TOTAL_FORMS": ["2"], + "entities-name-content_type-object_id-MAX_NUM_FORMS": ["1000"], + "entities-name-content_type-object_id-0-name": "Marina Rustow", + "entities-name-content_type-object_id-0-language": str(english.pk), + "entities-name-content_type-object_id-0-transliteration_style": Name.NONE, + "entities-name-content_type-object_id-1-name": "S.D. Goitein", + "entities-name-content_type-object_id-1-language": str(english.pk), + "entities-name-content_type-object_id-1-transliteration_style": Name.NONE, + }, + ) + assertContains(response, NameInlineFormSet.DISPLAY_NAME_ERROR) + + # should raise validation error if two primary names + response = admin_client.post( + reverse("admin:entities_person_add"), + data={ + "entities-name-content_type-object_id-INITIAL_FORMS": ["0"], + "entities-name-content_type-object_id-TOTAL_FORMS": ["2"], + "entities-name-content_type-object_id-MAX_NUM_FORMS": ["1000"], + "entities-name-content_type-object_id-0-name": "Marina Rustow", + "entities-name-content_type-object_id-0-primary": "on", + "entities-name-content_type-object_id-0-language": str(english.pk), + "entities-name-content_type-object_id-0-transliteration_style": Name.NONE, + "entities-name-content_type-object_id-1-name": "S.D. Goitein", + "entities-name-content_type-object_id-1-primary": "on", + "entities-name-content_type-object_id-1-language": str(english.pk), + "entities-name-content_type-object_id-1-transliteration_style": Name.NONE, + }, + ) + assertContains(response, NameInlineFormSet.DISPLAY_NAME_ERROR) + + # should NOT raise validation error if exactly one primary name + response = admin_client.post( + reverse("admin:entities_person_add"), + data={ + "entities-name-content_type-object_id-INITIAL_FORMS": ["0"], + "entities-name-content_type-object_id-TOTAL_FORMS": ["2"], + "entities-name-content_type-object_id-MAX_NUM_FORMS": ["1000"], + "entities-name-content_type-object_id-0-name": "Marina Rustow", + "entities-name-content_type-object_id-0-primary": "on", + "entities-name-content_type-object_id-0-language": str(english.pk), + "entities-name-content_type-object_id-0-transliteration_style": Name.NONE, + "entities-name-content_type-object_id-1-name": "S.D. Goitein", + "entities-name-content_type-object_id-1-language": str(english.pk), + "entities-name-content_type-object_id-1-transliteration_style": Name.NONE, + }, + ) + assertNotContains(response, NameInlineFormSet.DISPLAY_NAME_ERROR) + + +@pytest.mark.django_db +class TestPersonPersonRelationTypeChoiceIterator: + def test_iter(self): + # create three types, one in one category and two in another + type_a = PersonPersonRelationType.objects.create( + category=PersonPersonRelationType.IMMEDIATE_FAMILY, + name="Some family member", + ) + type_b = PersonPersonRelationType.objects.create( + category=PersonPersonRelationType.EXTENDED_FAMILY, + name="Distant cousin", + ) + type_c = PersonPersonRelationType.objects.create( + category=PersonPersonRelationType.EXTENDED_FAMILY, + name="Same category", + ) + field = PersonPersonRelationTypeChoiceField( + queryset=PersonPersonRelationType.objects.filter( + pk__in=[type_a.pk, type_b.pk, type_c.pk], + ) + ) + # field choice categories should use the full names, so grab those from model + immediate_family = dict(PersonPersonRelationType.CATEGORY_CHOICES)[ + PersonPersonRelationType.IMMEDIATE_FAMILY + ] + extended_family = dict(PersonPersonRelationType.CATEGORY_CHOICES)[ + PersonPersonRelationType.EXTENDED_FAMILY + ] + # convert tuple to dict to make this easier to traverse + choices = dict(field.choices) + # choices should be grouped into their correct categories, as lists + assert len(choices[immediate_family]) == 1 + assert len(choices[extended_family]) == 2 + assert (type_a.pk, type_a.name) in choices[immediate_family] + assert (type_a.pk, type_a.name) not in choices[extended_family] diff --git a/geniza/entities/tests/test_entities_models.py b/geniza/entities/tests/test_entities_models.py new file mode 100644 index 000000000..550fd1a87 --- /dev/null +++ b/geniza/entities/tests/test_entities_models.py @@ -0,0 +1,160 @@ +import pytest + +from geniza.corpus.models import Document +from geniza.entities.models import ( + DocumentPlaceRelation, + DocumentPlaceRelationType, + Name, + Person, + PersonDocumentRelation, + PersonDocumentRelationType, + PersonPersonRelation, + PersonPersonRelationType, + PersonPlaceRelation, + PersonPlaceRelationType, + PersonRole, + Place, +) + + +@pytest.mark.django_db +class TestPerson: + def test_str(self): + person = Person.objects.create() + # Person with no name uses default django __str__ method + assert str(person) == f"Person object ({person.pk})" + # add two names + secondary_name = Name.objects.create( + name="Shelomo Dov Goitein", content_object=person + ) + primary_name = Name.objects.create(name="S.D. Goitein", content_object=person) + # __str__ should use the first name added + assert str(person) == secondary_name.name + # set one of them as primary + primary_name.primary = True + primary_name.save() + # __str__ should use the primary name + assert str(person) == primary_name.name + + +@pytest.mark.django_db +class TestPersonRole: + def test_objects_by_label(self): + """Should return dict of PersonRole objects keyed on English label""" + # invalidate cached property (it is computed in other tests in the suite) + if "objects_by_label" in PersonRole.__dict__: + # __dict__["objects_by_label"] returns a classmethod + # __func__ returns a property + # fget returns the actual cached function + PersonRole.__dict__["objects_by_label"].__func__.fget.cache_clear() + # add some new roles + role = PersonRole(name_en="Some kind of official") + role.save() + role_2 = PersonRole(display_label_en="Example") + role_2.save() + # should be able to get a role by label + assert isinstance( + PersonRole.objects_by_label.get("Some kind of official"), PersonRole + ) + # should match by name_en or display_label_en, depending on what's set + assert PersonRole.objects_by_label.get("Some kind of official").pk == role.pk + assert PersonRole.objects_by_label.get("Example").pk == role_2.pk + + +@pytest.mark.django_db +class TestPersonPersonRelation: + def test_str(self): + goitein = Person.objects.create() + Name.objects.create(name="S.D. Goitein", content_object=goitein) + rustow = Person.objects.create() + Name.objects.create(name="Marina Rustow", content_object=rustow) + friendship_type = PersonPersonRelationType.objects.create( + name="Friend", category=PersonPersonRelationType.BUSINESS + ) + friendship = PersonPersonRelation.objects.create( + from_person=goitein, + to_person=rustow, + type=friendship_type, + ) + assert str(friendship) == f"{friendship_type} relation: {rustow} and {goitein}" + + # with converse_name + ayala = Person.objects.create() + Name.objects.create(name="Ayala Gordon", content_object=ayala) + (parent_child, _) = PersonPersonRelationType.objects.get_or_create( + name="Parent", + converse_name="Child", + category=PersonPersonRelationType.IMMEDIATE_FAMILY, + ) + goitein_ayala = PersonPersonRelation.objects.create( + from_person=ayala, + to_person=goitein, + type=parent_child, + ) + assert str(goitein_ayala) == f"Parent-Child relation: {goitein} and {ayala}" + + +@pytest.mark.django_db +class TestPersonDocumentRelation: + def test_str(self): + goitein = Person.objects.create() + Name.objects.create(name="S.D. Goitein", content_object=goitein) + recipient = PersonDocumentRelationType.objects.create(name="Test Recipient") + doc = Document.objects.create() + relation = PersonDocumentRelation.objects.create( + person=goitein, + document=doc, + type=recipient, + ) + assert str(relation) == f"{recipient} relation: {goitein} and {doc}" + + +@pytest.mark.django_db +class TestPlace: + def test_str(self): + place = Place.objects.create() + # Place with no name uses default django __str__ method + assert str(place) == f"Place object ({place.pk})" + # add two names + secondary_name = Name.objects.create(name="Philly", content_object=place) + primary_name = Name.objects.create(name="Philadelphia", content_object=place) + # __str__ should use the first name added + assert str(place) == secondary_name.name + # set one of them as primary + primary_name.primary = True + primary_name.save() + # __str__ should use the primary name + assert str(place) == primary_name.name + + +@pytest.mark.django_db +class TestPersonPlaceRelation: + def test_str(self): + goitein = Person.objects.create() + Name.objects.create(name="S.D. Goitein", content_object=goitein) + philadelphia = Place.objects.create() + Name.objects.create(name="Philadelphia", content_object=philadelphia) + (home_base, _) = PersonPlaceRelationType.objects.get_or_create(name="Home base") + relation = PersonPlaceRelation.objects.create( + person=goitein, + place=philadelphia, + type=home_base, + ) + assert str(relation) == f"{home_base} relation: {goitein} and {philadelphia}" + + +@pytest.mark.django_db +class TestDocumentPlaceRelation: + def test_str(self): + fustat = Place.objects.create() + Name.objects.create(name="Fustat", content_object=fustat) + (letter_origin, _) = DocumentPlaceRelationType.objects.get_or_create( + name="Letter origin" + ) + doc = Document.objects.create() + relation = DocumentPlaceRelation.objects.create( + place=fustat, + document=doc, + type=letter_origin, + ) + assert str(relation) == f"{letter_origin} relation: {doc} and {fustat}" diff --git a/geniza/entities/translation.py b/geniza/entities/translation.py new file mode 100644 index 000000000..f65b40744 --- /dev/null +++ b/geniza/entities/translation.py @@ -0,0 +1,47 @@ +from modeltranslation.translator import TranslationOptions, register + +from geniza.entities.models import ( + DocumentPlaceRelationType, + Person, + PersonDocumentRelationType, + PersonPersonRelationType, + PersonPlaceRelationType, + PersonRole, +) + + +@register(Person) +class PersonTranslationOption(TranslationOptions): + fields = ("description",) + required_languages = () + + +@register(PersonDocumentRelationType) +class PersonDocumentRelationTypeOption(TranslationOptions): + fields = ("name",) + required_languages = () + + +@register(PersonPersonRelationType) +class PersonPersonRelationTypeOption(TranslationOptions): + fields = ("name", "converse_name") + required_languages = () + + +@register(PersonRole) +class PersonRoleTranslationOption(TranslationOptions): + fields = ("name", "display_label") + required_languages = {"en": ("name",)} + empty_values = {"name": None} + + +@register(PersonPlaceRelationType) +class PersonPlaceRelationTypeOption(TranslationOptions): + fields = ("name",) + required_languages = () + + +@register(DocumentPlaceRelationType) +class DocumentPlaceRelationTypeOption(TranslationOptions): + fields = ("name",) + required_languages = () diff --git a/geniza/footnotes/admin.py b/geniza/footnotes/admin.py index 33dc725c9..975398db9 100644 --- a/geniza/footnotes/admin.py +++ b/geniza/footnotes/admin.py @@ -38,8 +38,8 @@ class AuthorshipInline(SortableInlineAdminMixin, admin.TabularInline): # reusable exception for digital edition footnote validation DuplicateDigitalEditionsError = ValidationError( """ - You cannot create multiple Digital Edition footnotes on the - same source and document. + You cannot create multiple Digital Edition footnotes, or multiple + Digital Translation footnotes, on the same source and document. """, code="invalid", ) @@ -63,15 +63,19 @@ def clean(self): document_contenttype = ContentType.objects.get( app_label="corpus", model="document" ) - document_pks = [ - fn.get("object_id") - for fn in cleaned_data - if Footnote.DIGITAL_EDITION in fn.get("doc_relation", []) - and fn.get("content_type").pk == document_contenttype.pk - ] - # if there are any duplicate document pks, it's invalid - if len(document_pks) > len(set(document_pks)): - raise DuplicateDigitalEditionsError + for digital_relation in [ + Footnote.DIGITAL_EDITION, + Footnote.DIGITAL_TRANSLATION, + ]: + document_pks = [ + fn.get("object_id") + for fn in cleaned_data + if digital_relation in fn.get("doc_relation", []) + and fn.get("content_type").pk == document_contenttype.pk + ] + # if there are any duplicate document pks, it's invalid + if len(document_pks) > len(set(document_pks)): + raise DuplicateDigitalEditionsError class SourceFootnoteInline(TabularInlinePaginated): @@ -161,14 +165,18 @@ def clean(self): cleaned_data = [form.cleaned_data for form in valid_forms] # get source pk of all digital editions if all("source" in fn for fn in cleaned_data): - sources = [ - fn.get("source").pk - for fn in cleaned_data - if Footnote.DIGITAL_EDITION in fn.get("doc_relation", []) - ] - # if there are any duplicate source pks, raise validation error - if len(sources) > len(set(sources)): - raise DuplicateDigitalEditionsError + for digital_relation in [ + Footnote.DIGITAL_EDITION, + Footnote.DIGITAL_TRANSLATION, + ]: + sources = [ + fn.get("source").pk + for fn in cleaned_data + if digital_relation in fn.get("doc_relation", []) + ] + # if there are any duplicate source pks, raise validation error + if len(sources) > len(set(sources)): + raise DuplicateDigitalEditionsError class DocumentFootnoteInline(GenericTabularInline): @@ -328,16 +336,22 @@ def clean(self): already exists on this document and source """ super().clean() - if ( - Footnote.DIGITAL_EDITION in self.cleaned_data.get("doc_relation", []) - and Footnote.objects.filter( - content_type=self.cleaned_data.get("content_type"), - object_id=self.cleaned_data.get("object_id"), - source=self.cleaned_data.get("source"), - doc_relation__contains=Footnote.DIGITAL_EDITION, - ).exists() - ): - raise DuplicateDigitalEditionsError + for digital_relation in [ + Footnote.DIGITAL_EDITION, + Footnote.DIGITAL_TRANSLATION, + ]: + if ( + digital_relation in self.cleaned_data.get("doc_relation", []) + and Footnote.objects.filter( + content_type=self.cleaned_data.get("content_type"), + object_id=self.cleaned_data.get("object_id"), + source=self.cleaned_data.get("source"), + doc_relation__contains=digital_relation, + ) + .exclude(pk=self.instance.pk) # exclude self! + .exists() + ): + raise DuplicateDigitalEditionsError @admin.register(Footnote) diff --git a/geniza/footnotes/metadata_export.py b/geniza/footnotes/metadata_export.py index 68e5758fd..522907687 100644 --- a/geniza/footnotes/metadata_export.py +++ b/geniza/footnotes/metadata_export.py @@ -58,7 +58,9 @@ def get_export_data_dict(self, source): "edition": source.edition, "other_info": source.other_info, "page_range": source.page_range, - "languages": {lang.name for lang in source.languages.all()}, + "languages": { + lang.name for lang in source.languages.all() if lang.code != "zxx" + }, "url": source.url, "notes": source.notes, # count via annotated queryset diff --git a/geniza/footnotes/migrations/0029_source_help_text.py b/geniza/footnotes/migrations/0029_source_help_text.py new file mode 100644 index 000000000..d09305f1d --- /dev/null +++ b/geniza/footnotes/migrations/0029_source_help_text.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.16 on 2023-07-18 20:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("footnotes", "0028_sourcelanguage_direction"), + ] + + operations = [ + migrations.AlterField( + model_name="source", + name="languages", + field=models.ManyToManyField( + help_text="The language(s) the source is written in. Note: Sources should never include\n transcription language unless the entire source consists of a transcription.", + to="footnotes.SourceLanguage", + ), + ), + migrations.AlterField( + model_name="source", + name="source_type", + field=models.ForeignKey( + help_text="The form of the source's publication. Note: for unpublished sources, be sure\n to create separate Source records for unpublished transcriptions and unpublished\n translations, even if they reside on the same digital document.", + on_delete=django.db.models.deletion.CASCADE, + to="footnotes.sourcetype", + ), + ), + ] diff --git a/geniza/footnotes/migrations/0030_digital_footnote_location.py b/geniza/footnotes/migrations/0030_digital_footnote_location.py new file mode 100644 index 000000000..63226d168 --- /dev/null +++ b/geniza/footnotes/migrations/0030_digital_footnote_location.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.16 on 2023-07-18 19:04 + +from django.db import migrations +from django.db.models import Q + + +def migrate_footnote_locations(apps, schema_editor): + # migration to copy footnote locations from all EDITION and TRANSLATION footnotes + # to digital footnotes on the same source + document that are missing location + Footnote = apps.get_model("footnotes", "Footnote") + + # we cannot use model constants in a migration, so for reference: + # "X" is Footnote.DIGITAL_EDITION, "E" is Footnote.EDITION + # "Y" is Footnote.DIGITAL_TRANSLATION, "T" is Footnote.TRANSLATION + + # get all digital footnotes that are missing location + digital_footnotes = Footnote.objects.filter( + Q(doc_relation__contains="X") | Q(doc_relation__contains="Y"), + location="", + ) + + for digital_fn in digital_footnotes: + if "X" in digital_fn.doc_relation: + # get Edition for Digital Edition + corresponding_footnote = Footnote.objects.filter( + doc_relation__contains="E", + source__pk=digital_fn.source.pk, + object_id=digital_fn.object_id, + content_type=digital_fn.content_type, + ).exclude(location="") + elif "Y" in digital_fn.doc_relation: + # get Translation for Digital Translation + corresponding_footnote = Footnote.objects.filter( + doc_relation__contains="T", + source__pk=digital_fn.source.pk, + object_id=digital_fn.object_id, + content_type=digital_fn.content_type, + ).exclude(location="") + + # ensure there is exactly one corresponding; otherwise ambiguous which location to use + if corresponding_footnote.count() == 1: + # set location on digital edition and add it to update set + digital_fn.location = corresponding_footnote.first().location + digital_fn.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("footnotes", "0029_source_help_text"), + ] + + operations = [ + migrations.RunPython( + migrate_footnote_locations, reverse_code=migrations.RunPython.noop + ) + ] diff --git a/geniza/footnotes/migrations/0031_unspecified_source_language.py b/geniza/footnotes/migrations/0031_unspecified_source_language.py new file mode 100644 index 000000000..4600f1955 --- /dev/null +++ b/geniza/footnotes/migrations/0031_unspecified_source_language.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.16 on 2023-08-22 19:13 + +from django.db import migrations, models + + +def create_unspecified_source_language(apps, schema_editor): + SourceLanguage = apps.get_model("footnotes", "SourceLanguage") + SourceLanguage.objects.get_or_create( + name="Unspecified (unpublished transcriptions)", code="zxx" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("footnotes", "0030_digital_footnote_location"), + ] + + operations = [ + migrations.RunPython( + create_unspecified_source_language, + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="source", + name="languages", + field=models.ManyToManyField( + help_text="The language(s) the source is written in. Note: The Unspecified language\n option should only ever be used for unpublished transcriptions, as the language of the\n transcription is already marked on the document.", + to="footnotes.SourceLanguage", + ), + ), + ] diff --git a/geniza/footnotes/models.py b/geniza/footnotes/models.py index 6303a7949..8780c0a17 100644 --- a/geniza/footnotes/models.py +++ b/geniza/footnotes/models.py @@ -158,9 +158,18 @@ class Source(models.Model): other_info = models.TextField( blank=True, help_text="Additional citation information, if any" ) - source_type = models.ForeignKey(SourceType, on_delete=models.CASCADE) + source_type = models.ForeignKey( + SourceType, + on_delete=models.CASCADE, + help_text="""The form of the source's publication. Note: for unpublished sources, be sure + to create separate Source records for unpublished transcriptions and unpublished + translations, even if they reside on the same digital document.""", + ) languages = models.ManyToManyField( - SourceLanguage, help_text="The language(s) the source is written in" + SourceLanguage, + help_text="""The language(s) the source is written in. Note: The Unspecified language + option should only ever be used for unpublished transcriptions, as the language of the + transcription is already marked on the document.""", ) url = models.URLField(blank=True, max_length=300, verbose_name="URL") # preliminary place to store transcription text; should not be editable @@ -259,11 +268,12 @@ def formatted_display(self, extra_fields=True): parts.append(edition_str) # Add non-English languages as parenthetical - non_english_langs = 0 + included_langs = 0 if self.languages.exists(): for lang in self.languages.all(): - if "English" not in str(lang): - non_english_langs += 1 + # Also prevent Unspecified from showing up in source citations + if "English" not in str(lang) and "Unspecified" not in str(lang): + included_langs += 1 parts.append("(in %s)" % lang) # Handling presence of book/journal title @@ -274,7 +284,7 @@ def formatted_display(self, extra_fields=True): # NOT "Title" (in Hebrew) --> "Title," (in Hebrew) if self.title and ( self.source_type.type in doublequoted_types - and not non_english_langs # put comma after language even when doublequotes present + and not included_langs # put comma after language even when doublequotes present ): # find rightmost doublequote formatted_title = parts[-1] @@ -378,7 +388,7 @@ def formatted_display(self, extra_fields=True): use_comma = ( extra_fields or self.title - or (self.journal and not non_english_langs) + or (self.journal and not included_langs) or self.source_type.type == "Unpublished" ) delimiter = ", " if use_comma else " " @@ -431,7 +441,7 @@ def from_uri(cls, uri): class FootnoteQuerySet(models.QuerySet): def includes_footnote(self, other): """Check if the current queryset includes a match for the - specified footnotes. Matches are made by comparing content source, + specified footnote. Matches are made by comparing content source, location, document relation type, and notes. Returns the matching object if there was one, or False if not.""" @@ -598,7 +608,10 @@ def content_html(self): # keyed on canvas uri # handle multiple annotations on the same canvas html_content = defaultdict(list) - for a in self.annotation_set.all(): + # order by optional position property (set by manual reorder in editor), then date + for a in self.annotation_set.all().order_by( + "content__schema:position", "created" + ): if a.label: html_content[a.target_source_id].append(f"