Skip to content

Commit

Permalink
feat: Add chant image links view
Browse files Browse the repository at this point in the history
Add a new view along with associated template and static files for the bulk upload (by csv) of image links to specific chants in a source.

Add form that dynamically creates fields for each folio and implements custom save method for updated image links.

Add a few checks for completeness and duplication of the csv file on the front-end.

Add associated tests.
  • Loading branch information
dchiller committed Dec 12, 2024
1 parent a33c981 commit 91994c0
Show file tree
Hide file tree
Showing 8 changed files with 514 additions and 10 deletions.
39 changes: 37 additions & 2 deletions django/cantusdb_project/main_app/forms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from typing import Any

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 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 @@ -885,3 +887,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="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")
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

0 comments on commit 91994c0

Please sign in to comment.