Skip to content
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
5 changes: 4 additions & 1 deletion manuscript/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,12 @@ def link_canvases_view(self, request, object_id):
# keep what was entered so it can be corrected
if isinstance(raw_rows, list):
submitted = {
str(row.get("id")): str(row.get("folio") or "")
str(row.get("id")): [
str(number) for number in row.get("folios") or []
]
for row in raw_rows
if isinstance(row, dict)
and isinstance(row.get("folios") or [], list)
}
else:
if counts:
Expand Down
68 changes: 38 additions & 30 deletions manuscript/canvas_linking.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@
CANVAS_ID_MAX_LENGTH = Folio._meta.get_field("canvas_id").max_length


def folio_key(number):
"""Key for matching folio numbers, ignoring differences like "f. 12r" and
"12r\" """
return normalize_folio_label(number) or (number or "").lower()


def clean_canvas_rows(rows):
"""Validate the rows submitted from the canvas linking page. Each row is a
canvas from the manifest: id, index, label, image_id, and the folio
number entered for it (blank for none)."""
numbers entered for it, as an image can show more than one folio."""
if not isinstance(rows, list):
raise ValidationError("The submitted images could not be read.")

Expand All @@ -26,24 +32,31 @@ def clean_canvas_rows(rows):
index = int(row["index"])
label = str(row.get("label") or "").strip()[:255]
image_id = str(row.get("image_id") or "").strip() or None
folio_number = str(row.get("folio") or "").strip()
if not isinstance(row.get("folios", []), list):
raise TypeError
numbers = [str(number).strip() for number in row.get("folios", [])]
except (KeyError, TypeError, ValueError):
raise ValidationError("The submitted images could not be read.")
if not canvas_id or len(canvas_id) > CANVAS_ID_MAX_LENGTH or index < 0:
raise ValidationError("The submitted images could not be read.")
if image_id and len(image_id) > CANVAS_ID_MAX_LENGTH:
image_id = None
if len(folio_number) > FOLIO_NUMBER_MAX_LENGTH:
if any(len(number) > FOLIO_NUMBER_MAX_LENGTH for number in numbers):
raise ValidationError(
f"The folio number entered for image {index + 1} is too long."
)
# one entry per folio, ignoring blanks and repeats within the row
folios_by_key = {}
for number in numbers:
if number:
folios_by_key.setdefault(folio_key(number), number)
cleaned.append(
{
"id": canvas_id,
"index": index,
"label": label,
"image_id": image_id,
"folio": folio_number,
"folios": list(folios_by_key.values()),
}
)

Expand All @@ -62,56 +75,53 @@ def link_canvases(manuscript, rows, can_create=True):
page, where rows are cleaned by clean_canvas_rows.

A folio number is matched to an existing folio of the manuscript (ignoring
differences like "f. 12r" and "12r"), or a new folio is created. Folios are
never renamed or deleted: a folio whose canvas is cleared or given to
another folio is only unlinked, so its toponyms and other data stay intact.
differences like "f. 12r" and "12r"), or a new folio is created. A canvas
can have several folios, but a folio has one canvas. Folios are never
renamed or deleted: a folio removed from a canvas is only unlinked, so its
toponyms and other data stay intact.

Returns a Counter of linked, unlinked, and created folios, and a list of
the folios created. Raises ValidationError, without saving anything, if
the entries conflict."""
errors = []

# folio number entered -> row, checking no folio is entered twice
rows_by_folio = {}
positions_by_folio = defaultdict(list)
# folio number key -> (row, number as entered), checking no folio is
# entered for two canvases
entries_by_folio = defaultdict(list)
for row in rows:
if row["folio"]:
key = normalize_folio_label(row["folio"]) or row["folio"].lower()
rows_by_folio[key] = row
positions_by_folio[key].append(row)
for key, entered in positions_by_folio.items():
for number in row["folios"]:
entries_by_folio[folio_key(number)].append((row, number))
for entered in entries_by_folio.values():
if len(entered) > 1:
positions = ", ".join(str(row["index"] + 1) for row in entered)
positions = ", ".join(str(row["index"] + 1) for row, _ in entered)
errors.append(
f'Folio "{entered[0]["folio"]}" is entered for more than one '
f"image ({positions}). Each folio can be linked to one image."
f'Folio "{entered[0][1]}" is entered for more than one image '
f"({positions}). Each folio can be linked to one image."
)
rows_by_folio = {key: entered[-1] for key, entered in entries_by_folio.items()}

