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 page for bulk adding of image links to source chants #1728

Merged
merged 2 commits into from
Jan 17, 2025
Merged
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
37 changes: 35 additions & 2 deletions django/cantusdb_project/main_app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from django.contrib.admin.widgets import (
FilteredSelectMultiple,
)
from django.forms.widgets import CheckboxSelectMultiple
from dal import autocomplete
from django.forms.widgets import CheckboxSelectMultiple, HiddenInput
from dal import autocomplete # type: ignore[import-untyped]
from volpiano_display_utilities.cantus_text_syllabification import syllabify_text
from volpiano_display_utilities.latin_word_syllabification import LatinError
from .models import (
Expand Down Expand Up @@ -934,3 +934,36 @@ class Meta:
'using <a href="../password/">this form</a>.'
)
)


class ImageLinkForm(forms.Form):
"""
Subclass of Django's Form class that creates the form we use for
adding image links to chants in a source.

Initialize the Form with a field for every folio in the source,
passed as the "initial" parameter, which is a dictionary with a key
for every folio and a blank value.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
initial = kwargs.get("initial")
if initial:
for folio in initial:
self.fields[folio] = forms.CharField(
widget=HiddenInput(attrs={"class": "img-link-input"}),
required=False,
)

def save(self, source: Source) -> None:
"""
Save the image links to the database.

Args:
source: The source to which the image links belong.
"""
cleaned_data = self.cleaned_data
for folio, image_link in cleaned_data.items():
if image_link != "":
source.chant_set.filter(folio=folio).update(image_link=image_link)
2 changes: 1 addition & 1 deletion django/cantusdb_project/main_app/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def user_can_view_user_detail(viewing_user: User, user: User) -> bool:
return viewing_user.is_authenticated or user.is_indexer


def user_can_manage_source_editors(user: User) -> bool:
def user_can_manage_source_editors(user: Union[User, AnonymousUser]) -> bool:
"""
Checks if the user has permission to change the editors assigned to a Source.
Used in SourceDetailView.
Expand Down
133 changes: 133 additions & 0 deletions django/cantusdb_project/main_app/templates/source_add_image_links.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
<title>Add Image Links to Source: {{ source.short_heading }}</title>
{% endblock %}
{% block scripts %}
<script src="{% static 'js/source_add_image_links.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/source_add_image_links.css' %}" />
{% endblock %}
{% block content %}
<div class="container bg-white rounded">
<div class="row mt-4">
<div class="col">
<h4>
Add Image Links to Source: <a href="{% url 'source-detail' source.id %}">{{ source.heading }}</a>
</h4>
<p class="small pt-3">
Use this form to add image links from a csv file to the chants in this source. The csv file should
contain two columns. The first column should be the folio on which the chant appears, matching the values
of the "folio" field in the source inventory. The second column should be a url to an image of the chant,
including the scheme (e.g., "https://") and domain. An optional header may be included.
</p>
<p class="small">
The following checks will be performed on the csv file before it is uploaded:
</p>
<ol class="small">
<li>A check that an image link is defined for every folio in the source.</li>
<li>
A check that image links are not defined for any folios that do not exist in the source.
</li>
<li>
A check that no folios are duplicated in the file (e.g., a single
folio does not have two image links defined).
</li>
<li>
A check that either no image links are duplicated (every folio has a
unique image link) or every image link is the same (image links to
individual folios are not available).
</li>
</ol>
<p class="small">
Note: Files that fail these checks may still be uploaded but are provided as a simple
validation for the user. For example, if a folio appears multiple times in the csv,
the image link appearing last in the csv will be used. Sometimes, a mapping that fails
these checks might be correct, such as when images show two facing folios.
</p>
</div>
</div>
<label for="imgLinksCSV" class="form-label">
Select CSV file containing image links
</label>
<div class="row mb-4">
<div class="col-4">
<input class="form-control form-control-sm"
id="imgLinksCSV"
type="file"
accept=".csv" />
<form id="imgLinkForm" method="post">
{% csrf_token %}
{{ form }}
</form>
</div>
<div class="col">
<button id="imgLinkFormSubmitBtn"
class="btn btn-sm btn-primary"
form="imgLinkForm"
type="submit">Save Image Links</button>
</div>
</div>
<div id="csvTestingDiv" class="row" hidden>
<div class="col-auto">
<table class="table table-sm table-borderless csv-testing-table small">
<tbody>
<tr>
<td class="csv-testing-table-test">All image links provided</td>
<td class="csv-testing-table-icon">
<i id="folioCompletenessIcon"></i>
</td>
<td>
<div id="folioCompletenessInstances" class="csv-testing-table-instances"></div>
</td>
</tr>
<tr>
<td class="csv-testing-table-test">Extra folios in CSV</td>
<td class="csv-testing-table-icon">
<i id="extraFoliosIcon"></i>
</td>
<td>
<div id="extraFoliosInstances" class="csv-testing-table-instances"></div>
</td>
</tr>
<tr>
<td>Duplicate folios</td>
<td class="csv-testing-table-icon">
<i id="folioDuplicationIcon"></i>
</td>
<td>
<div id="folioDuplicationInstances" class="csv-testing-table-instances"></div>
</td>
</tr>
<tr>
<td>Duplicate image links</td>
<td class="csv-testing-table-icon">
<i id="imageLinkDuplicationIcon"></i>
</td>
<td>
<div id="imageLinkDuplicationInstances"
class="csv-testing-table-instances"></div>
</td>
</tr>
</tbody>
</table>
</div>
</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>
<tr>
<th class="text-center img-link-preview-cell">Folio</th>
<th class="text-center img-link-preview-cell">Image Link</th>
</tr>
</thead>
<tbody id="csvPreviewBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
112 changes: 112 additions & 0 deletions django/cantusdb_project/main_app/tests/test_views/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
add_accents_to_string,
)
from main_app.tests.mixins import HTMLContentsTestMixin
from users.models import User

# Create a Faker instance with locale set to Latin
faker = Faker("la")
Expand Down Expand Up @@ -1198,3 +1199,114 @@ 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")
6 changes: 6 additions & 0 deletions django/cantusdb_project/main_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
SourceListView,
SourceDeleteView,
SourceInventoryView,
SourceAddImageLinksView,
)
from main_app.views.user import (
CustomLogoutView,
Expand Down Expand Up @@ -364,6 +365,11 @@
SourceDeleteView.as_view(),
name="source-delete",
),
path(
"source/<int:source_id>/add-image-links",
SourceAddImageLinksView.as_view(),
name="source-add-image-links",
),
# melody
path(
"melody/",
Expand Down
Loading
Loading