diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py index 698cc8359..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,40 @@ 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 @@ -80,6 +116,7 @@ class StyledChoiceField(forms.ChoiceField): widget = SelectWidget() + class ChantCreateForm(forms.ModelForm): class Meta: model = Chant @@ -134,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(), @@ -162,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( @@ -318,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) @@ -353,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( @@ -531,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): @@ -714,6 +763,15 @@ class Meta: # 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("city", "name"), required=False, diff --git a/django/cantusdb_project/main_app/templates/chant_create.html b/django/cantusdb_project/main_app/templates/chant_create.html index 5aec5de03..4b29bb0bb 100644 --- a/django/cantusdb_project/main_app/templates/chant_create.html +++ b/django/cantusdb_project/main_app/templates/chant_create.html @@ -223,7 +223,7 @@

Create Chant

- + {{ form.manuscript_full_text_std_spelling }}

{{ form.manuscript_full_text_std_spelling.help_text }} @@ -241,8 +241,7 @@

Create Chant

- + {{ form.manuscript_full_text }}

{{ form.manuscript_full_text.help_text }} diff --git a/django/cantusdb_project/main_app/templates/chant_edit.html b/django/cantusdb_project/main_app/templates/chant_edit.html index 038eef616..47860c7d5 100644 --- a/django/cantusdb_project/main_app/templates/chant_edit.html +++ b/django/cantusdb_project/main_app/templates/chant_edit.html @@ -21,6 +21,7 @@ {% for message in messages %}

{% endfor %} @@ -283,25 +284,33 @@

Syllabification is based on saved syllabized text.

{% endif %}
- {% for syl_text, syl_mel in syllabized_text_with_melody %} - -
{{ syl_mel }}
- -
{{ syl_text }}
-
- {% endfor %} + {% if syllabized_text_with_melody %} + {% for syl_text, syl_mel in syllabized_text_with_melody %} + +
{{ syl_mel }}
+ +
{{ syl_text }}
+
+ {% endfor %} + {% else %} +

Error aligning text and melody. Please check text for invalid characters.

+ {% endif %}
{% endif %} -
-
- - Edit syllabification (new window) - + + {% if syllabized_text_with_melody %} + -
+ {% endif %}
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 @@

Edit Syllabification

-
@@ -46,8 +47,8 @@

Edit Syllabification

