diff --git a/django/cantusdb_project/main_app/admin/source.py b/django/cantusdb_project/main_app/admin/source.py index 0bc8d6c4f..79818d5d2 100644 --- a/django/cantusdb_project/main_app/admin/source.py +++ b/django/cantusdb_project/main_app/admin/source.py @@ -3,7 +3,7 @@ from main_app.admin.base_admin import BaseModelAdmin, EXCLUDE, READ_ONLY from main_app.admin.filters import InputFilter from main_app.forms import AdminSourceForm -from main_app.models import Source +from main_app.models import Source, SourceIdentifier class SourceKeyFilter(InputFilter): @@ -15,10 +15,19 @@ def queryset(self, request, queryset): return queryset.filter(holding_institution__siglum__icontains=self.value()) +class IdentifiersInline(admin.TabularInline): + model = SourceIdentifier + extra = 0 + + def get_queryset(self, request): + return super().get_queryset(request).select_related("source__holding_institution") + + @admin.register(Source) class SourceAdmin(BaseModelAdmin): exclude = EXCLUDE + ("source_status",) raw_id_fields = ("holding_institution",) + inlines = (IdentifiersInline,) # These search fields are also available on the user-source inline relationship in the user admin page search_fields = ( @@ -28,6 +37,8 @@ class SourceAdmin(BaseModelAdmin): "holding_institution__migrated_identifier", "id", "provenance_notes", + "name", + "identifiers__identifier" ) readonly_fields = ( ("title", "siglum") diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py index 81dce3c75..bea4213e8 100644 --- a/django/cantusdb_project/main_app/forms.py +++ b/django/cantusdb_project/main_app/forms.py @@ -1,5 +1,14 @@ from django import forms from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.contrib.admin.widgets import ( + FilteredSelectMultiple, +) +from django.forms.widgets import CheckboxSelectMultiple +from dal import autocomplete +from volpiano_display_utilities.cantus_text_syllabification import syllabify_text +from volpiano_display_utilities.latin_word_syllabification import LatinError from .models import ( Chant, Service, @@ -22,13 +31,6 @@ SelectWidget, CheckboxWidget, ) -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.contrib.admin.widgets import ( - FilteredSelectMultiple, -) -from django.forms.widgets import CheckboxSelectMultiple -from dal import autocomplete # ModelForm allows to build a form directly from a model # see https://docs.djangoproject.com/en/3.0/topics/forms/modelforms/ @@ -71,6 +73,50 @@ def label_from_instance(self, obj): widget = CheckboxSelectMultiple() + +class CantusDBLatinField(forms.CharField): + """ + A custom CharField for chant text fields. Validates that the text + can be syllabified (essentially, that it does not have any improper + characters). + """ + + def validate(self, value): + super().validate(value) + if value: + try: + syllabify_text(value) + except LatinError as err: + raise forms.ValidationError(str(err)) + except ValueError as exc: + raise forms.ValidationError("Invalid characters in text.") from exc + + +class CantusDBSyllabifiedLatinField(forms.CharField): + """ + A custom CharField for chant syllabified text fields. Validates that the text + can be syllabified (essentially, that it does not have any improper + characters). + """ + + def validate(self, value): + super().validate(value) + if value: + try: + syllabify_text(value, text_presyllabified=True) + except ValueError as exc: + raise forms.ValidationError("Invalid characters in text.") from exc + +class StyledChoiceField(forms.ChoiceField): + """ + A custom ChoiceField that uses the custom SelectWidget defined in widgets.py + as its widget (for styling). + """ + + widget = SelectWidget() + + + class ChantCreateForm(forms.ModelForm): class Meta: model = Chant @@ -125,8 +171,8 @@ class Meta: "finalis": TextInputWidget(), "extra": TextInputWidget(), "chant_range": VolpianoInputWidget(), - # manuscript_full_text_std_spelling: defined below (required) - "manuscript_full_text": TextAreaWidget(), + # manuscript_full_text_std_spelling: defined below (required & special field) + # "manuscript_full_text": defined below (special field) "volpiano": VolpianoAreaWidget(), "image_link": TextInputWidget(), "melody_id": TextInputWidget(), @@ -153,14 +199,18 @@ class Meta: help_text="Each folio starts with '1'.", ) - manuscript_full_text_std_spelling = forms.CharField( + manuscript_full_text_std_spelling = CantusDBLatinField( + widget=TextAreaWidget, + help_text=Chant._meta.get_field("manuscript_full_text_std_spelling").help_text, + label="Full text as in Source (standardized spelling)", required=True, + ) + + manuscript_full_text = CantusDBLatinField( widget=TextAreaWidget, - help_text="Manuscript full text with standardized spelling. Enter the words " - "according to the manuscript but normalize their spellings following " - "Classical Latin forms. Use upper-case letters for proper nouns, " - 'the first word of each chant, and the first word after "Alleluia" for ' - "Mass Alleluias. Punctuation is omitted.", + label="Full text as in Source (source spelling)", + help_text=Chant._meta.get_field("manuscript_full_text").help_text, + required=False, ) project = SelectWidgetNameModelChoiceField( @@ -191,6 +241,7 @@ class Meta: # "siglum", "holding_institution", "shelfmark", + "name", "segment_m2m", "provenance", "provenance_notes", @@ -212,11 +263,15 @@ class Meta: "fragmentarium_id", "dact_id", "indexing_notes", + "production_method", + "source_completeness", ] widgets = { # "title": TextInputWidget(), # "siglum": TextInputWidget(), + "shelfmark": TextInputWidget(), "provenance": autocomplete.ModelSelect2(url="provenance-autocomplete"), + "name": TextInputWidget(), "provenance_notes": TextInputWidget(), "date": TextInputWidget(), "cursus": SelectWidget(), @@ -246,6 +301,8 @@ class Meta: "other_editors": autocomplete.ModelSelect2Multiple( url="all-users-autocomplete" ), + "production_method": SelectWidget(), + "source_completeness": SelectWidget(), } field_classes = { "segment_m2m": CheckboxNameModelMultipleChoiceField, @@ -253,32 +310,15 @@ class Meta: holding_institution = forms.ModelChoiceField( queryset=Institution.objects.all(), - required=True, widget=autocomplete.ModelSelect2(url="holding-autocomplete"), + required=False, ) - shelfmark = forms.CharField( - required=True, - widget=TextInputWidget, - ) - - TRUE_FALSE_CHOICES_SOURCE = ( - (True, "Full source"), - (False, "Fragment or Fragmented"), - ) - - full_source = forms.ChoiceField(choices=TRUE_FALSE_CHOICES_SOURCE, required=False) - full_source.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} - ) TRUE_FALSE_CHOICES_INVEN = ((True, "Complete"), (False, "Incomplete")) - complete_inventory = forms.ChoiceField( + complete_inventory = StyledChoiceField( choices=TRUE_FALSE_CHOICES_INVEN, required=False ) - complete_inventory.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} - ) class ChantEditForm(forms.ModelForm): @@ -319,8 +359,8 @@ class Meta: "rubrics", ] widgets = { - # manuscript_full_text_std_spelling: defined below (required) - "manuscript_full_text": TextAreaWidget(), + # manuscript_full_text_std_spelling: defined below (required) & special field + # manuscript_full_text: defined below (special field) "volpiano": VolpianoAreaWidget(), "marginalia": TextInputWidget(), # folio: defined below (required) @@ -354,14 +394,18 @@ class Meta: "rubrics": TextInputWidget(), } - manuscript_full_text_std_spelling = forms.CharField( + manuscript_full_text_std_spelling = CantusDBLatinField( + widget=TextAreaWidget, + help_text=Chant._meta.get_field("manuscript_full_text_std_spelling").help_text, + label="Full text as in Source (standardized spelling)", required=True, + ) + + manuscript_full_text = CantusDBLatinField( widget=TextAreaWidget, - help_text="Manuscript full text with standardized spelling. Enter the words " - "according to the manuscript but normalize their spellings following " - "Classical Latin forms. Use upper-case letters for proper nouns, " - 'the first word of each chant, and the first word after "Alleluia" for ' - "Mass Alleluias. Punctuation is omitted.", + label="Full text as in Source (source spelling)", + help_text=Chant._meta.get_field("manuscript_full_text").help_text, + required=False, ) folio = forms.CharField( @@ -391,6 +435,7 @@ class Meta: # "siglum", "holding_institution", "shelfmark", + "name", "segment_m2m", "provenance", "provenance_notes", @@ -413,12 +458,17 @@ class Meta: "full_text_entered_by", "proofreaders", "other_editors", + "production_method", + "source_completeness", ] widgets = { + "shelfmark": TextInputWidget(), "segment_m2m": CheckboxSelectMultiple(), + "name": TextInputWidget(), "provenance": autocomplete.ModelSelect2(url="provenance-autocomplete"), "provenance_notes": TextInputWidget(), "date": TextInputWidget(), + "cursus": SelectWidget(), "summary": TextAreaWidget(), "liturgical_occasions": TextAreaWidget(), "description": TextAreaWidget(), @@ -446,48 +496,24 @@ class Meta: "other_editors": autocomplete.ModelSelect2Multiple( url="all-users-autocomplete" ), + "production_method": SelectWidget(), + "source_completeness": SelectWidget(), } field_classes = { "segment_m2m": CheckboxNameModelMultipleChoiceField, } - shelfmark = forms.CharField( - required=True, - widget=TextInputWidget, - ) - holding_institution = forms.ModelChoiceField( queryset=Institution.objects.all(), - required=True, widget=autocomplete.ModelSelect2(url="holding-autocomplete"), + required=False, ) - CHOICES_FULL_SOURCE = ( - (None, "None"), - (True, "Full source"), - (False, "Fragment or Fragmented"), - ) - full_source = forms.ChoiceField(choices=CHOICES_FULL_SOURCE, required=False) - full_source.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} - ) - - CHOICES_CURSUS = ( - (None, "None"), - ("Monastic", "Monastic"), - ("Secular", "Secular"), - ) - cursus = forms.ChoiceField(choices=CHOICES_CURSUS, required=False) - cursus.widget.attrs.update({"class": "form-control custom-select custom-select-sm"}) - CHOICES_COMPLETE_INV = ( (True, "complete inventory"), (False, "partial inventory"), ) - complete_inventory = forms.ChoiceField(choices=CHOICES_COMPLETE_INV, required=False) - complete_inventory.widget.attrs.update( - {"class": "form-control custom-select custom-select-sm"} - ) + complete_inventory = StyledChoiceField(choices=CHOICES_COMPLETE_INV, required=False) class SequenceEditForm(forms.ModelForm): @@ -550,10 +576,14 @@ class Meta: "manuscript_full_text", "manuscript_syllabized_full_text", ] - widgets = { - "manuscript_full_text": TextAreaWidget(), - "manuscript_syllabized_full_text": TextAreaWidget(), - } + + manuscript_full_text = CantusDBLatinField( + widget=TextAreaWidget, label="Full text as in Source (source spelling)" + ) + + manuscript_syllabized_full_text = CantusDBSyllabifiedLatinField( + widget=TextAreaWidget, label="Syllabized full text" + ) class AdminCenturyForm(forms.ModelForm): @@ -720,27 +750,31 @@ class Meta: model = Source fields = "__all__" - title = forms.CharField( - required=True, - widget=TextInputWidget, - help_text="Full Source Identification (City, Archive, Shelf-mark)", - ) - title.widget.attrs.update({"style": "width: 610px;"}) + # title = forms.CharField( + # required=True, + # widget=TextInputWidget, + # help_text="Full Source Identification (City, Archive, Shelf-mark)", + # ) + # title.widget.attrs.update({"style": "width: 610px;"}) + # + # siglum = forms.CharField( + # required=True, + # widget=TextInputWidget, + # help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", + # ) - siglum = forms.CharField( - required=True, - widget=TextInputWidget, - help_text="RISM-style siglum + Shelf-mark (e.g. GB-Ob 202).", - ) shelfmark = forms.CharField( required=True, widget=TextInputWidget, ) + name = forms.CharField(required=False, widget=TextInputWidget) + + holding_institution = forms.ModelChoiceField( - queryset=Institution.objects.all().order_by("name"), - required=True, + queryset=Institution.objects.all().order_by("city", "name"), + required=False, ) provenance = forms.ModelChoiceField( diff --git a/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py b/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py new file mode 100644 index 000000000..ad25ce5ae --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/map_cantus_ids.py @@ -0,0 +1,46 @@ +""" +This is a (potentially temporary) command to change one Cantus ID to another +Cantus ID. For the moment (October 2024), Cantus Index and Cantus Database are +working out some issues in the process of merging Cantus ID's, so this command +gives us a more fine-grained (but more general than changing them all manually) +approach to changing Cantus ID's. +""" + +from django.core.management.base import BaseCommand +import reversion # type: ignore[import-untyped] + +from main_app.models import Chant + + +class Command(BaseCommand): + help = "Change one Cantus ID to another Cantus ID." + + def add_arguments(self, parser): + parser.add_argument( + "old_cantus_id", + type=str, + help="The Cantus ID to change.", + ) + parser.add_argument( + "new_cantus_id", + type=str, + help="The Cantus ID to change to.", + ) + + def handle(self, *args, **options): + old_cantus_id = options["old_cantus_id"] + new_cantus_id = options["new_cantus_id"] + with reversion.create_revision(): + chants = Chant.objects.filter(cantus_id=old_cantus_id) + num_chants = chants.count() + for chant in chants.iterator(chunk_size=1_000): + chant.cantus_id = new_cantus_id + chant.save() + self.stdout.write( + self.style.SUCCESS( + f"Changed {old_cantus_id} to {new_cantus_id} in {num_chants} chants." + ) + ) + reversion.set_comment( + f"Changed Cantus ID: {old_cantus_id} to {new_cantus_id}" + ) diff --git a/django/cantusdb_project/main_app/management/commands/migrate_records.py b/django/cantusdb_project/main_app/management/commands/migrate_records.py index bfda4e0a6..bc3f26325 100644 --- a/django/cantusdb_project/main_app/management/commands/migrate_records.py +++ b/django/cantusdb_project/main_app/management/commands/migrate_records.py @@ -163,7 +163,7 @@ def handle(self, *args, **options): ) ) institution = print_inst - elif siglum in created_institutions: + elif siglum in created_institutions and source.id not in bad_siglum: print( self.style.SUCCESS( f"Re-using the pre-created institution for {siglum}" @@ -185,7 +185,7 @@ def handle(self, *args, **options): institution.alternate_names = "\n".join(list(deduped_names)) institution.save() - elif siglum not in created_institutions: + elif siglum not in created_institutions and source.id not in bad_siglum: print(self.style.SUCCESS(f"Creating institution record for {siglum}")) iobj = { @@ -229,6 +229,8 @@ def handle(self, *args, **options): created_institutions[siglum] = institution else: + source.shelfmark = shelfmark.strip() + source.save() print( self.style.ERROR( f"Could not determine the holding institution for {source}" diff --git a/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py b/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py new file mode 100644 index 000000000..fe7d9e279 --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/populate_source_completeness.py @@ -0,0 +1,22 @@ +""" +A temporary command to populate the source_completeness field in the Source model, +based on the full_source field. This command will be removed once the source_completeness +is initially populated. +""" + +from django.core.management.base import BaseCommand +from main_app.models import Source + + +class Command(BaseCommand): + def handle(self, *args, **options): + sources = Source.objects.all() + for source in sources: + if source.full_source: + source.source_completeness = ( + source.SourceCompletenessChoices.FULL_SOURCE + ) + else: + source.source_completeness = source.SourceCompletenessChoices.FRAGMENT + source.save() + self.stdout.write(self.style.SUCCESS("Source completeness populated")) diff --git a/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py b/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py new file mode 100644 index 000000000..b11f3941a --- /dev/null +++ b/django/cantusdb_project/main_app/management/commands/reformat_source_ids.py @@ -0,0 +1,49 @@ +""" +A command designed to do a one-time reformatting of DACT IDs and Fragment +IDs in the database. + +Fragment IDs should be of the form "F-XXXX" where XXXX is some alphanumeric. +Fragment IDs are currently assumed to be in the form "F-XXXX" or "XXXX". +DACT IDs should be of the form "D:0XXXX" where XXXX is the Fragment ID alphanumeric. +DACT IDs are currently assumed to be in the form "0XXXX" or "D-0XXXX". + +This command simply adds the prefix "F-" to all Fragment IDs and "D:" to all +DACT IDs where they are missing. +""" + +from django.core.management.base import BaseCommand + +from main_app.models import Source + + +class Command(BaseCommand): + help = "Reformat DACT IDs and Fragment IDs in the database." + + def handle(self, *args, **options): + sources = Source.objects.all() + for source in sources: + if source.dact_id: + if len(source.dact_id) == 5 and source.dact_id.startswith("0"): + source.dact_id = f"D:{source.dact_id}" + elif len(source.dact_id) == 7 and source.dact_id.startswith("D-0"): + source.dact_id = f"D:{source.dact_id[2:]}" + else: + self.stdout.write( + self.style.WARNING( + f"{source.id} | DACT ID {source.dact_id} is not in the correct format." + ) + ) + if source.fragmentarium_id: + if len(source.fragmentarium_id) == 4: + source.fragmentarium_id = f"F-{source.fragmentarium_id}" + elif len( + source.fragmentarium_id + ) == 6 and source.fragmentarium_id.startswith("F-"): + pass + else: + self.stdout.write( + self.style.WARNING( + f"{source.id} | Fragment ID {source.fragmentarium_id} is not in the correct format." + ) + ) + source.save() diff --git a/django/cantusdb_project/main_app/management/commands/remap_user_ids.py b/django/cantusdb_project/main_app/management/commands/remap_user_ids.py index a0a734ffc..51002d048 100644 --- a/django/cantusdb_project/main_app/management/commands/remap_user_ids.py +++ b/django/cantusdb_project/main_app/management/commands/remap_user_ids.py @@ -1,9 +1,9 @@ -from main_app.models import Source, Chant from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from sys import stdout from django.db.models.query import QuerySet -from typing import Optional +import reversion # type: ignore[import-untyped] + +from main_app.models import Source, Chant User = get_user_model() @@ -49,89 +49,66 @@ } -def reassign_sources() -> None: - CHUNK_SIZE = 1_000 - sources: QuerySet[Source] = Source.objects.all() - sources_count: int = sources.count() - start_index: int = 0 - while start_index <= sources_count: - stdout.write(f"processing chunk with {start_index=}\n") - chunk: QuerySet[Source] = sources[start_index : start_index + CHUNK_SIZE] - for source in chunk: - old_creator: Optional[User] = source.created_by - - updated_id: Optional[int] = None - try: - updated_id: int = USER_ID_MAPPING[old_creator.id] - except ( - KeyError, # old_creator.id not in USER_ID_MAPPING - AttributeError, # old_creator is None - ): - pass +class Command(BaseCommand): + def reassign_sources(self) -> None: + sources: QuerySet[Source] = Source.objects.filter( + created_by__in=USER_ID_MAPPING.keys() + ) + num_sources = sources.count() + self.stdout.write( + self.style.NOTICE(f"Reassigning {num_sources} sources to new users.") + ) + source_counter = 0 + for source in sources.iterator(chunk_size=1_000): + old_creator = source.created_by - if updated_id is None: - # user ID doesn't need to be remapped - continue + # We know the old_creator is in USER_ID_MAPPING.keys() because of the filter + # on the queryset. + updated_id = USER_ID_MAPPING[old_creator.id] # type: ignore[union-attr] - updated_creator: Optional[User] = None - try: - updated_creator = User.objects.get(id=updated_id) - except ( - User.DoesNotExist, - AttributeError, - ): - pass + updated_creator = User.objects.get(id=updated_id) source.created_by = updated_creator source.save() - start_index += CHUNK_SIZE - - -def reassign_chants() -> None: - CHUNK_SIZE = 1_000 - chants: QuerySet[Chant] = Chant.objects.all() - chants_count: int = chants.count() - start_index: int = 0 - while start_index <= chants_count: - stdout.write(f"processing chunk with {start_index=}\n") - chunk: QuerySet[Chant] = chants[start_index : start_index + CHUNK_SIZE] - for chant in chunk: - old_creator: Optional[User] = chant.created_by - - updated_id: Optional[int] = None - try: - updated_id: int = USER_ID_MAPPING[old_creator.id] - except ( - KeyError, # old_creator.id not in USER_ID_MAPPING - AttributeError, # old_creator is None - ): - pass - - if updated_id is None: - # user ID doesn't need to be remapped - continue - - updated_creator: Optional[User] = None - try: - updated_creator = User.objects.get(id=updated_id) - except User.DoesNotExist: - pass - + source_counter += 1 + if source_counter % 100 == 0: + self.stdout.write( + self.style.NOTICE(f"Reassigned {source_counter} sources.") + ) + + def reassign_chants(self) -> None: + chants: QuerySet[Chant] = Chant.objects.filter( + created_by__in=USER_ID_MAPPING.keys() + ) + num_chants = chants.count() + self.stdout.write( + self.style.NOTICE(f"Reassigning {num_chants} sources to new users.") + ) + chant_counter = 0 + for chant in chants.iterator(chunk_size=1_000): + old_creator = chant.created_by + # We know the old_creator is in USER_ID_MAPPING.keys() because of the filter + # on the queryset. + updated_id: int = USER_ID_MAPPING[old_creator.id] # type: ignore[union-attr] + updated_creator = User.objects.get(id=updated_id) chant.created_by = updated_creator chant.save() - start_index += CHUNK_SIZE - + chant_counter += 1 + if chant_counter % 100 == 0: + self.stdout.write( + self.style.NOTICE(f"Reassigned {chant_counter} chants.") + ) -class Command(BaseCommand): def handle(self, *args, **kwargs) -> None: - error_message = ( - "As of late November 2023, this command is not working. " - "It has been temporarily disabled until the bugs have been worked out." - ) - raise NotImplementedError(error_message) - stdout.write("\n\n==== Reassigning Sources ====\n") - reassign_sources() - stdout.write("\n== All sources successfully remapped! ==\n") - stdout.write("\n\n==== Reassigning Chants ====\n") - reassign_chants() - stdout.write("\n== All chants successfully remapped! ==\n") + with reversion.create_revision(): + self.stdout.write(self.style.NOTICE("==== Reassigning Sources ====")) + self.reassign_sources() + self.stdout.write( + self.style.SUCCESS("== All sources successfully remapped! ==") + ) + self.stdout.write(self.style.NOTICE("==== Reassigning Chants ====")) + self.reassign_chants() + self.stdout.write( + self.style.SUCCESS("== All chants successfully remapped! ==") + ) + reversion.set_comment("Command: remap user IDs") diff --git a/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py b/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py index 7c0fb2ebc..820e02d2a 100644 --- a/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py +++ b/django/cantusdb_project/main_app/management/commands/update_cached_concordances.py @@ -112,7 +112,10 @@ def make_chant_dict(chant: dict) -> dict: "incipit": chant["incipit"], "feast": chant["feast__name"], "genre": chant["genre__name"], - "service": chant["service__name"], + "office": chant[ + "service__name" + ], # We keep the office key for backwards compatibility + # with external applications (e.g. Cantus Index) using this export "position": chant["position"], "cantus_id": chant["cantus_id"], "image": chant["image_link"], diff --git a/django/cantusdb_project/main_app/migrations/0031_alter_source_holding_institution_and_more.py b/django/cantusdb_project/main_app/migrations/0031_alter_source_holding_institution_and_more.py deleted file mode 100644 index e793e3991..000000000 --- a/django/cantusdb_project/main_app/migrations/0031_alter_source_holding_institution_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.2.14 on 2024-08-30 13:58 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("main_app", "0030_institution_is_private_collection"), - ] - - operations = [ - migrations.AlterField( - model_name="source", - name="holding_institution", - field=models.ForeignKey( - default=19, - on_delete=django.db.models.deletion.PROTECT, - to="main_app.institution", - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="source", - name="shelfmark", - field=models.CharField( - default="XX-NN", - max_length=255, - ), - ), - ] diff --git a/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py b/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py new file mode 100644 index 000000000..b0440b7f8 --- /dev/null +++ b/django/cantusdb_project/main_app/migrations/0031_source_name_source_production_method_and_more.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.14 on 2024-10-02 17:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("main_app", "0030_institution_is_private_collection"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="name", + field=models.CharField( + blank=True, + help_text="A colloquial or commonly-used name for the source", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="source", + name="production_method", + field=models.IntegerField( + choices=[(1, "Manuscript"), (2, "Printed")], + default=1, + verbose_name="Manuscript/Printed", + ), + ), + migrations.AddField( + model_name="source", + name="source_completeness", + field=models.IntegerField( + choices=[ + (1, "Full source"), + (2, "Fragment/Fragmented"), + (3, "Reconstruction"), + ], + default=1, + verbose_name="Full Source/Fragment", + ), + ), + migrations.AlterField( + model_name="institution", + name="country", + field=models.CharField(default="[No Country]", max_length=64), + ), + migrations.AlterField( + model_name="institution", + name="name", + field=models.CharField(default="[No Name]", max_length=255), + ), + migrations.AlterField( + model_name="source", + name="shelfmark", + field=models.CharField( + default="[No Shelfmark]", + help_text="Primary Cantus Database identifier for the source (e.g. library shelfmark, DACT ID, etc.)", + max_length=255, + ), + ), + migrations.CreateModel( + name="SourceIdentifier", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("identifier", models.CharField(max_length=255)), + ( + "type", + models.IntegerField( + choices=[ + (1, "Other catalogues"), + (2, "olim (Former shelfmark)"), + (3, "Alternative names"), + (4, "RISM Online"), + ] + ), + ), + ("note", models.TextField(blank=True, null=True)), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="identifiers", + to="main_app.source", + ), + ), + ], + options={ + "verbose_name": "Source Identifier", + "ordering": ("type",), + }, + ), + ] diff --git a/django/cantusdb_project/main_app/models/__init__.py b/django/cantusdb_project/main_app/models/__init__.py index ea1149eca..5ed2a4227 100644 --- a/django/cantusdb_project/main_app/models/__init__.py +++ b/django/cantusdb_project/main_app/models/__init__.py @@ -10,6 +10,7 @@ from main_app.models.segment import Segment from main_app.models.sequence import Sequence from main_app.models.source import Source +from main_app.models.source_identifier import SourceIdentifier from main_app.models.institution import Institution from main_app.models.institution_identifier import InstitutionIdentifier from main_app.models.project import Project diff --git a/django/cantusdb_project/main_app/models/institution.py b/django/cantusdb_project/main_app/models/institution.py index e38450d90..a5d966f1b 100644 --- a/django/cantusdb_project/main_app/models/institution.py +++ b/django/cantusdb_project/main_app/models/institution.py @@ -28,7 +28,7 @@ class Meta: ), ] - name = models.CharField(max_length=255, default="s.n.") + name = models.CharField(max_length=255, default="[No Name]") siglum = models.CharField( verbose_name="RISM Siglum", max_length=32, @@ -50,7 +50,7 @@ class Meta: region = models.CharField( max_length=64, blank=True, null=True, help_text=region_help_text ) - country = models.CharField(max_length=64, default="s.l.") + country = models.CharField(max_length=64, default="[No Country]") alternate_names = models.TextField( blank=True, null=True, help_text="Enter alternate names on separate lines." ) diff --git a/django/cantusdb_project/main_app/models/source.py b/django/cantusdb_project/main_app/models/source.py index 919fbac20..2887b95f6 100644 --- a/django/cantusdb_project/main_app/models/source.py +++ b/django/cantusdb_project/main_app/models/source.py @@ -42,13 +42,24 @@ class Source(BaseModel): holding_institution = models.ForeignKey( "Institution", on_delete=models.PROTECT, - null=False, - blank=False, + null=True, + blank=True, ) shelfmark = models.CharField( max_length=255, blank=False, null=False, + help_text=( + "Primary Cantus Database identifier for the source " + "(e.g. library shelfmark, DACT ID, etc.)" + ), + default="[No Shelfmark]", + ) + name = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="A colloquial or commonly-used name for the source", ) provenance = models.ForeignKey( "Provenance", @@ -65,6 +76,18 @@ class Source(BaseModel): null=True, help_text="More exact indication of the provenance (if necessary)", ) + + class SourceCompletenessChoices(models.IntegerChoices): + FULL_SOURCE = 1, "Full source" + FRAGMENT = 2, "Fragment/Fragmented" + RECONSTRUCTION = 3, "Reconstruction" + + source_completeness = models.IntegerField( + choices=SourceCompletenessChoices.choices, + default=SourceCompletenessChoices.FULL_SOURCE, + verbose_name="Full Source/Fragment", + ) + full_source = models.BooleanField(blank=True, null=True) date = models.CharField( blank=True, @@ -134,6 +157,16 @@ class Source(BaseModel): blank=False, null=False, default=False ) + class ProductionMethodChoices(models.IntegerChoices): + MANUSCRIPT = 1, "Manuscript" + PRINTED = 2, "Printed" + + production_method = models.IntegerField( + default=ProductionMethodChoices.MANUSCRIPT, + choices=ProductionMethodChoices.choices, + verbose_name="Manuscript/Printed", + ) + # number_of_chants and number_of_melodies are used for rendering the source-list page (perhaps among other places) # they are automatically recalculated in main_app.signals.update_source_chant_count and # main_app.signals.update_source_melody_count every time a chant or sequence is saved or deleted @@ -157,10 +190,16 @@ def heading(self) -> str: city = f"{holdinst.city}," if holdinst.city else "" title.append(city) title.append(f"{holdinst.name},") + else: + title.append("Cantus") + + title.append(self.shelfmark) - tt = self.shelfmark if self.shelfmark else self.title + if self.source_completeness == self.SourceCompletenessChoices.FRAGMENT: + title.append("(fragment)") - title.append(tt) + if self.name: + title.append(f'("{self.name}")') return " ".join(title) @@ -170,9 +209,14 @@ def short_heading(self) -> str: if holdinst := self.holding_institution: if holdinst.siglum and holdinst.siglum != "XX-NN": title.append(f"{holdinst.siglum}") - elif holdinst.is_private_collector: - title.append("Private") + else: + title.append("Cantus") + else: + title.append("Cantus") + + title.append(self.shelfmark) + + if self.source_completeness == self.SourceCompletenessChoices.FRAGMENT: + title.append("(fragment)") - tt = self.shelfmark if self.shelfmark else self.title - title.append(tt) return " ".join(title) diff --git a/django/cantusdb_project/main_app/models/source_identifier.py b/django/cantusdb_project/main_app/models/source_identifier.py new file mode 100644 index 000000000..85ccb5f71 --- /dev/null +++ b/django/cantusdb_project/main_app/models/source_identifier.py @@ -0,0 +1,33 @@ +from django.db import models + +class SourceIdentifier(models.Model): + class Meta: + verbose_name = "Source Identifier" + ordering = ('type',) + + OTHER = 1 + OLIM = 2 + ALTN = 3 + RISM_ONLINE = 4 + + IDENTIFIER_TYPES = ( + (OTHER, 'Other catalogues'), + (OLIM, 'olim (Former shelfmark)'), + (ALTN, 'Alternative names'), + (RISM_ONLINE, "RISM Online") + ) + + identifier = models.CharField(max_length=255) + type = models.IntegerField(choices=IDENTIFIER_TYPES) + note = models.TextField(blank=True, null=True) + source = models.ForeignKey("Source", + related_name="identifiers", + on_delete=models.CASCADE) + + def __str__(self): + return f"{self.identifier}" + + @property + def identifier_type(self): + d = dict(self.IDENTIFIER_TYPES) + return d[self.type] diff --git a/django/cantusdb_project/main_app/templates/browse_chants.html b/django/cantusdb_project/main_app/templates/browse_chants.html index 8b9245520..771aceac0 100644 --- a/django/cantusdb_project/main_app/templates/browse_chants.html +++ b/django/cantusdb_project/main_app/templates/browse_chants.html @@ -88,7 +88,8 @@
{{ chant.manuscript_full_text_std_spelling|default:"" }}
- {% if chant.volpiano %}
+
+ {% if chant.volpiano and chant.source.id != 680970 %}
{{ chant.volpiano|default:"" }}
{% endif %}
{{ form.manuscript_full_text_std_spelling.help_text }}
@@ -241,8 +241,7 @@
{{ form.manuscript_full_text.help_text }}
diff --git a/django/cantusdb_project/main_app/templates/chant_detail.html b/django/cantusdb_project/main_app/templates/chant_detail.html
index 022f48101..a46a18156 100644
--- a/django/cantusdb_project/main_app/templates/chant_detail.html
+++ b/django/cantusdb_project/main_app/templates/chant_detail.html
@@ -240,8 +240,9 @@ {{ chant.volpiano }} Syllabification is based on saved syllabized text. Error aligning text and melody. Please check text for invalid characters.
- {{ form.holding_institution.help_text }}
-
- {{ form.shelfmark.help_text }}
+
+ {{ form.shelfmark.help_text|safe }}
+
+ {{ form.name.help_text|safe }}
{{ form.full_source.help_text }}
- {{ form.holding_institution.help_text }}
+
+ {{ form.holding_institution.help_text }}
- {{ form.shelfmark.help_text }}
+
+ {{ form.shelfmark.help_text }}
+
+ {{ form.name.help_text|safe }}
Create Chant
{{ chant.incipit }}
{{ chant.incipit }}
{{ syl_text }}
{{ syl_text }}
Search Chants
{% for chant in chants %}
+
+ {% if chant.source.id != 680970 or melodies != "true" %}
{% if not source %}
+ {% endif %}
{% endfor %}
diff --git a/django/cantusdb_project/main_app/templates/chant_syllabification_edit.html b/django/cantusdb_project/main_app/templates/chant_syllabification_edit.html
index 446afd73a..65b49f09f 100644
--- a/django/cantusdb_project/main_app/templates/chant_syllabification_edit.html
+++ b/django/cantusdb_project/main_app/templates/chant_syllabification_edit.html
@@ -12,6 +12,7 @@
{% for message in messages %}
{% endfor %}
@@ -37,8 +38,8 @@
@@ -297,7 +299,8 @@
Search Chants
{% endif %}
- {% if chant.volpiano %}
+
+ {% if chant.volpiano and chant.source.id != 680970 %}
♫
{% endif %}
@@ -307,6 +310,7 @@ Search Chants
{% endif %}
Edit Syllabification
Edit Syllabification
Create Source
Create Source
Shelfmark:*
{{ form.shelfmark }}
- Create Source
{{ source.heading }}
{% endif %}
+
{{ source.heading }}
Shelfmark:*
{{ form.shelfmark }}
-
More exact indication of the provenance (if necessary)
Browse Sources
{% sortable_header request "country" %}
- {% sortable_header request "city_institution" "City + Institution" %}
- {% sortable_header request "source" "Source" %}
+ {% sortable_header request "city_institution" "City + Holding Institution" %}
+ {% sortable_header request "source" "Cantus Siglum" %}
Summary
Date/Origin
Image Link
@@ -103,9 +103,16 @@ Browse Sources
{{ source.holding_institution.country }}
-
- {{ source.holding_institution.city }}, {{ source.holding_institution.name }}
-
+ {% if source.holding_institution %}
+
+
+ {% if source.holding_institution.city %}
+ {{ source.holding_institution.city }},
+ {% endif %}
+ {{ source.holding_institution.name }}
+
+
+ {% endif %}
@@ -113,6 +120,9 @@
Browse Sources
+ {% if source.name %}
+ "{{ source.name }}"
+ {% endif %}
{{ source.summary|default:""|truncatechars_html:120 }}
@@ -130,7 +140,8 @@
Browse Sources
{{ source.number_of_chants|default_if_none:"0" }}
- {% if source.number_of_melodies %}
+
+ {% if source.number_of_melodies and source.id != 680970 %}
diff --git a/django/cantusdb_project/main_app/tests/make_fakes.py b/django/cantusdb_project/main_app/tests/make_fakes.py
index a2febbdbd..5991df946 100644
--- a/django/cantusdb_project/main_app/tests/make_fakes.py
+++ b/django/cantusdb_project/main_app/tests/make_fakes.py
@@ -362,15 +362,33 @@ def make_fake_institution(
city: Optional[str] = None,
region: Optional[str] = None,
country: Optional[str] = None,
+ is_private_collector: Optional[bool] = None,
) -> Institution:
+ """
+ Note that the siglum and is_private_collector fields
+ are mutually exclusive. If both are specified, an exception
+ will be raised. If neither are specified, the function will
+ randomly determine whether the institution is a private collector or
+ will be given a fake siglum.
+ """
name = name if name else faker.sentence()
- siglum = siglum if siglum else faker.sentence(nb_words=1)
city = city if city else faker.city()
region = region if region else faker.country()
country = country if country else faker.country()
+ if siglum and is_private_collector:
+ raise ValueError("Siglum and Private Collector cannot both be specified.")
+ is_private_collector = False if siglum else faker.boolean(chance_of_getting_true=20)
+ if not is_private_collector and not siglum:
+ siglum = faker.sentence(nb_words=1)
+
inst = Institution.objects.create(
- name=name, siglum=siglum, city=city, region=region, country=country
+ name=name,
+ siglum=siglum,
+ city=city,
+ region=region,
+ country=country,
+ is_private_collector=is_private_collector,
)
inst.save()
@@ -468,4 +486,4 @@ def get_random_search_term(target):
slice_start = random.randint(0, len(target) - 2)
slice_end = random.randint(slice_start + 2, len(target))
search_term = target[slice_start:slice_end]
- return search_term
\ No newline at end of file
+ return search_term
diff --git a/django/cantusdb_project/main_app/tests/test_functions.py b/django/cantusdb_project/main_app/tests/test_functions.py
index a250d6cd6..0f89fb8d2 100644
--- a/django/cantusdb_project/main_app/tests/test_functions.py
+++ b/django/cantusdb_project/main_app/tests/test_functions.py
@@ -208,7 +208,7 @@ def test_concordances_structure(self):
"incipit",
"feast",
"genre",
- "service",
+ "office",
"position",
"cantus_id",
"image",
@@ -259,7 +259,7 @@ def test_concordances_values(self):
("incipit", chant.incipit),
("feast", chant.feast.name),
("genre", chant.genre.name),
- ("service", chant.service.name),
+ ("office", chant.service.name),
("position", chant.position),
("cantus_id", chant.cantus_id),
("image", chant.image_link),
diff --git a/django/cantusdb_project/main_app/tests/test_populate_source_completeness.py b/django/cantusdb_project/main_app/tests/test_populate_source_completeness.py
new file mode 100644
index 000000000..065bbb2ea
--- /dev/null
+++ b/django/cantusdb_project/main_app/tests/test_populate_source_completeness.py
@@ -0,0 +1,29 @@
+from django.test import TestCase
+from django.core.management import call_command
+
+from main_app.models import Source
+from main_app.tests.make_fakes import make_fake_source
+
+
+class TestPopulateSourceCompleteness(TestCase):
+ def test_populate_source_completeness(self):
+ # make a few "Full Source" sources
+ for _ in range(5):
+ make_fake_source(full_source=True)
+ # make a few "Fragment" sources
+ for _ in range(3):
+ make_fake_source(full_source=False)
+ # run the command
+ call_command("populate_source_completeness")
+ sources = Source.objects.all()
+ for source in sources:
+ if source.full_source:
+ self.assertEqual(
+ source.source_completeness,
+ source.SourceCompletenessChoices.FULL_SOURCE,
+ )
+ else:
+ self.assertEqual(
+ source.source_completeness,
+ source.SourceCompletenessChoices.FRAGMENT,
+ )
diff --git a/django/cantusdb_project/main_app/tests/test_reformat_source_ids.py b/django/cantusdb_project/main_app/tests/test_reformat_source_ids.py
new file mode 100644
index 000000000..7148d786e
--- /dev/null
+++ b/django/cantusdb_project/main_app/tests/test_reformat_source_ids.py
@@ -0,0 +1,56 @@
+from django.test import TestCase
+from django.core.management import call_command
+
+from main_app.models import Source
+from main_app.tests.make_fakes import make_fake_institution, make_fake_segment
+
+
+class TestReformatSourceIDs(TestCase):
+ def test_command(self):
+ segment = make_fake_segment()
+ fake_inst = make_fake_institution()
+ correct_source_1 = Source.objects.create(
+ segment=segment,
+ shelfmark="Correct Source 1",
+ holding_institution=fake_inst,
+ dact_id="0a1b3",
+ fragmentarium_id="a1b3",
+ )
+ correct_source_2 = Source.objects.create(
+ segment=segment,
+ shelfmark="Correct Source 2",
+ holding_institution=fake_inst,
+ dact_id="D-0a1b3",
+ fragmentarium_id="F-a1b3",
+ )
+ source_with_no_ids = Source.objects.create(
+ segment=segment,
+ shelfmark="Source with no IDs",
+ holding_institution=fake_inst,
+ )
+ source_with_incorrect_ids = Source.objects.create(
+ segment=segment,
+ shelfmark="Source with incorrect IDs",
+ holding_institution=fake_inst,
+ dact_id="a1b3",
+ fragmentarium_id="F-1b3",
+ )
+
+ call_command("reformat_source_ids")
+ self.assertEqual(Source.objects.get(pk=correct_source_1.pk).dact_id, "D:0a1b3")
+ self.assertEqual(
+ Source.objects.get(pk=correct_source_1.pk).fragmentarium_id, "F-a1b3"
+ )
+ self.assertEqual(Source.objects.get(pk=correct_source_2.pk).dact_id, "D:0a1b3")
+ self.assertEqual(
+ Source.objects.get(pk=correct_source_2.pk).fragmentarium_id, "F-a1b3"
+ )
+ self.assertIsNone(Source.objects.get(pk=source_with_no_ids.pk).dact_id)
+ self.assertIsNone(Source.objects.get(pk=source_with_no_ids.pk).fragmentarium_id)
+ self.assertEqual(
+ Source.objects.get(pk=source_with_incorrect_ids.pk).dact_id, "a1b3"
+ )
+ self.assertEqual(
+ Source.objects.get(pk=source_with_incorrect_ids.pk).fragmentarium_id,
+ "F-1b3",
+ )
diff --git a/django/cantusdb_project/main_app/tests/test_views/test_api.py b/django/cantusdb_project/main_app/tests/test_views/test_api.py
index 8991f3f44..05985e8f8 100644
--- a/django/cantusdb_project/main_app/tests/test_views/test_api.py
+++ b/django/cantusdb_project/main_app/tests/test_views/test_api.py
@@ -270,8 +270,7 @@ def test_json_melody_fields(self):
"mid",
"nid",
"cid",
- "holding_institution",
- "shelfmark",
+ "siglum",
"srcnid",
"folio",
"incipit",
@@ -279,7 +278,7 @@ def test_json_melody_fields(self):
"volpiano",
"mode",
"feast",
- "service",
+ "office",
"genre",
"position",
"chantlink",
@@ -765,7 +764,7 @@ def test_structure(self):
"incipit": "some string"
"feast": "some string"
"genre": "some string"
- "service": "some string"
+ "office": "some string"
"position": "some string"
"cantus_id": "some string"
"image": "some string"
@@ -811,7 +810,7 @@ def test_structure(self):
"incipit",
"feast",
"genre",
- "service",
+ "office",
"position",
"cantus_id",
"image",
@@ -834,7 +833,7 @@ def test_values(self):
"incipit": chant.incipit,
"feast": chant.feast.name,
"genre": chant.genre.name,
- "service": chant.service.name,
+ "office": chant.service.name,
"position": chant.position,
"mode": chant.mode,
"image": chant.image_link,
diff --git a/django/cantusdb_project/main_app/tests/test_views/test_chant.py b/django/cantusdb_project/main_app/tests/test_views/test_chant.py
index 706cfaba0..d91ef212c 100644
--- a/django/cantusdb_project/main_app/tests/test_views/test_chant.py
+++ b/django/cantusdb_project/main_app/tests/test_views/test_chant.py
@@ -4,6 +4,7 @@
from unittest.mock import patch
import random
+from typing import ClassVar
from django.test import TestCase, Client
from django.urls import reverse
@@ -112,12 +113,11 @@ def test_chant_edit_link(self):
)
# have to create project manager user - "View | Edit" toggle only visible for those with edit access for a chant's source
- self.user = get_user_model().objects.create(email="test@test.com")
- self.user.set_password("pass")
- self.user.save()
- self.client = Client()
+ pm_user = get_user_model().objects.create(email="test@test.com")
+ pm_user.set_password("pass")
+ pm_user.save()
project_manager = Group.objects.get(name="project manager")
- project_manager.user_set.add(self.user)
+ project_manager.user_set.add(pm_user)
self.client.login(email="test@test.com", password="pass")
response = self.client.get(reverse("chant-detail", args=[chant.id]))
@@ -164,7 +164,6 @@ def setUp(self):
self.user = get_user_model().objects.create(email="test@test.com")
self.user.set_password("pass")
self.user.save()
- self.client = Client()
project_manager = Group.objects.get(name="project manager")
project_manager.user_set.add(self.user)
self.client.login(email="test@test.com", password="pass")
@@ -323,6 +322,44 @@ def test_proofread_chant(self):
chant.refresh_from_db()
self.assertIs(chant.manuscript_full_text_std_proofread, True)
+ def test_invalid_text(self) -> None:
+ """
+ The user should not be able to create a chant with invalid text
+ (either invalid characters or unmatched brackets).
+ Instead, the user should be shown an error message.
+ """
+ source = make_fake_source()
+ with self.subTest("Chant with invalid characters"):
+ response = self.client.post(
+ reverse("source-edit-chants", args=[source.id]),
+ {
+ "manuscript_full_text_std_spelling": "this is a ch@nt t%xt with inv&lid ch!ra+ers",
+ "folio": "001r",
+ "c_sequence": "1",
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFormError(
+ response.context["form"],
+ "manuscript_full_text_std_spelling",
+ "Invalid characters in text.",
+ )
+ with self.subTest("Chant with unmatched brackets"):
+ response = self.client.post(
+ reverse("source-edit-chants", args=[source.id]),
+ {
+ "manuscript_full_text_std_spelling": "this is a chant with [ unmatched brackets",
+ "folio": "001r",
+ "c_sequence": "1",
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFormError(
+ response.context["form"],
+ "manuscript_full_text_std_spelling",
+ "Word [ contains non-alphabetic characters.",
+ )
+
class ChantEditSyllabificationViewTest(TestCase):
@classmethod
@@ -363,7 +400,10 @@ def test_edit_syllabification(self):
self.assertEqual(chant.manuscript_syllabized_full_text, "lorem ipsum")
response = self.client.post(
f"/edit-syllabification/{chant.id}",
- {"manuscript_syllabized_full_text": "lore-m i-psum"},
+ {
+ "manuscript_full_text": "lorem ipsum",
+ "manuscript_syllabized_full_text": "lore-m i-psum",
+ },
)
self.assertEqual(response.status_code, 302) # 302 Found
chant.refresh_from_db()
@@ -501,7 +541,8 @@ def test_filter_by_melody(self):
source=source,
volpiano=make_fake_volpiano(),
)
- chant_without_melody = Chant.objects.create(source=source)
+ # Create a chant without a melody
+ Chant.objects.create(source=source)
response = self.client.get(reverse("chant-search"), {"melodies": "true"})
# only chants with melodies should be in the result
self.assertEqual(len(response.context["chants"]), 1)
@@ -553,9 +594,11 @@ def test_search_bar_search(self):
manuscript_full_text="Full text contains, but does not start with 'the'",
cantus_id="123456",
)
- chant_starting_with_a_number = make_fake_chant(
+ # Create a chant starting with a number that won't be found by either
+ # search term
+ make_fake_chant(
manuscript_full_text=(
- "1 is a number. " "How unusual, to find an arabic numeral in a chant!"
+ "1 is a number. How unusual, to find an arabic numeral in a chant!"
),
cantus_id="234567",
)
@@ -1370,7 +1413,7 @@ def test_column_header_links(self):
# additional properties for which there are search fields
feast = make_fake_feast()
position = make_random_string(1)
- chant = make_fake_chant(
+ make_fake_chant(
manuscript_full_text_std_spelling=fulltext,
service=service,
genre=genre,
@@ -1527,7 +1570,7 @@ def test_feast_column(self):
url = feast.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
feast=feast,
@@ -1551,7 +1594,7 @@ def test_service_column(self):
url = service.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
service=service,
@@ -1575,7 +1618,7 @@ def test_genre_column(self):
url = genre.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
genre=genre,
@@ -1818,7 +1861,8 @@ def test_filter_by_melody(self):
source=source,
volpiano=make_fake_volpiano,
)
- chant_without_melody = Chant.objects.create(source=source)
+ # Create a chant without melody that won't be in the result
+ Chant.objects.create(source=source)
response = self.client.get(
reverse("chant-search-ms", args=[source.id]), {"melodies": "true"}
)
@@ -1836,11 +1880,11 @@ def test_keyword_search_starts_with(self):
source=source,
manuscript_full_text_std_spelling="quick brown fox jumps over the lazy dog",
)
- chant_2 = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling="brown fox jumps over the lazy dog",
)
- chant_3 = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling="lazy brown fox jumps quick over the dog",
)
@@ -1859,7 +1903,8 @@ def test_keyword_search_contains(self):
source=source,
manuscript_full_text_std_spelling="Quick brown fox jumps over the lazy dog",
)
- chant_2 = make_fake_chant(
+ # Make a chant that won't be returned by the search term
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling="brown fox jumps over the lazy dog",
)
@@ -1885,11 +1930,11 @@ def test_indexing_notes_search_starts_with(self):
source=source,
indexing_notes="quick brown fox jumps over the lazy dog",
)
- chant_2 = make_fake_chant(
+ make_fake_chant(
source=source,
indexing_notes="brown fox jumps over the lazy dog",
)
- chant_3 = make_fake_chant(
+ make_fake_chant(
source=source,
indexing_notes="lazy brown fox jumps quick over the dog",
)
@@ -1908,7 +1953,8 @@ def test_indexing_notes_search_contains(self):
source=source,
indexing_notes="Quick brown fox jumps over the lazy dog",
)
- chant_2 = make_fake_chant(
+ # Make a chant that won't be returned by the search term
+ make_fake_chant(
source=source,
indexing_notes="brown fox jumps over the lazy dog",
)
@@ -1931,13 +1977,13 @@ def test_keyword_search_searching_all_fields(self):
doesnt_include_search_term = "longevity is the soul of wit"
source = make_fake_source()
- chant_ms_spelling = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text=includes_search_term, # <== includes_search_term
manuscript_full_text_std_spelling=doesnt_include_search_term,
)
- chant_std_spelling = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text=doesnt_include_search_term,
manuscript_full_text_std_spelling=includes_search_term, # <==
@@ -1954,7 +2000,8 @@ def test_keyword_search_searching_all_fields(self):
manuscript_full_text_std_spelling=None,
)
- chant_without_search_term = make_fake_chant(
+ # This chant contains no search terms
+ make_fake_chant(
source=source,
manuscript_full_text=doesnt_include_search_term,
manuscript_full_text_std_spelling=doesnt_include_search_term,
@@ -2343,7 +2390,7 @@ def test_column_header_links(self):
# additional properties for which there are search fields
feast = make_fake_feast()
position = make_random_string(1)
- chant = make_fake_chant(
+ make_fake_chant(
service=service,
genre=genre,
cantus_id=cantus_id,
@@ -2449,9 +2496,7 @@ def test_source_link_column(self):
url = source.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
- source=source, manuscript_full_text_std_spelling=fulltext
- )
+ make_fake_chant(source=source, manuscript_full_text_std_spelling=fulltext)
response = self.client.get(
reverse("chant-search-ms", args=[source.id]),
{"keyword": search_term, "op": "contains"},
@@ -2487,7 +2532,7 @@ def test_feast_column(self):
url = feast.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
feast=feast,
@@ -2512,7 +2557,7 @@ def test_service_column(self):
url = service.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
service=service,
@@ -2537,7 +2582,7 @@ def test_genre_column(self):
url = genre.get_absolute_url()
fulltext = "manuscript full text"
search_term = "full"
- chant = make_fake_chant(
+ make_fake_chant(
source=source,
manuscript_full_text_std_spelling=fulltext,
genre=genre,
@@ -2699,37 +2744,62 @@ def test_image_link_column(self):
self.assertIn(f'Image', html)
+@patch("requests.get", mock_requests_get)
class ChantCreateViewTest(TestCase):
+ source: ClassVar[Source]
+
@classmethod
def setUpTestData(cls):
- Group.objects.create(name="project manager")
+ # Create project manager and contributor users
+ prod_manager_group = Group.objects.create(name="project manager")
+ contributor_group = Group.objects.create(name="contributor")
+ user_model = get_user_model()
+ pm_usr = user_model.objects.create_user(email="pm@test.com", password="pass")
+ pm_usr.groups.set([prod_manager_group])
+ pm_usr.save()
+ contributor_usr = user_model.objects.create_user(
+ email="contrib@test.com", password="pass"
+ )
+ contributor_usr.groups.set([contributor_group])
+ contributor_usr.save()
+ # Create a fake source that the contributor user can edit
+ cls.source = make_fake_source()
+ cls.source.current_editors.add(contributor_usr)
+ cls.source.save()
def setUp(self):
- self.user = get_user_model().objects.create(email="test@test.com")
- self.user.set_password("pass")
- self.user.save()
- self.client = Client()
- project_manager = Group.objects.get(name="project manager")
- project_manager.user_set.add(self.user)
- self.client.login(email="test@test.com", password="pass")
-
- def test_url_and_templates(self):
- source = make_fake_source()
+ # Log in as a contributor before each test
+ self.client.login(email="contrib@test.com", password="pass")
+
+ def test_permissions(self) -> None:
+ # The client starts logged in as a contributor
+ # with access to the source. Test that the client
+ # can access the ChantCreate view.
+ with self.subTest("Contributor can access ChantCreate view"):
+ response = self.client.get(reverse("chant-create", args=[self.source.id]))
+ self.assertEqual(response.status_code, 200)
+ with self.subTest("Project manager can access ChantCreate view"):
+ # Log in as a project manager
+ self.client.logout()
+ self.client.login(email="pm@test.com", password="pass")
+ response = self.client.get(reverse("chant-create", args=[self.source.id]))
+ self.assertEqual(response.status_code, 200)
+ with self.subTest("Unauthenticated user cannot access ChantCreate view"):
+ # Log out
+ self.client.logout()
+ response = self.client.get(reverse("chant-create", args=[self.source.id]))
+ self.assertEqual(response.status_code, 302)
- with patch("requests.get", mock_requests_get):
- response_1 = self.client.get(reverse("chant-create", args=[source.id]))
- response_2 = self.client.get(
- reverse("chant-create", args=[source.id + 100])
- )
+ def test_url_and_templates(self) -> None:
+ source = self.source
+ response_1 = self.client.get(reverse("chant-create", args=[source.id]))
self.assertEqual(response_1.status_code, 200)
self.assertTemplateUsed(response_1, "chant_create.html")
+ self.assertTemplateUsed(response_1, "base.html")
- self.assertEqual(response_2.status_code, 404)
- self.assertTemplateUsed(response_2, "404.html")
-
- def test_create_chant(self):
- source = make_fake_source()
+ def test_create_chant(self) -> None:
+ source = self.source
response = self.client.post(
reverse("chant-create", args=[source.id]),
{
@@ -2740,40 +2810,37 @@ def test_create_chant(self):
)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("chant-create", args=[source.id]))
- chant = Chant.objects.first()
+ chant = Chant.objects.get(source=source)
self.assertEqual(chant.manuscript_full_text_std_spelling, "initial")
- def test_view_url_path(self):
- source = make_fake_source()
- with patch("requests.get", mock_requests_get):
- response = self.client.get(f"/chant-create/{source.id}")
+ def test_view_url_path(self) -> None:
+ source = self.source
+ response = self.client.get(f"/chant-create/{source.id}")
self.assertEqual(response.status_code, 200)
- def test_context(self):
- """some context variable passed to templates"""
- source = make_fake_source()
+ def test_context(self) -> None:
+ """Test that correct source is in context"""
+ source = self.source
url = reverse("chant-create", args=[source.id])
- with patch("requests.get", mock_requests_get):
- response = self.client.get(url)
- self.assertEqual(response.context["source"].title, source.title)
+ response = self.client.get(url)
+ self.assertEqual(response.context["source"].id, source.id)
- def test_post_error(self):
+ def test_empty_fulltext(self) -> None:
"""post with correct source and empty full-text"""
- source = make_fake_source()
+ source = self.source
url = reverse("chant-create", args=[source.id])
response = self.client.post(url, data={"manuscript_full_text_std_spelling": ""})
self.assertFormError(
- response,
- "form",
+ response.context["form"],
"manuscript_full_text_std_spelling",
"This field is required.",
)
- def test_nonexistent_source(self):
+ def test_nonexistent_source(self) -> None:
"""
users should not be able to access Chant Create page for a source that doesn't exist
"""
- nonexistent_source_id: str = faker.numerify(
+ nonexistent_source_id = faker.numerify(
"#####"
) # there's not supposed to be 5-digits source id
with patch("requests.get", mock_requests_get):
@@ -2782,55 +2849,38 @@ def test_nonexistent_source(self):
)
self.assertEqual(response.status_code, 404)
- def test_repeated_seq(self):
+ def test_repeated_seq(self) -> None:
"""post with a folio and seq that already exists in the source"""
- TEST_FOLIO = "001r"
+ test_folio = "001r"
# create some chants in the test source
- source = make_fake_source()
+ source = self.source
for i in range(1, 5):
Chant.objects.create(
source=source,
- manuscript_full_text=faker.text(10),
- folio=TEST_FOLIO,
+ manuscript_full_text=" ".join(faker.words(faker.random_int(3, 10))),
+ folio=test_folio,
c_sequence=i,
)
# post a chant with the same folio and seq
url = reverse("chant-create", args=[source.id])
- fake_text = faker.text(10)
+ fake_text = "this is also a fake but valid text"
response = self.client.post(
url,
data={
"manuscript_full_text_std_spelling": fake_text,
- "folio": TEST_FOLIO,
+ "folio": test_folio,
"c_sequence": random.randint(1, 4),
},
follow=True,
)
self.assertFormError(
- response,
- "form",
+ response.context["form"],
None,
errors="Chant with the same sequence and folio already exists in this source.",
)
- def test_view_url_reverse_name(self):
- fake_sources = [make_fake_source() for _ in range(10)]
- for source in fake_sources:
- with patch("requests.get", mock_requests_get):
- response = self.client.get(reverse("chant-create", args=[source.id]))
- self.assertEqual(response.status_code, 200)
-
- def test_template_used(self):
- fake_sources = [make_fake_source() for _ in range(10)]
- for source in fake_sources:
- with patch("requests.get", mock_requests_get):
- response = self.client.get(reverse("chant-create", args=[source.id]))
- self.assertEqual(response.status_code, 200)
- self.assertTemplateUsed(response, "base.html")
- self.assertTemplateUsed(response, "chant_create.html")
-
- def test_volpiano_signal(self):
- source = make_fake_source()
+ def test_volpiano_signal(self) -> None:
+ source = self.source
self.client.post(
reverse("chant-create", args=[source.id]),
{
@@ -2844,10 +2894,9 @@ def test_volpiano_signal(self):
# clefs, accidentals, etc., to be deleted
},
)
- with patch("requests.get", mock_requests_get):
- chant_1 = Chant.objects.get(
- manuscript_full_text_std_spelling="ut queant lactose"
- )
+ chant_1 = Chant.objects.get(
+ manuscript_full_text_std_spelling="ut queant lactose"
+ )
self.assertEqual(chant_1.volpiano, "9abcdefg)A-B1C2D3E4F5G67?. yiz")
self.assertEqual(chant_1.volpiano_notes, "9abcdefg9abcdefg")
self.client.post(
@@ -2859,16 +2908,13 @@ def test_volpiano_signal(self):
"volpiano": "abacadaeafagahaja",
},
)
- with patch("requests.get", mock_requests_get):
- chant_2 = Chant.objects.get(
- manuscript_full_text_std_spelling="resonare foobaz"
- )
+ chant_2 = Chant.objects.get(manuscript_full_text_std_spelling="resonare foobaz")
self.assertEqual(chant_2.volpiano, "abacadaeafagahaja")
self.assertEqual(chant_2.volpiano_intervals, "1-12-23-34-45-56-67-78-8")
- def test_initial_values(self):
+ def test_initial_values(self) -> None:
# create a chant with a known folio, feast, service, c_sequence and image_link
- source: Source = make_fake_source()
+ source: Source = self.source
folio: str = "001r"
sequence: int = 1
feast: Feast = make_fake_feast()
@@ -2885,12 +2931,11 @@ def test_initial_values(self):
"image_link": image_link,
},
)
- with patch("requests.get", mock_requests_get):
- # when we request the Chant Create page, the same folio, feast, service and image_link should
- # be preselected, and c_sequence should be incremented by 1.
- response = self.client.get(
- reverse("chant-create", args=[source.id]),
- )
+ # when we request the Chant Create page, the same folio, feast, service and image_link should
+ # be preselected, and c_sequence should be incremented by 1.
+ response = self.client.get(
+ reverse("chant-create", args=[source.id]),
+ )
observed_initial_folio: int = response.context["form"].initial["folio"]
with self.subTest(subtest="test initial value of folio field"):
@@ -2912,12 +2957,11 @@ def test_initial_values(self):
with self.subTest(subtest="test initial value of image_link field"):
self.assertEqual(observed_initial_image, image_link)
- def test_suggested_chant_buttons(self):
- source: Source = make_fake_source()
- with patch("requests.get", mock_requests_get):
- response_empty_source = self.client.get(
- reverse("chant-create", args=[source.id]),
- )
+ def test_suggested_chant_buttons(self) -> None:
+ source: Source = self.source
+ response_empty_source = self.client.get(
+ reverse("chant-create", args=[source.id]),
+ )
with self.subTest(
test="Ensure no suggestions displayed when there is no previous chant"
):
@@ -2925,11 +2969,11 @@ def test_suggested_chant_buttons(self):
response_empty_source, "Suggestions based on previous chant:"
)
- previous_chant: Chant = make_fake_chant(cantus_id="001010", source=source)
- with patch("requests.get", mock_requests_get):
- response_after_previous_chant = self.client.get(
- reverse("chant-create", args=[source.id]),
- )
+ # Make a chant to serve as the previous chant
+ make_fake_chant(cantus_id="001010", source=source)
+ response_after_previous_chant = self.client.get(
+ reverse("chant-create", args=[source.id]),
+ )
suggested_chants = response_after_previous_chant.context["suggested_chants"]
with self.subTest(
test="Ensure suggested chant suggestions present when previous chant exists"
@@ -2940,11 +2984,11 @@ def test_suggested_chant_buttons(self):
self.assertIsNotNone(suggested_chants)
self.assertEqual(len(suggested_chants), 5)
- rare_chant: Chant = make_fake_chant(cantus_id="a07763", source=source)
- with patch("requests.get", mock_requests_get):
- response_after_rare_chant = self.client.get(
- reverse("chant-create", args=[source.id]),
- )
+ # Make a chant with a rare cantus_id to serve as the previous chant
+ make_fake_chant(cantus_id="a07763", source=source)
+ response_after_rare_chant = self.client.get(
+ reverse("chant-create", args=[source.id]),
+ )
with self.subTest(
test="When previous chant has no suggested chants, ensure no suggestions are displayed"
):
@@ -2956,6 +3000,45 @@ def test_suggested_chant_buttons(self):
)
self.assertIsNone(response_after_rare_chant.context["suggested_chants"])
+ def test_invalid_text(self) -> None:
+ """
+ The user should not be able to create a chant with invalid text
+ (either invalid characters or unmatched brackets).
+ Instead, the user should be shown an error message.
+ """
+ with self.subTest("Chant with invalid characters"):
+ source = self.source
+ response = self.client.post(
+ reverse("chant-create", args=[source.id]),
+ {
+ "manuscript_full_text_std_spelling": "this is a ch@nt t%xt with inv&lid ch!ra+ers",
+ "folio": "001r",
+ "c_sequence": "1",
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFormError(
+ response.context["form"],
+ "manuscript_full_text_std_spelling",
+ "Invalid characters in text.",
+ )
+ with self.subTest("Chant with unmatched brackets"):
+ source = self.source
+ response = self.client.post(
+ reverse("chant-create", args=[source.id]),
+ {
+ "manuscript_full_text_std_spelling": "this is a chant with [ unmatched brackets",
+ "folio": "001r",
+ "c_sequence": "1",
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertFormError(
+ response.context["form"],
+ "manuscript_full_text_std_spelling",
+ "Word [ contains non-alphabetic characters.",
+ )
+
class CISearchViewTest(TestCase):
diff --git a/django/cantusdb_project/main_app/tests/test_views/test_source.py b/django/cantusdb_project/main_app/tests/test_views/test_source.py
index e7102d4ec..8c9e529a5 100644
--- a/django/cantusdb_project/main_app/tests/test_views/test_source.py
+++ b/django/cantusdb_project/main_app/tests/test_views/test_source.py
@@ -54,15 +54,14 @@ def test_url_and_templates(self):
self.assertTemplateUsed(response, "source_create.html")
def test_create_source(self):
- hinst = make_fake_institution(siglum="FA-Ke")
response = self.client.post(
reverse("source-create"),
{
"shelfmark": "test-shelfmark", # shelfmark is a required field
- "holding_institution": hinst.id, # holding institution is a required field
+ "source_completeness": "1", # required field
+ "production_method": "1", # required field
},
)
-
self.assertEqual(response.status_code, 302)
created_source = Source.objects.get(shelfmark="test-shelfmark")
self.assertRedirects(
@@ -106,13 +105,13 @@ def test_url_and_templates(self):
def test_edit_source(self):
source = make_fake_source()
- hinst = make_fake_institution(siglum="FA-Ke")
response = self.client.post(
reverse("source-edit", args=[source.id]),
{
"shelfmark": "test-shelfmark", # shelfmark is a required field,
- "holding_institution": hinst.id, # holding institution is a required field
+ "source_completeness": "1", # required field
+ "production_method": "1", # required field
},
)
@@ -981,10 +980,14 @@ def test_ordering(self) -> None:
Order is currently available by country, city + institution name (parameter:
"city_institution"), and siglum + shelfmark. Siglum + shelfmark is the default.
"""
- # Create a bunch of sources
sources = []
+ # Add a source from a private collector
+ private_collector = make_fake_institution(is_private_collector=True)
+ sources.append(make_fake_source(holding_institution=private_collector))
+ # Create a bunch of other sources
for _ in range(10):
- sources.append(make_fake_source())
+ inst = make_fake_institution(siglum=faker.word())
+ sources.append(make_fake_source(holding_institution=inst))
# Default ordering is by siglum and shelfmark, ascending
with self.subTest("Default ordering"):
response = self.client.get(reverse("source-list"))
@@ -992,7 +995,13 @@ def test_ordering(self) -> None:
expected_source_order = sorted(
sources,
key=lambda source: (
- source.holding_institution.siglum,
+ 0 if source.holding_institution else 1,
+ 1 if source.holding_institution.is_private_collector else 0,
+ (
+ source.holding_institution.siglum
+ if source.holding_institution.siglum
+ else ""
+ ),
source.shelfmark,
),
)
diff --git a/django/cantusdb_project/main_app/views/api.py b/django/cantusdb_project/main_app/views/api.py
index 48f4da16e..f83cc6296 100644
--- a/django/cantusdb_project/main_app/views/api.py
+++ b/django/cantusdb_project/main_app/views/api.py
@@ -1,14 +1,13 @@
import csv
-from typing import List
-from typing import Optional
+from typing import Optional, Union, Any
from django.contrib.auth import get_user_model
from django.contrib.flatpages.models import FlatPage
from django.core.exceptions import PermissionDenied
from django.db.models.query import QuerySet
-from django.db.models import Model
from django.http.response import JsonResponse
-from django.http import HttpResponse, HttpResponseNotFound
+from django.http import HttpResponse, HttpResponseNotFound, Http404, HttpRequest
from django.urls.base import reverse
+from django.shortcuts import get_object_or_404
from articles.models import Article
from main_app.models import (
Chant,
@@ -19,16 +18,9 @@
Source,
)
from next_chants import next_chants
-from django.http import Http404
-from django.core.exceptions import PermissionDenied
-from django.urls import reverse
-from django.contrib.auth import get_user_model
-from typing import List
-from django.contrib.flatpages.models import FlatPage
-from django.shortcuts import get_object_or_404
-def ajax_melody_list(request, cantus_id) -> JsonResponse:
+def ajax_melody_list(request: HttpRequest, cantus_id: str) -> JsonResponse:
"""
Function-based view responding to the AJAX call for melody list on the chant detail page,
accessed with ``chants/
/ {{ source.number_of_melodies }}
{% endif %}