diff --git a/bin/post_compile b/bin/post_compile index 0127b7b..6a864a4 100644 --- a/bin/post_compile +++ b/bin/post_compile @@ -1,2 +1,3 @@ #!/bin/bash python manage.py prepare_db +python manage.py loaddata fixtures/sites.json diff --git a/fixtures/sites.json b/fixtures/sites.json index bda24f8..b77e43b 100644 --- a/fixtures/sites.json +++ b/fixtures/sites.json @@ -14,5 +14,13 @@ "domain": "example.com", "name": "example.com" } + }, + { + "pk": 3, + "model": "sites.site", + "fields": { + "domain": "aniop-atlas-staging.eldarion.com", + "name": "AniOp ATLAS [staging]" + } } ] diff --git a/readhomer_atlas/iiif.py b/readhomer_atlas/iiif.py new file mode 100644 index 0000000..f075962 --- /dev/null +++ b/readhomer_atlas/iiif.py @@ -0,0 +1,70 @@ +from posixpath import join as urljoin +from urllib.parse import quote_plus, unquote + + +class IIIFResolver: + BASE_URL = "https://image.library.jhu.edu/iiif/" + # @@@ figure out what this actually is in IIIF spec terms + CANVAS_BASE_URL = "https://rosetest.library.jhu.edu/rosademo/iiif3/" + COLLETION_SUBDIR = "homer/VA" + iruri_kwargs = { + "region": "full", + "size": "full", + "rotation": "0", + "quality": "default", + "format": "jpg", + } + + def __init__(self, urn): + """ + IIIFResolver("urn:cite2:hmt:vaimg.2017a:VA012VN_0514") + """ + self.urn = urn + + @property + def munged_image_path(self): + image_part = self.urn.rsplit(":", maxsplit=1).pop() + return image_part.replace("_", "-") + + @property + def iiif_image_id(self): + path = urljoin(self.COLLETION_SUBDIR, self.munged_image_path) + return quote_plus(path) + + @property + def identifier(self): + return urljoin(self.BASE_URL, self.iiif_image_id) + + @property + def info_url(self): + info_path = "image.json" + return urljoin(self.identifier, info_path) + + def build_image_request_url(self, **kwargs): + iruri_kwargs = {} + iruri_kwargs.update(self.iruri_kwargs) + iruri_kwargs.update(**kwargs) + return urljoin( + self.identifier, + "{region}/{size}/{rotation}/{quality}.{format}".format(**iruri_kwargs), + ) + + @property + def image_url(self): + return self.build_image_request_url() + + @property + def canvas_url(self): + path = unquote(self.iiif_image_id) + return urljoin(self.CANVAS_BASE_URL, path, "canvas") + + def get_region_by_pct(self, dimensions): + percentages = ",".join( + [ + f'{dimensions["x"]:.2f}', + f'{dimensions["y"]:.2f}', + f'{dimensions["w"]:.2f}', + f'{dimensions["h"]:.2f}', + ] + ) + return f"pct:{percentages}" diff --git a/readhomer_atlas/library/models.py b/readhomer_atlas/library/models.py index c5069c1..6c621aa 100755 --- a/readhomer_atlas/library/models.py +++ b/readhomer_atlas/library/models.py @@ -95,7 +95,7 @@ def resolve_references(self): if delta_urns: print( - f'Could not resolve all references, probably to bad data in the CEX file [urn="{self.urn}" unresolved_urns="{",".join(delta_urns)}"]' + f'Could not resolve all references, probably due to bad data in the CEX file [urn="{self.urn}" unresolved_urns="{",".join(delta_urns)}"]' ) self.text_parts.set(reference_objs) diff --git a/readhomer_atlas/library/schema.py b/readhomer_atlas/library/schema.py index 9c37d69..d8a8704 100644 --- a/readhomer_atlas/library/schema.py +++ b/readhomer_atlas/library/schema.py @@ -1,4 +1,4 @@ -from django.db.models import Max, Min, Q +from django.db.models import Q import django_filters from graphene import Connection, Field, ObjectType, String, relay @@ -18,31 +18,18 @@ Token, ) from .urn import URN -from .utils import get_chunker +from .utils import ( + extract_version_urn_and_ref, + filter_via_ref_predicate, + get_chunker, + get_textparts_from_passage_reference, +) # @@@ alias Node because relay.Node is quite different TextPart = Node -def extract_version_urn_and_ref(value): - dirty_version_urn, ref = value.rsplit(":", maxsplit=1) - # Restore the trailing ":". - version_urn = f"{dirty_version_urn}:" - return version_urn, ref - - -def filter_via_ref_predicate(instance, queryset, predicate): - # We need a sequential identifier to do the range unless there is something - # else we can do with siblings / slicing within treebeard. Using `path` - # might work too, but having `idx` also allows us to do simple integer math - # as-needed. - if queryset.exists(): - subquery = queryset.filter(predicate).aggregate(min=Min("idx"), max=Max("idx")) - queryset = queryset.filter(idx__gte=subquery["min"], idx__lte=subquery["max"]) - return queryset - - class LimitedConnectionField(DjangoFilterConnectionField): """ Ensures that queries without `first` or `last` return up to @@ -191,7 +178,7 @@ def reference_filter(self, queryset, name, value): urn__startswith=version_urn, depth=len(start.split(".")) + 1, ) - return filter_via_ref_predicate(self, queryset, predicate) + return filter_via_ref_predicate(queryset, predicate) class Meta: model = TextPart @@ -219,52 +206,10 @@ def _add_passage_to_context(self, reference): self.request.passage["version"] = version - def _build_predicate(self, queryset, ref, max_rank): - predicate = Q() - if not ref: - # @@@ get all the text parts in the work; do we want to support this - # or should we just return the first text part? - start = queryset.first().ref - end = queryset.last().ref - else: - try: - start, end = ref.split("-") - except ValueError: - start = end = ref - - # @@@ still need to validate reference based on the depth - # start_book, start_line = instance._resolve_ref(start) - # end_book, end_line = instance._resolve_ref(end) - # the validation might be done through treebeard; for now - # going to avoid the queries at this time - if start: - if len(start.split(".")) == max_rank: - condition = Q(ref=start) - else: - condition = Q(ref__istartswith=f"{start}.") - predicate.add(condition, Q.OR) - if end: - if len(end.split(".")) == max_rank: - condition = Q(ref=end) - else: - condition = Q(ref__istartswith=f"{end}.") - predicate.add(condition, Q.OR) - if not start or not end: - raise ValueError(f"Invalid reference: {ref}") - - return predicate - def get_lowest_textparts_queryset(self, value): self._add_passage_to_context(value) version = self.request.passage["version"] - citation_scheme = version.metadata["citation_scheme"] - max_depth = version.get_descendants().last().depth - - max_rank = len(citation_scheme) - queryset = version.get_descendants().filter(depth=max_depth) - _, ref = value.rsplit(":", maxsplit=1) - predicate = self._build_predicate(queryset, ref, max_rank) - return filter_via_ref_predicate(self, queryset, predicate) + return get_textparts_from_passage_reference(value, version=version) class PassageTextPartFilterSet(TextPartsReferenceFilterMixin, django_filters.FilterSet): diff --git a/readhomer_atlas/library/utils.py b/readhomer_atlas/library/utils.py index dcb81e9..735000d 100644 --- a/readhomer_atlas/library/utils.py +++ b/readhomer_atlas/library/utils.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.db.models import Max, Min +from django.db.models import Max, Min, Q from django.utils.functional import cached_property @@ -88,3 +88,68 @@ def get_chunker(queryset, start_idx, chunk_length, **kwargs): if chunk_length < settings.ATLAS_CONFIG["IN_MEMORY_PASSAGE_CHUNK_MAX"]: return InMemorySiblingChunker(queryset, start_idx, chunk_length, **kwargs) return SQLSiblingChunker(queryset, start_idx, chunk_length, **kwargs) + + +def extract_version_urn_and_ref(value): + dirty_version_urn, ref = value.rsplit(":", maxsplit=1) + # Restore the trailing ":". + version_urn = f"{dirty_version_urn}:" + return version_urn, ref + + +def build_textpart_predicate(queryset, ref, max_rank): + predicate = Q() + if not ref: + # @@@ get all the text parts in the work; do we want to support this + # or should we just return the first text part? + start = queryset.first().ref + end = queryset.last().ref + else: + try: + start, end = ref.split("-") + except ValueError: + start = end = ref + + # @@@ still need to validate reference based on the depth + # start_book, start_line = instance._resolve_ref(start) + # end_book, end_line = instance._resolve_ref(end) + # the validation might be done through treebeard; for now + # going to avoid the queries at this time + if start: + if len(start.split(".")) == max_rank: + condition = Q(ref=start) + else: + condition = Q(ref__istartswith=f"{start}.") + predicate.add(condition, Q.OR) + if end: + if len(end.split(".")) == max_rank: + condition = Q(ref=end) + else: + condition = Q(ref__istartswith=f"{end}.") + predicate.add(condition, Q.OR) + if not start or not end: + raise ValueError(f"Invalid reference: {ref}") + + return predicate + + +def filter_via_ref_predicate(queryset, predicate): + # We need a sequential identifier to do the range unless there is something + # else we can do with siblings / slicing within treebeard. Using `path` + # might work too, but having `idx` also allows us to do simple integer math + # as-needed. + if queryset.exists(): + subquery = queryset.filter(predicate).aggregate(min=Min("idx"), max=Max("idx")) + queryset = queryset.filter(idx__gte=subquery["min"], idx__lte=subquery["max"]) + return queryset + + +def get_textparts_from_passage_reference(passage_reference, version): + citation_scheme = version.metadata["citation_scheme"] + max_depth = version.get_descendants().last().depth + + max_rank = len(citation_scheme) + queryset = version.get_descendants().filter(depth=max_depth) + _, ref = passage_reference.rsplit(":", maxsplit=1) + predicate = build_textpart_predicate(queryset, ref, max_rank) + return filter_via_ref_predicate(queryset, predicate) diff --git a/readhomer_atlas/settings.py b/readhomer_atlas/settings.py index 9d3b9e6..0f8ed1d 100644 --- a/readhomer_atlas/settings.py +++ b/readhomer_atlas/settings.py @@ -133,6 +133,7 @@ "readhomer_atlas", "readhomer_atlas.library", "readhomer_atlas.tocs", + "readhomer_atlas.web_annotation", ] ADMIN_URL = "admin:index" @@ -183,3 +184,7 @@ ) NODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +# @@@ review +DEFAULT_HTTP_CACHE_DURATION = 60 * 60 * 24 * 365 # one year +DEFAULT_HTTP_PROTOCOL = os.environ.get("DEFAULT_HTTP_PROTOCOL", "http") diff --git a/readhomer_atlas/urls.py b/readhomer_atlas/urls.py index 1fada18..d441c9a 100644 --- a/readhomer_atlas/urls.py +++ b/readhomer_atlas/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import include, path from django.views.decorators.csrf import csrf_exempt from django.contrib import admin @@ -13,4 +13,5 @@ path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True))), path("tocs/", serve_toc, name="serve_toc"), path("tocs/", tocs_index, name="tocs_index"), + path("wa/", include("readhomer_atlas.web_annotation.urls")), ] diff --git a/readhomer_atlas/web_annotation/__init__.py b/readhomer_atlas/web_annotation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/readhomer_atlas/web_annotation/apps.py b/readhomer_atlas/web_annotation/apps.py new file mode 100644 index 0000000..9ebf6f2 --- /dev/null +++ b/readhomer_atlas/web_annotation/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WebAnnotationConfig(AppConfig): + name = "web_annotation" diff --git a/readhomer_atlas/web_annotation/shims.py b/readhomer_atlas/web_annotation/shims.py new file mode 100644 index 0000000..cea0bf2 --- /dev/null +++ b/readhomer_atlas/web_annotation/shims.py @@ -0,0 +1,61 @@ +from django.db.models import Q +from django.utils.functional import cached_property + +from ..library.models import Node, TextAlignmentChunk +from ..library.utils import ( + extract_version_urn_and_ref, + get_textparts_from_passage_reference, +) +from .utils import preferred_folio_urn + + +class AlignmentsShim: + """ + Shim to allow us to retrieve alignment data indirectly from the database + eventually, we'll likely want to write out bonding box info as standoff annotation + and ship to explorehomer directly. + """ + + def __init__(self, folio_urn): + self.folio_urn = preferred_folio_urn(folio_urn) + + @cached_property + def folio_lines(self): + return Node.objects.filter(urn__startswith=self.folio_urn).filter(kind="line") + + @cached_property + def line_urns(self): + return [l.urn for l in self.folio_lines] + + def get_ref(self): + first = self.line_urns[0].rsplit(":", maxsplit=1)[1] + last = self.line_urns[-1].rsplit(":", maxsplit=1)[1] + # @@@ strip folios + first = first.split(".", maxsplit=1)[1] + last = last.split(".", maxsplit=1)[1] + if first == last: + return first + return f"{first}-{last}" + + def get_alignment_data(self, idx=None, fields=None): + if fields is None: + fields = ["idx", "items", "citation"] + + ref = self.get_ref() + version_urn = "urn:cts:greekLit:tlg0012.tlg001.perseus-grc2:" + passage_reference = f"{version_urn}{ref}" + + # @@@ add as a Node manager method + version_urn, ref = extract_version_urn_and_ref(passage_reference) + try: + version = Node.objects.get(urn=version_urn) + except Node.DoesNotExist: + raise Exception(f"{version_urn} was not found.") + + textparts_queryset = get_textparts_from_passage_reference( + passage_reference, version + ) + alignments = TextAlignmentChunk.objects.filter( + Q(start__in=textparts_queryset) | Q(end__in=textparts_queryset) + ).values(*fields) + return list(alignments) diff --git a/readhomer_atlas/web_annotation/shortcuts.py b/readhomer_atlas/web_annotation/shortcuts.py new file mode 100644 index 0000000..fee9548 --- /dev/null +++ b/readhomer_atlas/web_annotation/shortcuts.py @@ -0,0 +1,12 @@ +from django.conf import settings + +from django.contrib.sites.models import Site + + +def build_absolute_url(url): + # get_current should cache: + # https://docs.djangoproject.com/en/2.2/ref/contrib/sites/#caching-the-current-site-object + current_site = Site.objects.get_current() + return "{scheme}://{host}{url}".format( + scheme=settings.DEFAULT_HTTP_PROTOCOL, host=current_site.domain, url=url + ) diff --git a/readhomer_atlas/web_annotation/urls.py b/readhomer_atlas/web_annotation/urls.py new file mode 100644 index 0000000..98cee50 --- /dev/null +++ b/readhomer_atlas/web_annotation/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .views import ( + serve_wa, + serve_web_annotation_collection, + serve_web_annotation_page, +) + + +urlpatterns = [ + path( + "/translation-alignment/collection//", + serve_web_annotation_collection, + name="serve_web_annotation_collection", + ), + path( + "/translation-alignment/collection///", + serve_web_annotation_page, + name="serve_web_annotation_page", + ), + path( + "/translation-alignment///", + serve_wa, + name="serve_web_annotation", + ), +] diff --git a/readhomer_atlas/web_annotation/utils.py b/readhomer_atlas/web_annotation/utils.py new file mode 100644 index 0000000..a9be493 --- /dev/null +++ b/readhomer_atlas/web_annotation/utils.py @@ -0,0 +1,229 @@ +from django.db.models import Q +from django.shortcuts import Http404 +from django.urls import reverse_lazy +from django.utils.functional import cached_property + +from ..iiif import IIIFResolver +from ..library.models import ImageROI, Node +from .shortcuts import build_absolute_url + + +def preferred_folio_urn(urn): + """ + # @@@ we've been exposing the CITE urn, but maybe + we should just expose the folio instead + """ + if not urn.startswith("urn:cite2:hmt:msA.v1:"): + return urn + _, ref = urn.rsplit(":", maxsplit=1) + # @@@ hardcoded version + return f"urn:cts:greekLit:tlg0012.tlg001.msA-folios:{ref}" + + +def as_zero_based(int_val): + """ + https://www.w3.org/TR/annotation-model/#model-35 + The relative position of the first Annotation in the items list, relative to the Annotation Collection. The first entry in the first page is considered to be entry 0. + Each Page should have exactly 1 startIndex, and must not have more than 1. The value must be an xsd:nonNegativeInteger. + + JHU seems to be using zero-based pagination too, so we're matching that. + """ + return int_val - 1 + + +def map_dimensions_to_integers(dimensions): + """ + FragmentSelector requires percentages expressed as integers. + + https://www.w3.org/TR/media-frags/#naming-space + """ + int_dimensions = {} + for k, v in dimensions.items(): + int_dimensions[k] = round(v) + return int_dimensions + + +class WebAnnotationGenerator: + def __init__(self, folio_urn, alignment): + self.urn = folio_urn + self.alignment = alignment + self.idx = alignment["idx"] + + @cached_property + def folio_image_urn(self): + folio = Node.objects.get(urn=preferred_folio_urn(self.urn)) + return folio.image_annotations.first().urn + + @property + def greek_lines(self): + return self.alignment["items"][0] + + @property + def english_lines(self): + return self.alignment["items"][1] + + def as_text(self, lines): + return "\n".join([f"{l[0]}) {l[1]}" for l in lines]) + + def as_html(self, lines): + # @@@ this could be rendered via Django if we need fancier HTML + return "
    " + "".join([f"
  • {l[0]}) {l[1]}
  • " for l in lines]) + "