-
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/views/chant.py b/django/cantusdb_project/main_app/views/chant.py index 7f57674d4..2d357da5f 100644 --- a/django/cantusdb_project/main_app/views/chant.py +++ b/django/cantusdb_project/main_app/views/chant.py @@ -7,6 +7,7 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.db.models import Q, QuerySet +from django.forms import BaseModelForm from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -18,6 +19,7 @@ TemplateView, UpdateView, ) +from volpiano_display_utilities.latin_word_syllabification import LatinError from volpiano_display_utilities.cantus_text_syllabification import ( syllabify_text, flatten_syllabified_text, @@ -205,11 +207,14 @@ def get_context_data(self, **kwargs): # syllabification section if chant.volpiano: has_syl_text = bool(chant.manuscript_syllabized_full_text) - text_and_mel, _ = align_text_and_volpiano( - chant.get_best_text_for_syllabizing(), - chant.volpiano, - text_presyllabified=has_syl_text, - ) + try: + text_and_mel, _ = align_text_and_volpiano( + chant.get_best_text_for_syllabizing(), + chant.volpiano, + text_presyllabified=has_syl_text, + ) + except LatinError: + text_and_mel = None context["syllabized_text_with_melody"] = text_and_mel if project := chant.project: @@ -769,13 +774,15 @@ class ChantCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): template_name = "chant_create.html" form_class = ChantCreateForm pk_url_kwarg = "source_pk" + source: Source + latest_chant: Optional[Chant] def test_func(self): user = self.request.user source_id = self.kwargs.get(self.pk_url_kwarg) - source = get_object_or_404(Source, id=source_id) + self.source = get_object_or_404(Source, id=source_id) - return user_can_edit_chants_in_source(user, source) + return user_can_edit_chants_in_source(user, self.source) # if success_url and get_success_url not specified, will direct to chant detail page def get_success_url(self): @@ -793,8 +800,10 @@ def get_initial(self): """ try: latest_chant = self.source.chant_set.latest("date_updated") + self.latest_chant = latest_chant except Chant.DoesNotExist: # if there is no chant in source, start with folio 001r, and c_sequence 1 + self.latest_chant = None return { "folio": "001r", "feast": "", @@ -816,22 +825,12 @@ def get_initial(self): "image_link": latest_image, } - def dispatch(self, request, *args, **kwargs): - """Make sure the source specified in url exists before we display the form""" - self.source = get_object_or_404(Source, pk=kwargs["source_pk"]) - return super().dispatch(request, *args, **kwargs) - - def get_suggested_feasts(self): + def get_suggested_feasts(self, latest_chant: Chant) -> dict[Feast, int]: """based on the feast of the most recently edited chant, provide a list of suggested feasts that might follow the feast of that chant. Returns: a dictionary, with feast objects as keys and counts as values """ - try: - latest_chant = self.source.chant_set.latest("date_updated") - except Chant.DoesNotExist: - return None - current_feast = latest_chant.feast chants_that_end_current_feast = Chant.objects.filter( is_last_chant_in_feast=True, feast=current_feast @@ -852,31 +851,30 @@ def get_suggested_feasts(self): def get_context_data(self, **kwargs: Any) -> dict[Any, Any]: context = super().get_context_data(**kwargs) context["source"] = self.source - previous_chant: Optional[Chant] = None - try: - previous_chant = self.source.chant_set.latest("date_updated") - except Chant.DoesNotExist: - pass + previous_chant = self.latest_chant context["previous_chant"] = previous_chant - context["suggested_feasts"] = self.get_suggested_feasts() - - previous_cantus_id: Optional[str] = None + suggested_feasts = None + suggested_chants = None if previous_chant: + suggested_feasts = self.get_suggested_feasts(previous_chant) previous_cantus_id = previous_chant.cantus_id - - suggested_chants = None - if previous_cantus_id: - suggested_chants = get_suggested_chants(previous_cantus_id) + if previous_cantus_id: + suggested_chants = get_suggested_chants(previous_cantus_id) + context["suggested_feasts"] = suggested_feasts context["suggested_chants"] = suggested_chants return context def form_valid(self, form): - """compute source, incipit; folio/sequence (if left empty) - validate the form: add success/error message + """ + Validates the new chant. + + Custom validation steps are: + - Check if a chant with the same sequence and folio already exists in the source. + - Compute the chant incipit. + - Adds the "created_by" and "updated_by" fields to the chant. """ # compute source - form.instance.source = self.source # same effect as the next line - # form.instance.source = get_object_or_404(Source, pk=self.kwargs['source_pk']) + form.instance.source = self.source # compute incipit, within 30 charactors, keep words complete words = form.instance.manuscript_full_text_std_spelling.split(" ") @@ -912,8 +910,7 @@ def form_valid(self, form): "Chant '" + form.instance.incipit + "' created successfully!", ) return super().form_valid(form) - else: - return super().form_invalid(form) + return super().form_invalid(form) class ChantDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): @@ -1132,11 +1129,18 @@ def get_context_data(self, **kwargs): has_syl_text = bool(chant.manuscript_syllabized_full_text) # Note: the second value returned is a flag indicating whether the alignment process # encountered errors. In future, this could be used to display a message to the user. - text_and_mel, _ = align_text_and_volpiano( - chant.get_best_text_for_syllabizing(), - chant.volpiano, - text_presyllabified=has_syl_text, - ) + try: + text_and_mel, _ = align_text_and_volpiano( + chant.get_best_text_for_syllabizing(), + chant.volpiano, + text_presyllabified=has_syl_text, + ) + except LatinError as err: + messages.error( + self.request, + "Error in aligning text and melody: " + str(err), + ) + text_and_mel = None context["syllabized_text_with_melody"] = text_and_mel user = self.request.user @@ -1243,12 +1247,20 @@ def get_initial(self): initial = super().get_initial() chant = self.get_object() has_syl_text = bool(chant.manuscript_syllabized_full_text) - syls_text, _ = syllabify_text( - text=chant.get_best_text_for_syllabizing(), - clean_text=True, - text_presyllabified=has_syl_text, - ) - self.flattened_syls_text = flatten_syllabified_text(syls_text) + try: + syls_text, _ = syllabify_text( + text=chant.get_best_text_for_syllabizing(), + clean_text=True, + text_presyllabified=has_syl_text, + ) + self.flattened_syls_text = flatten_syllabified_text(syls_text) + except LatinError as err: + messages.error( + self.request, + "Error in syllabifying text: " + str(err), + ) + syls_text = None + self.flattened_syls_text = "" initial["manuscript_syllabized_full_text"] = self.flattened_syls_text return initial