folios = list(manuscript.folio_set.all())
folios_by_key = defaultdict(list)
for folio in folios:
key = (
normalize_folio_label(folio.folio_number)
or (folio.folio_number or "").lower()
)
key = folio_key(folio.folio_number)
if key:
folios_by_key[key].append(folio)

targets = {} # folio number key -> existing folio, or None to create
for key, row in rows_by_folio.items():
for key, (row, number) in rows_by_folio.items():
matches = folios_by_key.get(key, [])
if len(matches) > 1:
numbers = ", ".join(sorted(f'"{f.folio_number}"' for f in matches))
errors.append(
f'Folio "{row["folio"]}" matches several folio records in this '
f'Folio "{number}" matches several folio records in this '
f"manuscript ({numbers}). Merge or renumber those folio records "
"first."
)
elif matches:
targets[key] = matches[0]
elif not can_create:
errors.append(
f'There is no folio record "{row["folio"]}" in this manuscript, '
f'There is no folio record "{number}" in this manuscript, '
"and you don't have permission to add folio records."
)
else:
Expand All @@ -124,13 +134,11 @@ def link_canvases(manuscript, rows, can_create=True):
created = []
submitted_ids = {row["id"] for row in rows}
linked_folio_ids = {
folio.pk: rows_by_folio[key]["id"]
folio.pk: rows_by_folio[key][0]["id"]
for key, folio in targets.items()
if folio is not None
}
with transaction.atomic():
# unlink first, so moving a folio to another canvas can't collide
# with the one-folio-per-canvas constraint
for folio in folios:
if folio.canvas_id not in submitted_ids:
continue
Expand All @@ -147,9 +155,9 @@ def link_canvases(manuscript, rows, can_create=True):
)

