diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py index 4f65fa883..997bc561b 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 ChantCreateForm(forms.ModelForm): class Meta: model = Chant @@ -125,8 +161,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 +189,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( @@ -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( @@ -550,10 +594,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): @@ -738,10 +786,7 @@ class Meta: widget=TextInputWidget, ) - name = forms.CharField( - required=False, - widget=TextInputWidget - ) + name = forms.CharField(required=False, widget=TextInputWidget) holding_institution = forms.ModelChoiceField( queryset=Institution.objects.all().order_by("name"), 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 0d8374a47..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 @@ -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() @@ -2817,13 +2857,13 @@ def test_repeated_seq(self) -> None: for i in range(1, 5): Chant.objects.create( source=source, - manuscript_full_text=faker.text(10), + 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={ @@ -2960,6 +3000,45 @@ def test_suggested_chant_buttons(self) -> None: ) 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 b0bbdfbc2..654fdf71f 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: @@ -848,16 +853,19 @@ def get_context_data(self, **kwargs: Any) -> dict[Any, Any]: 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(" ") @@ -893,8 +901,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): @@ -1113,11 +1120,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 @@ -1224,12 +1238,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