" + + @property + def alignment_urn(self): + # @@@ what if we have multiple alignments covering a single line? + # @@@ we can use the idx, but no too helpful downstream + version_urn = "urn:cts:greekLit:tlg0012.tlg001.perseus-grc2:" + return f'{version_urn}{self.alignment["citation"]}' + + def get_urn_coordinates(self, urns): + # first convert urns to folio exmplar URNS + predicate = Q() + for urn in urns: + _, ref = urn.rsplit(":", maxsplit=1) + predicate.add(Q(urn__endswith=f".{ref}"), Q.OR) + + # retrieve folio exemplar URNs + text_parts = Node.objects.filter( + urn__startswith="urn:cts:greekLit:tlg0012.tlg001.msA-folios:" + ).filter(predicate) + + # @@@ order of these ROIs is really important; do we ensure it? + roi_qs = ImageROI.objects.filter(text_parts__in=text_parts) + if not roi_qs: + # @@@ we should handle this further up the chain; + # this ensures we don't serve a 500 when we're missing + # bounding box data + raise Http404 + coordinates = [] + for roi_obj in roi_qs: + if roi_obj.data["urn:cite2:hmt:va_dse.v1.surface:"] != self.urn: + # @@@ validates that the URNs are found within the current folio + # @@@ prefer we don't look at data to handle that + continue + coords = [float(part) for part in roi_obj.coordinates_value.split(",")] + coordinates.append(coords) + return coordinates + + def get_bounding_box_dimensions(self, coords): + dimensions = {} + y_coords = [] + for x, y, w, h in coords: + dimensions["x"] = min(dimensions.get("x", 100.0), x * 100) + dimensions["y"] = min(dimensions.get("y", 100.0), y * 100) + dimensions["w"] = max(dimensions.get("w", 0.0), w * 100) + y_coords.append(y * 100) + + dimensions["h"] = y_coords[-1] - y_coords[0] + h * 100 + return dimensions + + @cached_property + def common_obj(self): + cite_version_urn = "urn:cts:greekLit:tlg0012.tlg001.msA:" + urns = [] + # @@@ this is a giant hack, would be better to resolve the citation ref + for ref, _, _ in self.greek_lines: + urns.append(f"{cite_version_urn}{ref}") + urn_coordinates = self.get_urn_coordinates(urns) + precise_bb_dimensions = self.get_bounding_box_dimensions(urn_coordinates) + bb_dimensions = map_dimensions_to_integers(precise_bb_dimensions) + + dimensions_str = ",".join( + [ + str(bb_dimensions["x"]), + str(bb_dimensions["y"]), + str(bb_dimensions["w"]), + str(bb_dimensions["h"]), + ] + ) + fragment_selector_val = f"xywh=percent:{dimensions_str}" + + image_urn = self.folio_image_urn + iiif_obj = IIIFResolver(image_urn) + image_api_selector_region = iiif_obj.get_region_by_pct(bb_dimensions) + + return { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "target": [ + self.alignment_urn, + { + "type": "SpecificResource", + "source": {"id": f"{iiif_obj.canvas_url}", "type": "Canvas"}, + "selector": { + "type": "FragmentSelector", + "region": fragment_selector_val, + }, + }, + { + "type": "SpecificResource", + "source": {"id": f"{iiif_obj.identifier}", "type": "Image"}, + "selector": { + "type": "ImageApiSelector", + "region": image_api_selector_region, + }, + }, + iiif_obj.build_image_request_url(region=image_api_selector_region), + ], + } + + def get_textual_bodies(self, body_format): + bodies = [ + {"type": "TextualBody", "language": "el"}, + {"type": "TextualBody", "language": "en"}, + ] + if body_format == "text": + for body, lines in zip(bodies, [self.greek_lines, self.english_lines]): + body["format"] = "text/plain" + body["value"] = self.as_text(lines) + elif body_format == "html": + for body, lines in zip(bodies, [self.greek_lines, self.english_lines]): + body["format"] = "text/plain" + body["value"] = self.as_html(lines) + return bodies + + def get_absolute_url(self, body_format): + url = reverse_lazy( + "serve_web_annotation", + kwargs={"urn": self.urn, "idx": self.idx, "format": body_format}, + ) + return build_absolute_url(url) + + def get_object_for_body_format(self, body_format): + obj = { + "body": self.get_textual_bodies(body_format), + "id": self.get_absolute_url(body_format), + } + obj.update(self.common_obj) + return obj + + @property + def text_obj(self): + return self.get_object_for_body_format("text") + + @property + def html_obj(self): + return self.get_object_for_body_format("html") + + +class WebAnnotationCollectionGenerator: + def __init__(self, urn, alignments, format): + self.alignments = alignments + self.format = format + self.urn = urn + self.item_list = [] + + def append_to_item_list(self, data): + # strip @context key + data.pop("@context", None) + self.item_list.append(data) + + @property + def items(self): + for alignment in self.alignments: + wa = WebAnnotationGenerator(self.urn, alignment) + if self.format == "html": + self.append_to_item_list(wa.html_obj) + elif self.format == "text": + self.append_to_item_list(wa.text_obj) + return self.item_list diff --git a/readhomer_atlas/web_annotation/views.py b/readhomer_atlas/web_annotation/views.py new file mode 100644 index 0000000..cc52f0d --- /dev/null +++ b/readhomer_atlas/web_annotation/views.py @@ -0,0 +1,116 @@ +from django.conf import settings +from django.core.paginator import EmptyPage, Paginator +from django.http import Http404, JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse_lazy +from django.views.decorators.cache import cache_page + +from ..library.models import Node +from .shims import AlignmentsShim +from .shortcuts import build_absolute_url +from .utils import ( + WebAnnotationCollectionGenerator, + WebAnnotationGenerator, + as_zero_based, + preferred_folio_urn, +) + + +PAGE_SIZE = 10 + + +def get_folio_obj(urn): + return get_object_or_404(Node, **{"urn": preferred_folio_urn(urn)}) + + +@cache_page(settings.DEFAULT_HTTP_CACHE_DURATION) +def serve_wa(request, urn, idx, format): + # @@@ query alignments from Postgres + alignment_by_idx = None + alignments = AlignmentsShim(urn).get_alignment_data() + for alignment in alignments: + if alignment["idx"] == idx: + alignment_by_idx = alignment + break + if not alignment_by_idx: + raise Http404 + + wa = WebAnnotationGenerator(urn, alignment) + if format == "text": + return JsonResponse(data=wa.text_obj) + elif format == "html": + return JsonResponse(data=wa.html_obj) + else: + raise Http404 + + +@cache_page(settings.DEFAULT_HTTP_CACHE_DURATION) +def serve_web_annotation_collection(request, urn, format): + get_folio_obj(urn) + # @@@ query alignments from Postgres + alignments = AlignmentsShim(urn).get_alignment_data(fields=["idx"]) + paginator = Paginator(alignments, per_page=PAGE_SIZE) + urls = { + "id": reverse_lazy("serve_web_annotation_collection", args=[urn, format]), + "first": reverse_lazy( + "serve_web_annotation_page", + args=[urn, format, as_zero_based(paginator.page_range[0])], + ), + "last": reverse_lazy( + "serve_web_annotation_page", + args=[urn, format, as_zero_based(paginator.page_range[-1])], + ), + } + data = { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": build_absolute_url(urls["id"]), + "type": "AnnotationCollection", + "label": f"Translation Alignments for {urn}", + "total": paginator.count, + "first": build_absolute_url(urls["first"]), + "last": build_absolute_url(urls["last"]), + } + return JsonResponse(data) + + +@cache_page(settings.DEFAULT_HTTP_CACHE_DURATION) +def serve_web_annotation_page(request, urn, format, zero_page_number): + get_folio_obj(urn) + + # @@@ query alignments from Postgres + alignments = AlignmentsShim(urn).get_alignment_data() + + page_number = zero_page_number + 1 + paginator = Paginator(alignments, per_page=PAGE_SIZE) + try: + page = paginator.page(page_number) + except EmptyPage: + raise Http404 + collection = WebAnnotationCollectionGenerator(urn, page.object_list, format) + urls = { + "id": reverse_lazy( + "serve_web_annotation_page", args=[urn, format, as_zero_based(page_number)] + ), + "part_of": reverse_lazy("serve_web_annotation_collection", args=[urn, format]), + } + data = { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": build_absolute_url(urls["id"]), + "type": "AnnotationPage", + "partOf": build_absolute_url(urls["part_of"]), + "startIndex": as_zero_based(page.start_index()), + "items": collection.items, + } + if page.has_previous(): + prev_url = reverse_lazy( + "serve_web_annotation_page", + args=[urn, format, as_zero_based(page.previous_page_number())], + ) + data["prev"] = build_absolute_url(prev_url) + if page.has_next(): + next_url = reverse_lazy( + "serve_web_annotation_page", + args=[urn, format, as_zero_based(page.next_page_number())], + ) + data["next"] = build_absolute_url(next_url) + return JsonResponse(data)