for key, folio in targets.items():
row = rows_by_folio[key]
row, number = rows_by_folio[key]
if folio is None:
folio = Folio(manuscript=manuscript, folio_number=row["folio"])
folio = Folio(manuscript=manuscript, folio_number=number)
created.append(folio)
counts["created"] += 1
elif (
Expand Down
12 changes: 1 addition & 11 deletions manuscript/management/commands/verify_canvas_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@ def verify_manuscript(self, manuscript, dry_run):
canvases_by_image[canvas["image_id"]].append(canvas)

folios = list(manuscript.folio_set.exclude(canvas_id__isnull=True))
# canvases held by a valid link, which a re-linked folio can't take
used_canvas_ids = {
folio.canvas_id for folio in folios if folio.canvas_id in canvases_by_id
}

notes = defaultdict(list)
with transaction.atomic():
Expand All @@ -102,16 +98,10 @@ def verify_manuscript(self, manuscript, dry_run):
canvas, method = self.find_moved_canvas(
folio, canvases, canvases_by_image
)
if canvas and canvas["id"] not in used_canvas_ids:
if canvas:
notes[f"re-linked by {method}"].append(self.describe(folio, canvas))
self.apply_canvas(folio, canvas, dry_run)
used_canvas_ids.add(canvas["id"])
counts["relinked"] += 1
elif canvas:
notes[
"canvas gone, and its replacement is linked to another folio"
].append(folio.folio_number or f"#{folio.pk}")
counts["broken"] += 1
else:
notes["canvas gone, and no replacement found"].append(
folio.folio_number or f"#{folio.pk}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1.15 on 2026-11-03 19:30

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("manuscript", "0129_add_public_notes"),
]

operations = [
migrations.RemoveConstraint(
model_name="folio",
name="unique_folio_canvas_per_manuscript",
),
]
8 changes: 0 additions & 8 deletions manuscript/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,14 +691,6 @@ def get_canvas_id(self):

class Meta:
ordering = ["folio_number"]
constraints = [
models.UniqueConstraint(
fields=["manuscript", "canvas_id"],
condition=Q(canvas_id__isnull=False),
name="unique_folio_canvas_per_manuscript",
violation_error_message="Another folio in this manuscript is already linked to this IIIF canvas.",
)
]


class SingleManuscript(models.Model):
Expand Down
78 changes: 63 additions & 15 deletions manuscript/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError
from django.forms import inlineformset_factory
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
Expand Down Expand Up @@ -237,20 +236,24 @@ def test_dry_run_does_not_save(self):
folio.refresh_from_db()
self.assertIsNone(folio.canvas_id)

def test_canvas_unique_per_manuscript(self):
def test_folios_can_share_a_canvas(self):
canvas_id = "https://example.org/iiif/ms/canvas/p0001"
Folio.objects.create(manuscript=self.manuscript, canvas_id=canvas_id)
with self.assertRaises(IntegrityError):
Folio.objects.create(manuscript=self.manuscript, canvas_id=canvas_id)
for number in ("13v", "14r"):
Folio.objects.create(
manuscript=self.manuscript, folio_number=number, canvas_id=canvas_id
)
self.assertEqual(
self.manuscript.folio_set.filter(canvas_id=canvas_id).count(), 2
)


def canvas_row(number, folio="", label=None):
def canvas_row(number, *folios, label=None):
return {
"id": f"https://example.org/iiif/ms/canvas/p{number:04d}",
"index": number - 1,
"label": label if label is not None else f"label {number}",
"image_id": f"https://example.org/image/{number}",
"folio": folio,
"folios": list(folios),
}


Expand Down Expand Up @@ -335,6 +338,39 @@ def test_leaves_links_to_canvases_not_submitted(self):
folio.refresh_from_db()
self.assertEqual(folio.canvas_id, canvas_row(9)["id"])

def test_links_several_folios_to_a_canvas(self):
existing = self.add_folio("13v")
counts = self.link([canvas_row(1, "13v", "14r")])

self.assertEqual(counts["linked"], 2)
self.assertEqual([folio.folio_number for folio in self.created], ["14r"])
existing.refresh_from_db()
self.assertEqual(existing.canvas_id, canvas_row(1)["id"])
self.assertEqual(
Folio.objects.get(folio_number="14r").canvas_id, canvas_row(1)["id"]
)

def test_removing_one_of_a_canvas_s_folios_only_unlinks_that_one(self):
kept = self.add_folio("13v", canvas=1)
removed = self.add_folio("14r", canvas=1)
counts = self.link([canvas_row(1, "13v")])

self.assertEqual(counts["unlinked"], 1)
kept.refresh_from_db()
removed.refresh_from_db()
self.assertEqual(kept.canvas_id, canvas_row(1)["id"])
self.assertIsNone(removed.canvas_id)

def test_same_folio_twice_on_a_canvas_counts_once(self):
folio = self.add_folio("13v")
rows = clean_canvas_rows([canvas_row(1, "13v", "f. 13v", " ")])

self.assertEqual(rows[0]["folios"], ["13v"])
counts, _ = link_canvases(self.manuscript, rows)
self.assertEqual(counts["linked"], 1)
folio.refresh_from_db()
self.assertEqual(folio.canvas_id, canvas_row(1)["id"])

def test_same_folio_on_two_canvases_is_rejected(self):
folio = self.add_folio("1r", canvas=1)
with self.assertRaisesMessage(ValidationError, "more than one image (1, 2)"):
Expand Down Expand Up @@ -495,7 +531,7 @@ def test_errors_keep_entries(self):

self.assertEqual(response.status_code, 200)
self.assertContains(response, "more than one image")
self.assertEqual(response.context["submitted"][canvas_row(2)["id"]], "1r")
self.assertEqual(response.context["submitted"][canvas_row(2)["id"]], ["1r"])
self.assertFalse(Folio.objects.exists())

def test_needs_folio_permission(self):
Expand Down Expand Up @@ -601,14 +637,26 @@ def test_reports_links_it_cannot_fix(self):
self.assertIn("canvas gone, and no replacement found: 1r", out)
self.assertIn("1 could not be re-linked", out)

def test_does_not_take_a_canvas_linked_to_another_folio(self):
self.add_folio("1v", "b", 1, "1v", "img-b")
folio = self.add_folio("1r", "a", 0, "1r", "img-b")
out = self.run_command([("b", "1v", "img-b")])
def test_folios_sharing_a_canvas_are_re_linked_together(self):
folios = [
self.add_folio("13v", "a", 0, "13v-14r", "img-a"),
self.add_folio("14r", "a", 0, "13v-14r", "img-a"),
]
out = self.run_command([("new-a", "13v-14r", "img-a")])

folio.refresh_from_db()
self.assertEqual(folio.canvas_id, "https://example.org/iiif/ms/canvas/a")
self.assertIn("its replacement is linked to another folio: 1r", out)
for folio in folios:
folio.refresh_from_db()
self.assertEqual(
folio.canvas_id, "https://example.org/iiif/ms/canvas/new-a"
)
self.assertIn("2 re-linked", out)

def test_folios_sharing_a_canvas_are_left_alone_when_it_is_still_there(self):
for number in ("13v", "14r"):
self.add_folio(number, "a", 0, "13v-14r", "img-a")
out = self.run_command([("a", "13v-14r", "img-a")])

self.assertIn("2 unchanged", out)

def test_dry_run_does_not_save(self):
folio = self.add_folio("1r", "a", 0, "1r", "img-a")
Expand Down
Loading
Loading