Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create workflow to add chants to a source from a csv file. #1770

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions django/cantusdb_project/main_app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion django/cantusdb_project/main_app/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
<title>Bulk Add Chants to Source: {{ source.short_heading }}</title>
{% endblock %}
{% block scripts %}
<script src="{% static 'js/source_add_chants.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/source_bulk_add.css' %}" />
{% endblock %}
{% block content %}
<div class="container bg-white rounded">
<div class="row mt-4">
<div class="col small">
<h4>
Bulk Add Chants to Source: <a href="{% url 'source-detail' source.id %}">{{ source.heading }}</a>
</h4>
<p class="pt-3">
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:
<ul>
<li>folio</li>
<li>sequence</li>
<li>full_text_std_spelling</li>
</ul>
</p>
<p>
The following optional columns are also supported:
<ul>
<li>rubrics</li>
<li>indexing_notes</li>
<li>marginalia</li>
<li>content_structure</li>
<li>feast</li>
<li>service</li>
<li>genre</li>
<li>position</li>
<li>liturgical_function</li>
<li>cantus_id</li>
<li>mode</li>
<li>finalis</li>
<li>differentia</li>
<li>full_text_source_spelling</li>
<li>extra</li>
<li>chant_range</li>
<li>addendum</li>
<li>polyphony</li>
</ul>
</p>
<p>
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.
</p>
<ol>
<li>A check that the required columns are present.</li>
<li>A check that any additional columns correspond to valid chant data fields.</li>
<li>
A check that the source does not contain any chants with the same folio and sequence.
</li>
<li>
A check that the values of any of the following fields correspond to valid values:
<ul>
<li>feast</li>
<li>genre</li>
<li>service</li>
<li>polyphony</li>
<li>liturgical_function</li>
</ul>
</li>
</ol>
</div>
</div>
<label for="addChantsCSV" class="form-label">Select CSV file containing chants</label>
<div class="row mb-4">
<div class="col-4">
<input class="form-control form-control-sm"
id="addChantsCSV"
type="file"
accept=".csv" />
<form id="addChantsForm" method="post">
{% csrf_token %}
{{ form }}
</form>
</div>
<div class="col">
<button id="addChantsFormSubmitBtn"
class="btn btn-sm btn-primary"
form="addChantsForm"
type="submit">Add Chants</button>
</div>
</div>
<div class="row" id="formErrorAlertDiv"></div>
<div id="csvPreviewDiv" hidden>
<h6>CSV Preview</h6>
<div id="csvPreviewDiv" class="row justify-content-center">
<div class="col-auto csv-preview-table">
<table class="table table-sm table-bordered small">
<thead id="csvPreviewHead">
</thead>
<tbody id="csvPreviewBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{% endblock %}
{% block scripts %}
<script src="{% static 'js/source_add_image_links.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/source_add_image_links.css' %}" />
<link rel="stylesheet" href="{% static 'css/source_bulk_add.css' %}" />
{% endblock %}
{% block content %}
<div class="container bg-white rounded">
Expand Down
111 changes: 0 additions & 111 deletions django/cantusdb_project/main_app/tests/test_views/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]", password="12345", is_staff=True
)
cls.non_auth_user = user_model.objects.create(
email="[email protected]", 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")
Loading
Loading