diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py
index 5062361d7..b75f15ec3 100644
--- a/django/cantusdb_project/main_app/forms.py
+++ b/django/cantusdb_project/main_app/forms.py
@@ -249,14 +249,18 @@ def clean(self) -> dict[str, Any]:
# Call super().clean() to ensure that the form's built-in validation
# is run before our custom validation.
super().clean()
- folio = self.cleaned_data["folio"]
- c_sequence = self.cleaned_data["c_sequence"]
- source = self.cleaned_data["source"]
- if source.chant_set.filter(folio=folio, c_sequence=c_sequence):
- raise forms.ValidationError(
- "Chant with the same sequence and folio already exists in this source.",
- code="duplicate-folio-sequence",
- )
+ # Only do this additional validation if the form is otherwise valid.
+ # For example, if the `folio` field has been left blank, then we can't
+ # use is to check for uniqueness.
+ if self.is_valid():
+ folio = self.cleaned_data["folio"]
+ c_sequence = self.cleaned_data["c_sequence"]
+ source = self.cleaned_data["source"]
+ if source.chant_set.filter(folio=folio, c_sequence=c_sequence):
+ raise forms.ValidationError(
+ "Chant with the same sequence and folio already exists in this source.",
+ code="duplicate-folio-sequence",
+ )
return self.cleaned_data
@@ -953,7 +957,7 @@ class Meta:
)
-class ImageLinkForm(forms.Form):
+class AddImageLinksForm(forms.Form):
"""
Subclass of Django's Form class that creates the form we use for
adding image links to chants in a source.
@@ -985,6 +989,22 @@ def save(self, source: Source) -> None:
if image_link != "":
source.chant_set.filter(folio=folio).update(image_link=image_link)
+
+ChantCreateFormset = forms.inlineformset_factory(
+ Source,
+ Chant,
+ form=ChantCreateForm,
+ extra=0,
+ max_num=250,
+ can_delete=False,
+ can_order=False,
+)
+
+
+class ChantCreateFromCSVForm(forms.Form):
+ new_chants = forms.JSONField(widget=forms.HiddenInput)
+
+
class BrowseChantsBulkEditForm(forms.ModelForm):
class Meta:
model = Chant
diff --git a/django/cantusdb_project/main_app/mixins.py b/django/cantusdb_project/main_app/mixins.py
index bf163a19d..831f75e07 100644
--- a/django/cantusdb_project/main_app/mixins.py
+++ b/django/cantusdb_project/main_app/mixins.py
@@ -64,7 +64,7 @@ def render_to_response(
for field in json_fields:
obj_json[field] = self._get_field(obj, field)
return JsonResponse({obj.get_verbose_name(): obj_json})
- q_s = context["object_list"].values(*json_fields)
+ q_s = self.object_list.values(*json_fields) # type: ignore[attr-defined]
q_s_name = str(q_s.model.get_verbose_name_plural())
return JsonResponse({q_s_name: list(q_s)})
try:
diff --git a/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html
new file mode 100644
index 000000000..9039a6d19
--- /dev/null
+++ b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html
@@ -0,0 +1,107 @@
+{% extends "base.html" %}
+{% load static %}
+{% block title %}
+
Bulk Add Chants to Source: {{ source.short_heading }}
+{% endblock %}
+{% block scripts %}
+
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
+ Use this form to add chants from a csv file to this source. Each row of the csv file should
+ contain the data for a single chant (note: this form is limited to creating 250 chants additional
+ a time). The following columns are required:
+
+ folio
+ sequence
+ full_text_std_spelling
+
+
+
+ The following optional columns are also supported:
+
+ rubrics
+ indexing_notes
+ marginalia
+ content_structure
+ feast
+ service
+ genre
+ position
+ liturgical_function
+ cantus_id
+ mode
+ finalis
+ differentia
+ full_text_source_spelling
+ extra
+ chant_range
+ addendum
+ polyphony
+
+
+
+ The following checks will be performed on the csv file before it is saved. Failure of any of these
+ checks will leave all chants unsaved and an error message will be displayed.
+
+
+ A check that the required columns are present.
+ A check that any additional columns correspond to valid chant data fields.
+
+ A check that the source does not contain any chants with the same folio and sequence.
+
+
+ A check that the values of any of the following fields correspond to valid values:
+
+ feast
+ genre
+ service
+ polyphony
+ liturgical_function
+
+
+
+
+
+
Select CSV file containing chants
+
+
+
+
+
+
+ Add Chants
+
+
+
+
+
+{% endblock %}
diff --git a/django/cantusdb_project/main_app/templates/source_add_image_links.html b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html
similarity index 98%
rename from django/cantusdb_project/main_app/templates/source_add_image_links.html
rename to django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html
index d7a4c0901..b6822fd6e 100644
--- a/django/cantusdb_project/main_app/templates/source_add_image_links.html
+++ b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html
@@ -5,7 +5,7 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
{% block content %}
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 cd23b816a..e1c8785d6 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
@@ -1358,114 +1358,3 @@ def test_ordering(self) -> None:
self.assertEqual(
list(reversed(expected_source_order)), list(response_sources_reverse)
)
-
-
-class SourceAddImageLinksViewTest(TestCase):
- auth_user: User
- non_auth_user: User
- source: Source
-
- @classmethod
- def setUpTestData(cls) -> None:
- user_model = get_user_model()
- cls.auth_user = user_model.objects.create(
- email="authuser@test.com", password="12345", is_staff=True
- )
- cls.non_auth_user = user_model.objects.create(
- email="nonauthuser@test.com", password="12345", is_staff=False
- )
- cls.source = make_fake_source(published=True)
- for folio in ["001r", "001v", "003", "004A"]:
- make_fake_chant(source=cls.source, folio=folio, image_link=None)
- # Make a second chant for one of the folios, with an existing image link.
- # We'll update this image_link in the process.
- make_fake_chant(
- source=cls.source, folio="001v", image_link="https://i-already-exist.com"
- )
- # Make a final chant for a different folio with an existing image link. We
- # won't update this image_link in the process.
- make_fake_chant(
- source=cls.source, folio="004B", image_link="https://i-already-exist.com/2"
- )
-
- def test_permissions(self) -> None:
- with self.subTest("Test unauthenticated user"):
- response = self.client.get(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 302)
- self.assertRedirects(
- response,
- f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}",
- status_code=302,
- target_status_code=200,
- )
- response = self.client.post(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 302)
- self.assertRedirects(
- response,
- f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}",
- status_code=302,
- target_status_code=200,
- )
- with self.subTest("Test non-staff user"):
- self.client.force_login(self.non_auth_user)
- response = self.client.get(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 403)
- response = self.client.post(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 403)
- with self.subTest("Test staff user"):
- self.client.force_login(self.auth_user)
- response = self.client.get(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 200)
- # Post redirect is tested in the `test_form` method
-
- def test_form(self) -> None:
- with self.subTest("Test form fields"):
- self.client.force_login(self.auth_user)
- response = self.client.get(
- reverse("source-add-image-links", args=[self.source.id])
- )
- self.assertEqual(response.status_code, 200)
- self.assertTemplateUsed(response, "source_add_image_links.html")
- form = response.context["form"]
- self.assertListEqual(
- list(form.fields.keys()), ["001r", "001v", "003", "004A", "004B"]
- )
- with self.subTest("Test form submission"):
- response = self.client.post(
- reverse("source-add-image-links", args=[self.source.id]),
- {
- "001r": "https://example.com/001r",
- "001v": "https://example.com/001v",
- "004A": "https://example.com/004A",
- },
- )
- self.assertRedirects(
- response,
- reverse("source-detail", args=[self.source.id]),
- status_code=302,
- target_status_code=200,
- )
- with self.subTest("Test saved data"):
- chants_001r = Chant.objects.filter(source=self.source, folio="001r").all()
- self.assertEqual(len(chants_001r), 1)
- self.assertEqual(chants_001r[0].image_link, "https://example.com/001r")
- chants_001v = Chant.objects.filter(source=self.source, folio="001v").all()
- self.assertEqual(len(chants_001v), 2)
- for chant in chants_001v:
- self.assertEqual(chant.image_link, "https://example.com/001v")
- chants_003 = Chant.objects.filter(source=self.source, folio="003").all()
- self.assertEqual(len(chants_003), 1)
- self.assertIsNone(chants_003[0].image_link)
- chants_004B = Chant.objects.filter(source=self.source, folio="004B").all()
- self.assertEqual(len(chants_004B), 1)
- self.assertEqual(chants_004B[0].image_link, "https://i-already-exist.com/2")
diff --git a/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py b/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py
new file mode 100644
index 000000000..21ec06709
--- /dev/null
+++ b/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py
@@ -0,0 +1,289 @@
+import ujson
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+
+from main_app.models import Source, Chant
+from main_app.tests.make_fakes import (
+ make_fake_source,
+ make_fake_chant,
+ make_fake_segment,
+)
+from users.models import User
+
+
+class AddImageLinksViewTest(TestCase):
+ auth_user: User
+ non_auth_user: User
+ source: Source
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ user_model = get_user_model()
+ cls.auth_user = user_model.objects.create(
+ email="authuser@test.com", password="12345", is_staff=True
+ )
+ cls.non_auth_user = user_model.objects.create(
+ email="nonauthuser@test.com", password="12345", is_staff=False
+ )
+ cls.source = make_fake_source(published=True)
+ for folio in ["001r", "001v", "003", "004A"]:
+ make_fake_chant(source=cls.source, folio=folio, image_link=None)
+ # Make a second chant for one of the folios, with an existing image link.
+ # We'll update this image_link in the process.
+ make_fake_chant(
+ source=cls.source, folio="001v", image_link="https://i-already-exist.com"
+ )
+ # Make a final chant for a different folio with an existing image link. We
+ # won't update this image_link in the process.
+ make_fake_chant(
+ source=cls.source, folio="004B", image_link="https://i-already-exist.com/2"
+ )
+
+ def test_permissions(self) -> None:
+ with self.subTest("Test unauthenticated user"):
+ response = self.client.get(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(
+ response,
+ f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}",
+ status_code=302,
+ target_status_code=200,
+ )
+ response = self.client.post(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(
+ response,
+ f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}",
+ status_code=302,
+ target_status_code=200,
+ )
+ with self.subTest("Test non-staff user"):
+ self.client.force_login(self.non_auth_user)
+ response = self.client.get(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 403)
+ response = self.client.post(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 403)
+ with self.subTest("Test staff user"):
+ self.client.force_login(self.auth_user)
+ response = self.client.get(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 200)
+ # Post redirect is tested in the `test_form` method
+
+ def test_form(self) -> None:
+ with self.subTest("Test form fields"):
+ self.client.force_login(self.auth_user)
+ response = self.client.get(
+ reverse("source-add-image-links", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(
+ response, "source_bulk_actions/add_image_links.html"
+ )
+ form = response.context["form"]
+ self.assertListEqual(
+ list(form.fields.keys()), ["001r", "001v", "003", "004A", "004B"]
+ )
+ with self.subTest("Test form submission"):
+ response = self.client.post(
+ reverse("source-add-image-links", args=[self.source.id]),
+ {
+ "001r": "https://example.com/001r",
+ "001v": "https://example.com/001v",
+ "004A": "https://example.com/004A",
+ },
+ )
+ self.assertRedirects(
+ response,
+ reverse("source-detail", args=[self.source.id]),
+ status_code=302,
+ target_status_code=200,
+ )
+ with self.subTest("Test saved data"):
+ chants_001r = Chant.objects.filter(source=self.source, folio="001r").all()
+ self.assertEqual(len(chants_001r), 1)
+ self.assertEqual(chants_001r[0].image_link, "https://example.com/001r")
+ chants_001v = Chant.objects.filter(source=self.source, folio="001v").all()
+ self.assertEqual(len(chants_001v), 2)
+ for chant in chants_001v:
+ self.assertEqual(chant.image_link, "https://example.com/001v")
+ chants_003 = Chant.objects.filter(source=self.source, folio="003").all()
+ self.assertEqual(len(chants_003), 1)
+ self.assertIsNone(chants_003[0].image_link)
+ chants_004B = Chant.objects.filter(source=self.source, folio="004B").all()
+ self.assertEqual(len(chants_004B), 1)
+ self.assertEqual(chants_004B[0].image_link, "https://i-already-exist.com/2")
+
+
+class AddChantsViewTest(TestCase):
+ auth_user: User
+ non_auth_user: User
+ source: Source
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ segment = make_fake_segment(id=4063)
+ cls.source = make_fake_source(published=True, segment=segment)
+ user_model = get_user_model()
+ cls.auth_user = user_model.objects.create(
+ email="authuser@test.com", password="12345", is_staff=True
+ )
+ cls.non_auth_user = user_model.objects.create(
+ email="nonauthuser@test.com", password="12345", is_staff=False
+ )
+
+ def test_permissions(self) -> None:
+ with self.subTest("Test unauthenticated user"):
+ response = self.client.get(
+ reverse("source-add-chants", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(
+ response,
+ f"{reverse('login')}?next={reverse('source-add-chants', args=[self.source.id])}",
+ status_code=302,
+ target_status_code=200,
+ )
+ response = self.client.post(
+ reverse("source-add-chants", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(
+ response,
+ f"{reverse('login')}?next={reverse('source-add-chants', args=[self.source.id])}",
+ status_code=302,
+ target_status_code=200,
+ )
+ with self.subTest("Test non-staff user"):
+ self.client.force_login(self.non_auth_user)
+ response = self.client.get(
+ reverse("source-add-chants", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 403)
+ response = self.client.post(
+ reverse("source-add-chants", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 403)
+ with self.subTest("Test staff user"):
+ self.client.force_login(self.auth_user)
+ response = self.client.get(
+ reverse("source-add-chants", args=[self.source.id])
+ )
+ self.assertEqual(response.status_code, 200)
+ # Post redirect is tested in the `test_post` method
+
+ def test_post(self) -> None:
+ """
+ Tests the following possibilities for the POST request:
+ - the data sent with the POST request does not pass
+ ChantCreateFromCSVForm validation
+ - the data sent with the POST request passes ChantCreateFromCSVForm
+ validation, but not the ChantCreateFormset validation
+ - the data sent with the POST request passes ChantCreateFromCSVForm
+ validation and the ChantCreateFormset validation
+ """
+ self.client.force_login(self.auth_user)
+ with self.subTest("Invalid ChantCreateFromCSVForm"):
+ response = self.client.post(
+ reverse("source-add-chants", args=[self.source.id]),
+ {
+ "new_chants": "Some text that is not JSON.",
+ },
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertJSONEqual(
+ response.content,
+ {
+ "form_error": "The submitted form is invalid. Please try again.",
+ },
+ )
+ with self.subTest("Valid ChantCreateFromCSVForm; invalid chants"):
+ make_fake_chant(source=self.source, folio="001r", c_sequence=1)
+ invalid_new_chants = [
+ {
+ "folio": "001r", # This chant repeats the folio
+ "c_sequence": 1, # and c_sequence of an existing chant
+ "full_text_std_spelling": "Some standard full text",
+ },
+ {
+ "folio": "001v",
+ "c_sequence": 1,
+ # This chant contains no full_text_std_spelling
+ "polyphony": "Some polyphony", # Not a valid choice for this field
+ },
+ { # a valid chant
+ "folio": "001v",
+ "c_sequence": 2,
+ "full_text_std_spelling": "Some other standard full text",
+ },
+ ]
+ response = self.client.post(
+ reverse("source-add-chants", args=[self.source.id]),
+ {
+ "new_chants": ujson.dumps(invalid_new_chants),
+ },
+ )
+ self.assertEqual(response.status_code, 400)
+ formset_errors = response.json()["formset_errors"]
+ expected_formset_errors = [
+ {
+ "form_idx": 0,
+ "field_name": "__all__",
+ "error": [
+ "Chant with the same sequence and folio already exists in this source."
+ ],
+ },
+ {
+ "form_idx": 1,
+ "field_name": "full_text_std_spelling",
+ "error": ["This field is required."],
+ },
+ {
+ "form_idx": 1,
+ "field_name": "polyphony",
+ "error": [
+ "Select a valid choice. Some polyphony is not one of the available choices."
+ ],
+ },
+ ]
+ self.assertEqual(formset_errors, expected_formset_errors)
+ # Test no additional chants were saved
+ self.assertEqual(Chant.objects.filter(source=self.source).count(), 1)
+ with self.subTest("Valid ChantCreateFromCSVForm; valid chants"):
+ valid_new_chants = [
+ {
+ "folio": "001v",
+ "c_sequence": 1,
+ "full_text_std_spelling": "Some standard full text",
+ },
+ {
+ "folio": "001v",
+ "c_sequence": 2,
+ "full_text_std_spelling": "Some other standard full text",
+ },
+ ]
+ response = self.client.post(
+ reverse("source-add-chants", args=[self.source.id]),
+ {
+ "new_chants": ujson.dumps(valid_new_chants),
+ },
+ )
+ self.assertRedirects(
+ response,
+ reverse("browse-chants", args=[self.source.id]),
+ status_code=302,
+ target_status_code=200,
+ )
+ chants = Chant.objects.filter(source=self.source).all()
+ self.assertEqual(len(chants), 3)
diff --git a/django/cantusdb_project/main_app/urls.py b/django/cantusdb_project/main_app/urls.py
index 307380857..4bb3281aa 100644
--- a/django/cantusdb_project/main_app/urls.py
+++ b/django/cantusdb_project/main_app/urls.py
@@ -85,7 +85,6 @@
SourceListView,
SourceDeleteView,
SourceInventoryView,
- SourceAddImageLinksView,
)
from main_app.views.user import (
CustomLogoutView,
@@ -106,6 +105,10 @@
ProofreadByAutocomplete,
HoldingAutocomplete,
)
+from main_app.views.source_bulk_actions import (
+ AddImageLinksView,
+ AddChantsView,
+)
from main_app.views.auth import change_password
urlpatterns = [
@@ -367,9 +370,14 @@
),
path(
"source//add-image-links",
- SourceAddImageLinksView.as_view(),
+ AddImageLinksView.as_view(),
name="source-add-image-links",
),
+ path(
+ "source//add-chants",
+ AddChantsView.as_view(),
+ name="source-add-chants",
+ ),
# melody
path(
"melody/",
diff --git a/django/cantusdb_project/main_app/views/autocomplete.py b/django/cantusdb_project/main_app/views/autocomplete.py
index 6e8fa446c..a6d7b9a24 100644
--- a/django/cantusdb_project/main_app/views/autocomplete.py
+++ b/django/cantusdb_project/main_app/views/autocomplete.py
@@ -1,5 +1,5 @@
from dal import autocomplete
-from django.db.models import Q
+from django.db.models import Q, QuerySet
from django.contrib.auth import get_user_model
from main_app.models import (
@@ -57,9 +57,7 @@ def get_queryset(self):
class FeastAutocomplete(autocomplete.Select2QuerySetView):
- def get_queryset(self):
- if not self.request.user.is_authenticated:
- return Feast.objects.none()
+ def get_queryset(self) -> QuerySet[Feast]:
qs = Feast.objects.all().order_by("name")
if self.q:
qs = qs.filter(name__icontains=self.q)
@@ -67,10 +65,10 @@ def get_queryset(self):
class ServiceAutocomplete(autocomplete.Select2QuerySetView):
- def get_result_label(self, result):
+ def get_result_label(self, result: Service) -> str:
return f"{result.name} - {result.description}"
- def get_queryset(self):
+ def get_queryset(self) -> QuerySet[Service]:
if not self.request.user.is_authenticated:
return Service.objects.none()
qs = Service.objects.all().order_by("name")
@@ -82,12 +80,10 @@ def get_queryset(self):
class GenreAutocomplete(autocomplete.Select2QuerySetView):
- def get_result_label(self, result):
+ def get_result_label(self, result: Genre) -> str:
return f"{result.name} - {result.description}"
- def get_queryset(self):
- if not self.request.user.is_authenticated:
- return Genre.objects.none()
+ def get_queryset(self) -> QuerySet[Genre]:
qs = Genre.objects.all().order_by("name")
if self.q:
qs = qs.filter(
diff --git a/django/cantusdb_project/main_app/views/feast.py b/django/cantusdb_project/main_app/views/feast.py
index 187b3695e..14ddd5ae4 100644
--- a/django/cantusdb_project/main_app/views/feast.py
+++ b/django/cantusdb_project/main_app/views/feast.py
@@ -7,6 +7,7 @@
from extra_views import SearchableListMixin
from main_app.models import Feast
+from main_app.mixins import JSONResponseMixin
# this categorization is not finalized yet
# the feastcode on old cantus requires cleaning
@@ -86,10 +87,11 @@ def namedtuple_fetch(results, description) -> Generator[NamedTuple, None, None]:
yield nt_result(*res)
-class FeastDetailView(DetailView):
+class FeastDetailView(JSONResponseMixin, DetailView):
model = Feast
context_object_name = "feast"
template_name = "feast_detail.html"
+ json_fields = ["id", "name", "description", "feast_code"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -129,7 +131,7 @@ def get_context_data(self, **kwargs):
return context
-class FeastListView(SearchableListMixin, ListView):
+class FeastListView(JSONResponseMixin, SearchableListMixin, ListView):
"""Searchable List view for Feast model
Accessed by /feasts/
@@ -146,6 +148,7 @@ class FeastListView(SearchableListMixin, ListView):
paginate_by = 100
context_object_name = "feasts"
template_name = "feast_list.html"
+ json_fields = ["id", "name", "description", "feast_code"]
def get_ordering(self) -> tuple:
ordering = self.request.GET.get("sort_by")
diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py
index be2bcc26e..d71941a21 100644
--- a/django/cantusdb_project/main_app/views/source.py
+++ b/django/cantusdb_project/main_app/views/source.py
@@ -1,5 +1,5 @@
import re
-from typing import Any, Optional
+from typing import Any
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -17,14 +17,11 @@
UpdateView,
DeleteView,
TemplateView,
- FormView,
)
-from django.views.generic.detail import SingleObjectMixin
from main_app.forms import (
SourceCreateForm,
SourceEditForm,
SourceBrowseChantsProofreadForm,
- ImageLinkForm,
BrowseChantsBulkEditFormset,
)
from main_app.models import (
@@ -622,46 +619,3 @@ def get_context_data(self, **kwargs):
context["chants"] = queryset
return context
-
-
-class SourceAddImageLinksView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore
- template_name = "source_add_image_links.html"
- pk_url_kwarg = "source_id"
- queryset = Source.objects.select_related("holding_institution")
- context_object_name = "source"
- form_class = ImageLinkForm
- object: Source
- http_method_names = ["get", "post"]
-
- def test_func(self) -> bool:
- return user_can_manage_source_editors(self.request.user)
-
- def get_success_url(self) -> str:
- return reverse("source-detail", args=[self.object.id])
-
- def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
- self.object = self.get_object()
- return super().get(request, *args, **kwargs)
-
- def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
- self.object = self.get_object()
- return super().post(request, *args, **kwargs)
-
- def get_initial(self) -> dict[str, Any]:
- """
- Set the initial data required by the ImageLinkForm
- on GET requests.
- """
- folios: QuerySet[Chant, Optional[str]] = (
- self.object.chant_set.values_list("folio", flat=True)
- .distinct()
- .order_by("folio")
- )
- return {folio: "" for folio in folios if folio}
-
- def form_valid(self, form: ImageLinkForm) -> HttpResponseRedirect:
- """
- Save the image links to the database.
- """
- form.save(self.object)
- return HttpResponseRedirect(self.get_success_url())
diff --git a/django/cantusdb_project/main_app/views/source_bulk_actions.py b/django/cantusdb_project/main_app/views/source_bulk_actions.py
new file mode 100644
index 000000000..e145f4686
--- /dev/null
+++ b/django/cantusdb_project/main_app/views/source_bulk_actions.py
@@ -0,0 +1,178 @@
+"""
+This module contains views that are used to make bulk additions or edits to chants
+(or some subset of chants) associated with a particular source.
+"""
+
+from typing import Any, Optional
+from django.http import (
+ HttpRequest,
+ HttpResponse,
+ HttpResponseRedirect,
+ JsonResponse,
+)
+from django.views.generic.edit import FormView
+from django.views.generic.detail import SingleObjectMixin
+from django.contrib import messages
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.urls import reverse
+from django.db.models.query import QuerySet
+from django.forms.models import BaseInlineFormSet
+
+from main_app.models import Source, Chant
+from main_app.forms import AddImageLinksForm, ChantCreateFormset, ChantCreateFromCSVForm
+from main_app.permissions import user_can_manage_source_editors
+
+
+class AddImageLinksView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore
+ template_name = "source_bulk_actions/add_image_links.html"
+ pk_url_kwarg = "source_id"
+ queryset = Source.objects.select_related("holding_institution")
+ context_object_name = "source"
+ form_class = AddImageLinksForm
+ object: Source
+ http_method_names = ["get", "post"]
+
+ def test_func(self) -> bool:
+ return user_can_manage_source_editors(self.request.user)
+
+ def get_success_url(self) -> str:
+ return reverse("source-detail", args=[self.object.id])
+
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ self.object = self.get_object()
+ return super().get(request, *args, **kwargs)
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ self.object = self.get_object()
+ return super().post(request, *args, **kwargs)
+
+ def get_initial(self) -> dict[str, Any]:
+ """
+ Set the initial data required by the ImageLinkForm
+ on GET requests.
+ """
+ folios: QuerySet[Chant, Optional[str]] = (
+ self.object.chant_set.values_list("folio", flat=True)
+ .distinct()
+ .order_by("folio")
+ )
+ return {folio: "" for folio in folios if folio}
+
+ def form_valid(self, form: AddImageLinksForm) -> HttpResponseRedirect:
+ """
+ Save the image links to the database.
+ """
+ form.save(self.object)
+ return HttpResponseRedirect(self.get_success_url())
+
+
+class AddChantsView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore
+ template_name = "source_bulk_actions/add_chants.html"
+ http_method_names = ["get", "post"]
+ pk_url_kwarg = "source_id"
+ queryset = Source.objects.select_related("holding_institution")
+ context_object_name = "source"
+ object: Source
+ form_class = ChantCreateFromCSVForm
+ # The field_name map is used to map field names in the CSV file to
+ # field names on the Chant model.
+ field_name_map = {
+ "sequence": "c_sequence",
+ "full_text_std_spelling": "manuscript_full_text_std_spelling",
+ "full_text_source_spelling": "manuscript_full_text",
+ }
+
+ def get_success_url(self) -> str:
+ return reverse("browse-chants", args=[self.object.id])
+
+ def test_func(self) -> bool:
+ return user_can_manage_source_editors(self.request.user)
+
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ """
+ The default `FormView` get method does not call the `SingleObjectMixin`
+ `get_object` method, so we need to call it manually.
+ """
+ self.object = self.get_object()
+ return super().get(request, *args, **kwargs)
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ """
+ The default `FormView` post method does not call the `SingleObjectMixin`
+ `get_object` method, so we need to call it manually.
+ """
+ self.object = self.get_object()
+ return super().post(request, *args, **kwargs)
+
+ def form_valid(self, form: ChantCreateFromCSVForm) -> HttpResponse:
+ """
+ If the form is valid, we need to save the new chants to the database.
+ For this, we create a formset with the data from the ChantCreateCSVForm's
+ cleaned and validated data.
+
+ We use the ChantCreateFromCSVForm (which puts all new chant data in a single
+ JSON Field) since otherwise we would quickly run into an issue with having
+ too many fields (governed by the DATA_UPLOAD_MAX_NUMBER_FILES setting, because
+ of how the form data is encoded client-side) in the form. Rather than fine-tune
+ that setting to account for the varying number of chants that could be added and
+ dealing with formset management on the front-end, we use this single-field form.
+ """
+ chant_data = form.cleaned_data["new_chants"]
+ # Add the management form data to the formset data
+ formset_data = {
+ "chant_set-TOTAL_FORMS": len(chant_data),
+ "chant_set-INITIAL_FORMS": 0,
+ }
+ form_count = 0
+ for chant in chant_data:
+ for key, value in chant.items():
+ mapped_field_name = self.field_name_map.get(key, key)
+ formset_data[f"chant_set-{form_count}-{mapped_field_name}"] = value
+ form_count += 1
+ new_chant_formset = ChantCreateFormset(formset_data, instance=self.object)
+ if new_chant_formset.is_valid():
+ new_chant_formset.save()
+ messages.success(self.request, f"{form_count} chants added successfully.")
+ return HttpResponseRedirect(self.get_success_url())
+ return self.chant_formset_invalid(new_chant_formset)
+
+ def form_invalid(self, form: ChantCreateFromCSVForm) -> HttpResponse:
+ """
+ If the form is invalid, we'll pass back a 400 response.
+ """
+ return JsonResponse(
+ status=400,
+ data={"form_error": "The submitted form is invalid. Please try again."},
+ )
+
+ def chant_formset_invalid(
+ self, formset: BaseInlineFormSet # type: ignore[type-arg]
+ ) -> HttpResponse:
+ """
+ If the formset is invalid, we'll pass back errors in the response
+ to be displayed to the user.
+ """
+ # Errors will reference the model field names, so we need to map them
+ # back to the field names in the CSV file for display.
+ errors_list = []
+ swapped_field_name_map = {v: k for k, v in self.field_name_map.items()}
+ for form_idx, form_errors in enumerate(formset.errors):
+ if form_errors:
+ for (
+ field_name,
+ error,
+ ) in form_errors.items(): # type:ignore[attr-defined]
+ mapped_field_name = swapped_field_name_map.get(
+ field_name, field_name
+ )
+ errors_list.append(
+ {
+ "form_idx": form_idx,
+ "field_name": mapped_field_name,
+ "error": error,
+ }
+ )
+ return JsonResponse(
+ status=400,
+ data={"formset_errors": errors_list},
+ )
diff --git a/django/cantusdb_project/static/css/source_add_image_links.css b/django/cantusdb_project/static/css/source_bulk_add.css
similarity index 100%
rename from django/cantusdb_project/static/css/source_add_image_links.css
rename to django/cantusdb_project/static/css/source_bulk_add.css
diff --git a/django/cantusdb_project/static/js/source_add_chants.js b/django/cantusdb_project/static/js/source_add_chants.js
new file mode 100644
index 000000000..9ab332bdb
--- /dev/null
+++ b/django/cantusdb_project/static/js/source_add_chants.js
@@ -0,0 +1,220 @@
+function updatePreviewHeader(columnHeaders) {
+ // Update the header row of the preview table
+ // - columnHeaders: an array of strings with the column headers
+ const tableHead = document.getElementById('csvPreviewHead');
+ tableHead.innerHTML = '';
+ for (let i = 0; i < columnHeaders.length; i++) {
+ const th = document.createElement('th');
+ th.textContent = columnHeaders[i];
+ tableHead.appendChild(th);
+ }
+};
+
+function createTableCell(value) {
+ // Create a table cell with the given value
+ // doing the necessary replacements for values
+ // coming from our csv file match regex:
+ // - unescape double quotes
+ // - remove leading and trailing quotes
+ // - remove leading and trailing commas
+ // - remove leading and trailing whitespace
+ value = value.replaceAll(/^[",]*|[,"]*$/g, '');
+ value = value.trim().replaceAll(/""/g, '"');
+ const td = document.createElement('td');
+ td.textContent = value;
+ return [td, value];
+}
+
+function styleTableError(elem, error_message) {
+ // Style a table element (tr or td) as an error
+ elem.classList.add('table-danger');
+ elem.setAttribute('data-bs-title', error_message);
+ new bootstrap.Tooltip(elem);
+}
+
+function csvLoadCallback(csv, relatedFieldMaps) {
+ // Instantiate a FormData object with our existing
+ // form data.
+ const addChantsForm = new FormData(document.getElementById('addChantsForm'));
+ // Remove the values from a previously-selected file
+ // from the form data
+ addChantsForm.set('new_chants', null);
+ // Parse the CSV file and display it in the table.
+ parseCSVUpdateFormAndPreview(csv, addChantsForm, relatedFieldMaps);
+ return addChantsForm;
+};
+
+
+async function getRelatedFieldMap(relatedField) {
+ // Get a map of name -> id for related field
+ // values. Calls the endpoint provided and returns
+ // a promise that resolves to the
+ // map of related field values.
+ // Intended to work with one of the following
+ // values for relatedField:
+ // - 'genres'
+ // - 'services'
+ // - 'feasts'
+ const endpoint = `/${relatedField}/`;
+ return fetch(endpoint, { headers: { 'Accept': 'application/json' } })
+ .then(response => response.json())
+ .then(data => {
+ const fieldData = data[relatedField];
+ const nameIDMap = {};
+ for (let i = 0; i < fieldData.length; i++) {
+ nameIDMap[fieldData[i].name] = fieldData[i].id;
+ }
+ return nameIDMap;
+ })
+ .catch((error) => {
+ console.error('Error:', error);
+ });
+}
+
+function parseCSVUpdateFormAndPreview(csv, formData, relatedFieldMaps) {
+ // Parse the passed CSV file and update the preview
+ // table and the form with the new data.
+ const rows = csv.split('\n');
+ const columnHeaders = rows[0].trim().split(',');
+ updatePreviewHeader(columnHeaders);
+ const tableBody = document.getElementById('csvPreviewBody');
+ tableBody.innerHTML = '';
+ newChantsJSON = [];
+ const relatedFieldNames = ['genre', 'service', 'feast'];
+ for (let i = 1; i < rows.length; i++) {
+ const row = rows[i];
+ // Split the row into columns with a regex pattern that
+ // accounts for commas inside quotes.
+ const rowValues = row.match(/(?:"([^"]*(?:""[^"]*)*)")|([^",]+)|(?:,)()(?=,)/g)
+ const tr = document.createElement('tr');
+ newChantObj = {};
+ for (let j = 0; j < rowValues.length; j++) {
+ // Unescape double quotes
+ const rowValue = rowValues[j];
+ const [td, escapedRowValue] = createTableCell(rowValue);
+ tr.appendChild(td);
+ // If the column is a related field, add the id to the form data
+ if (relatedFieldNames.includes(columnHeaders[j])) {
+ // If the value of the field is blank, set it to null, but if the
+ // value is not blank but does not map to a valid id, flag it.
+ if (escapedRowValue === '') {
+ newChantObj[columnHeaders[j]] = null;
+ } else if (relatedFieldMaps[`${columnHeaders[j]}s`].hasOwnProperty(escapedRowValue)) {
+ newChantObj[columnHeaders[j]] = relatedFieldMaps[`${columnHeaders[j]}s`][escapedRowValue];
+ } else {
+ styleTableError(td, `Invalid value for ${columnHeaders[j]}`);
+ addGeneralErrorAlert(`Found: invalid value for ${columnHeaders[j]}. See red cells for details.`);
+ }
+ } else {
+ newChantObj[columnHeaders[j]] = escapedRowValue;
+ }
+ }
+ tableBody.appendChild(tr);
+ newChantsJSON.push(newChantObj);
+ document.getElementById("csvPreviewDiv").hidden = false;
+ formData.set('new_chants', JSON.stringify(newChantsJSON));
+ }
+};
+
+function addLoadingSpinner(button) {
+ const spinnerElem = document.createElement('div');
+ spinnerElem.classList.add('spinner-border', 'spinner-border-sm');
+ spinnerElem.setAttribute('role', 'status');
+ const spinnerSpan = document.createElement('span');
+ spinnerSpan.classList.add('visually-hidden');
+ spinnerSpan.textContent = 'Loading...';
+ spinnerElem.appendChild(spinnerSpan);
+ button.appendChild(spinnerElem);
+};
+
+function removeLoadingSpinner(button) {
+ button.removeChild(button.lastChild);
+};
+
+function addGeneralErrorAlert(error_message) {
+ // Add a general error alert to the form
+ const formErrorDiv = document.getElementById("formErrorAlertDiv");
+ const alert = document.createElement('div');
+ alert.classList.add('alert', 'alert-danger', 'alert-dismissible');
+ formErrorDiv.appendChild(alert);
+ alert.setAttribute('role', 'alert');
+ alert.textContent = error_message;
+ const closeButton = document.createElement('button');
+ closeButton.classList.add('btn-close');
+ closeButton.setAttribute('data-bs-dismiss', 'alert');
+ closeButton.setAttribute('aria-label', 'Close');
+ closeButton.setAttribute('type', 'button');
+ alert.appendChild(closeButton);
+ new bootstrap.Alert(alert);
+};
+
+document.addEventListener('DOMContentLoaded', function () {
+ var addChantsForm;
+ // Add listener to the file input field to parse and display the CSV file
+ document.getElementById('addChantsCSV').addEventListener('change', function (e) {
+ const file = e.target.files[0];
+ const reader = new FileReader();
+ reader.onload = function (e) {
+ const csv = e.target.result;
+ addChantsForm = csvLoadCallback(csv, relatedFieldMaps);
+ };
+ reader.readAsText(file);
+ });
+ // Add a listener to handle the form submission
+ document.getElementById('addChantsForm').addEventListener('submit', function (e) {
+ e.preventDefault();
+ // Add loading spinner to the submit button
+ const submitButton = document.getElementById('addChantsFormSubmitBtn');
+ addLoadingSpinner(submitButton);
+ // Post the form data to the server
+ fetch(this.action, {
+ method: 'POST',
+ body: addChantsForm
+ })
+ .then(response => {
+ // Remove the loading spinner from the submit button
+ removeLoadingSpinner(submitButton);
+ // If the response is not ok, display the errors.
+ if (!response.ok) {
+ response.json().then(data => {
+ if (data['form_error']) {
+ // If there is a form error, it is a general error
+ // that we'll show as an alert.
+ addGeneralErrorAlert(data['form_error']);
+ };
+ if (data['formset_errors']) {
+ // If there are errors, they are chant-specific errors
+ // that we'll display in the preview table.
+ const columns = document.getElementById('csvPreviewHead').children;
+ const tableBody = document.getElementById('csvPreviewBody');
+ const rows = tableBody.children;
+ for (error of data['formset_errors']) {
+ const error_row = rows[error['form_idx']];
+ if (error["field_name"] === "__all__") {
+ styleTableError(error_row, error["error"]);
+ } else {
+ const cellIndex = Array.from(columns).findIndex(cell => cell.textContent === error["field_name"]);
+ const errorCell = error_row.children[cellIndex];
+ styleTableError(errorCell, error["error"]);
+ };
+ };
+ addGeneralErrorAlert("Errors were found in individual chants. Hover over red cells to see the errors.");
+ }
+ }
+ );
+ } else {
+ // If the response is ok, it is a redirect to the
+ // browse chants page. Redirect the user to that page.
+ window.location.href = response.url;
+ }
+ });
+
+ });
+ var relatedFieldMaps = {};
+ // Get the related field maps
+ const relatedFields = ['genres', 'services', 'feasts'];
+ relatedFields.forEach(async (relatedField) => {
+ relatedFieldMaps[relatedField] = await getRelatedFieldMap(relatedField);
+ });
+}
+);
\ No newline at end of file