Source code for djiiif.views

"""Drop-in views that serve IIIF documents for stored images.

Mounting :data:`djiiif.urls.urlpatterns` turns a Django instance into a minimal
provider of two documents built from a stored image: an Image API ``info.json``
(:func:`serve_info_json`) and a Presentation API ``manifest``
(:func:`serve_manifest`). Each maps an identifier back to a stored image, reads
its dimensions, and returns the document built by the corresponding
:mod:`djiiif` builder. Neither serves derivative pixels — only the JSON
documents.

Two settings-driven views need no stored image: :func:`serve_collection` renders
a browsable Collection of manifest references, and :func:`serve_activity_collection`
/ :func:`serve_activity_page` render a paged IIIF Change Discovery activity stream
so aggregators can harvest what changed. :func:`serve_annotation_page` serves a
W3C AnnotationPage (transcriptions/OCR/commentary) and :func:`serve_search`
answers IIIF Content Search 2.0 queries; both draw from project-owned backend
callables, and :func:`serve_manifest` advertises them when configured.
"""

from urllib.parse import unquote

from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.core.files.images import get_image_dimensions
from django.core.files.storage import default_storage
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404, JsonResponse
from django.utils.module_loading import import_string

from djiiif import (
    build_activity,
    build_annotation_page,
    build_collection,
    build_collection_page,
    build_info_document,
    build_manifest,
    build_ordered_collection,
    build_search_response,
    build_search_service,
    resolve_activity,
    resolve_annotation,
    resolve_info,
    urljoin,
)
from djiiif.geo import resolve_navplace


def _load_dimensions(identifier: str) -> tuple[str, int, int]:
    """Resolve an encoded identifier to its storage name and pixel dimensions.

    Args:
        identifier: The encoded identifier segment captured by the URLconf.

    Returns:
        A ``(name, width, height)`` tuple, where ``name`` is the decoded storage
        name.

    Raises:
        Http404: If the file is missing, outside storage, or not a readable
            image.
    """
    name = unquote(identifier)

    try:
        image = default_storage.open(name)
    except (FileNotFoundError, SuspiciousOperation, ValueError) as exc:
        raise Http404("No IIIF image for that identifier.") from exc

    try:
        width, height = get_image_dimensions(image)
    finally:
        image.close()

    if not width or not height:
        raise Http404("Identifier does not resolve to a readable image.")

    return name, width, height


def _ld_json(document: dict) -> JsonResponse:
    """Wrap a document in a JSON-LD response with the IIIF CORS header.

    Args:
        document: The IIIF document to serialize.

    Returns:
        A ``JsonResponse`` with the ``application/ld+json`` content type and a
        permissive ``Access-Control-Allow-Origin`` header that IIIF clients
        require.
    """
    response = JsonResponse(document, content_type="application/ld+json")
    response["Access-Control-Allow-Origin"] = "*"
    return response


[docs] def serve_info_json(request, identifier): """Serve the ``info.json`` document for a stored image. The document's ``id`` is taken from the request URL (minus the ``/info.json`` suffix) so it always matches the URL the document is served from, as the spec requires. When ``settings.IIIF_INFO`` is configured, its declarative extras are threaded in; a per-image ``IIIF_INFO`` callable receives the **decoded storage name** here (there is no field file on the view path — see :func:`djiiif.resolve_info`). Args: request: The incoming ``HttpRequest``. identifier: The encoded identifier segment captured by the URLconf. Returns: A JSON-LD ``JsonResponse`` carrying the ``info.json`` document. Raises: Http404: If the identifier does not resolve to a readable image. """ name, width, height = _load_dimensions(identifier) id_url = request.build_absolute_uri(request.path).rsplit("/info.json", 1)[0] return _ld_json(build_info_document(id_url, width, height, extras=resolve_info(name)))
[docs] def serve_manifest(request, identifier): """Serve a single-image Presentation API manifest for a stored image. The image service base URI is the request URL minus the ``/manifest`` suffix, matching the identifier the ``info.json`` view serves. The manifest label defaults to the file's base name. When ``IIIF_ANNOTATIONS_BACKEND`` is configured, the canvas gains an ``annotations`` reference to this image's AnnotationPage; when a search backend is available (a dedicated ``IIIF_SEARCH_BACKEND`` or the annotations fallback), the manifest advertises a ``SearchService2``. Both are view-only — ``IIIFObject.manifest`` (no request in scope) emits neither. When ``IIIF_NAVPLACE`` is configured its callable receives the decoded storage name here (no field file on the view path) and its geometry is emitted as ``navPlace``. Args: request: The incoming ``HttpRequest``. identifier: The encoded identifier segment captured by the URLconf. Returns: A JSON-LD ``JsonResponse`` carrying the manifest document. Raises: Http404: If the identifier does not resolve to a readable image. """ name, width, height = _load_dimensions(identifier) id_url = request.build_absolute_uri(request.path).rsplit("/manifest", 1)[0] label = name.rsplit("/", 1)[-1] manifest = build_manifest(id_url, width, height, label=label, nav_place=resolve_navplace(name)) if _resolve_backend("IIIF_ANNOTATIONS_BACKEND") is not None: manifest["items"][0]["annotations"] = [ {"id": urljoin([id_url, "annotations", "1"]), "type": "AnnotationPage"} ] if _search_available(): manifest["service"] = [build_search_service(urljoin([id_url, "search"]))] return _ld_json(manifest)
[docs] def serve_collection(request): """Serve a IIIF Collection of manifest references. Driven by the ``IIIF_COLLECTION_SOURCE`` setting — a callable returning an iterable of member entries (``(manifest_url, label[, thumbnail])`` tuples or preformed member dicts, per :func:`djiiif.build_collection`) and, optionally, a ``label`` for the collection itself. When the setting is unset the endpoint does not exist, so it returns ``404``. The collection's ``id`` is the request URL, so it always matches where it is served from. The response reads no image storage — it references manifests by URL only. Args: request: The incoming ``HttpRequest``. Returns: A JSON-LD ``JsonResponse`` carrying the Collection document. Raises: Http404: If ``IIIF_COLLECTION_SOURCE`` is unset. """ source = getattr(settings, "IIIF_COLLECTION_SOURCE", None) if source is None: raise Http404("No IIIF collection is configured.") label = getattr(settings, "IIIF_COLLECTION_LABEL", "Collection") id_url = request.build_absolute_uri(request.path).rstrip("/") items = source() if callable(source) else source return _ld_json(build_collection(id_url, list(items), label=label))
def _activity_paginator() -> Paginator: """Resolve ``IIIF_ACTIVITY_SOURCE`` into a :class:`~django.core.paginator.Paginator`. The setting may be a dotted-path string (imported here), a callable object, or a direct iterable; a callable is invoked to obtain the iterable. A queryset or list is paginated in place (querysets slice lazily in the DB); a bare generator, which cannot be sliced, is materialized to a list first. Returns: A ``Paginator`` over the activity entries, page size from ``IIIF_ACTIVITY_PAGE_SIZE`` (default 100). Raises: Http404: If ``IIIF_ACTIVITY_SOURCE`` is unset (the stream is opt-in). """ source = getattr(settings, "IIIF_ACTIVITY_SOURCE", None) if source is None: raise Http404("No IIIF activity stream is configured.") if isinstance(source, str): source = import_string(source) entries = source() if callable(source) else source if not hasattr(entries, "__getitem__"): entries = list(entries) page_size = getattr(settings, "IIIF_ACTIVITY_PAGE_SIZE", 100) return Paginator(entries, page_size) def _activity_base_url(request, suffix: str) -> str: """Return the ``/iiif/activity`` base URL, stripping a route ``suffix``. Args: request: The incoming ``HttpRequest``. suffix: The trailing route segment to remove (``"/collection"`` or ``"/page/<n>"``). Returns: The absolute base URL shared by the collection and page routes. """ return request.build_absolute_uri(request.path).rsplit(suffix, 1)[0]
[docs] def serve_activity_collection(request): """Serve the Change Discovery ``OrderedCollection`` entry point. Driven by ``IIIF_ACTIVITY_SOURCE`` (see :func:`_activity_paginator`). The collection's ``id`` is the request URL; ``first``/``last`` point at the page routes. Reads no image storage. Args: request: The incoming ``HttpRequest``. Returns: A JSON-LD ``JsonResponse`` carrying the ``OrderedCollection``. Raises: Http404: If ``IIIF_ACTIVITY_SOURCE`` is unset. """ paginator = _activity_paginator() base = _activity_base_url(request, "/collection") id_url = request.build_absolute_uri(request.path) return _ld_json( build_ordered_collection( id_url, paginator.count, f"{base}/page/1", f"{base}/page/{paginator.num_pages}", ) )
[docs] def serve_activity_page(request, page): """Serve one Change Discovery ``OrderedCollectionPage``. The page's activities are the source entries for this slice, resolved via :func:`djiiif.resolve_activity` and built with :func:`djiiif.build_activity`, kept in the source's ascending-``endTime`` order (the source owns that contract; djiiif does not re-sort). ``prev``/``next`` are emitted at the interior boundaries only. Args: request: The incoming ``HttpRequest``. page: The 1-based page number (captured by the ``<int:page>`` route). Returns: A JSON-LD ``JsonResponse`` carrying the ``OrderedCollectionPage``. Raises: Http404: If ``IIIF_ACTIVITY_SOURCE`` is unset or ``page`` is out of range. """ paginator = _activity_paginator() try: page_obj = paginator.page(page) except InvalidPage as exc: raise Http404("No such activity page.") from exc base = _activity_base_url(request, f"/page/{page}") page_url = request.build_absolute_uri(request.path) prev_url = f"{base}/page/{page - 1}" if page_obj.has_previous() else None next_url = f"{base}/page/{page + 1}" if page_obj.has_next() else None activities = [] for entry in page_obj.object_list: resolved = resolve_activity(entry) activities.append( build_activity( resolved["object_id"], resolved["end_time"], activity_type=resolved["type"], object_type=resolved["object_type"], ) ) return _ld_json( build_collection_page( page_url, f"{base}/collection", activities, prev_url=prev_url, next_url=next_url, start_index=paginator.per_page * (page - 1), ) )
def _resolve_backend(setting_name: str): """Resolve a backend setting to a callable, or ``None`` when unset. Args: setting_name: The setting to read (``IIIF_ANNOTATIONS_BACKEND`` / ``IIIF_SEARCH_BACKEND``). Returns: The backend callable (importing a dotted-path string), or ``None`` if the setting is unset. """ backend = getattr(settings, setting_name, None) if backend is None: return None if isinstance(backend, str): backend = import_string(backend) return backend def _search_available() -> bool: """Return whether search can be answered (a dedicated backend or the fallback).""" return ( _resolve_backend("IIIF_SEARCH_BACKEND") is not None or _resolve_backend("IIIF_ANNOTATIONS_BACKEND") is not None ) def _canvas_id_for(request, suffix: str) -> tuple[str, str]: """Return ``(id_url, canvas_id)`` for an annotation/search request. Args: request: The incoming ``HttpRequest``. suffix: The trailing route segment to strip (``"/annotations/1"`` or ``"/search"``) to recover the image service base URI. Returns: The image service base URI and the ``{id_url}/canvas/1`` URI that a served manifest targets. """ id_url = request.build_absolute_uri(request.path).rsplit(suffix, 1)[0] return id_url, urljoin([id_url, "canvas", "1"])
[docs] def serve_annotation_page(request, identifier): """Serve a W3C ``AnnotationPage`` for a stored image. Draws its annotations from the ``IIIF_ANNOTATIONS_BACKEND`` callable ``(identifier, request) -> iterable`` of :class:`~djiiif.Annotation` / ``dict`` entries. The annotations target ``{id_url}/canvas/1``, matching the canvas :func:`serve_manifest` emits. Reads no image storage. Args: request: The incoming ``HttpRequest``. identifier: The encoded identifier segment captured by the URLconf. Returns: A JSON-LD ``JsonResponse`` carrying the ``AnnotationPage``. Raises: Http404: If ``IIIF_ANNOTATIONS_BACKEND`` is unset. """ backend = _resolve_backend("IIIF_ANNOTATIONS_BACKEND") if backend is None: raise Http404("No IIIF annotations backend is configured.") _id_url, canvas_id = _canvas_id_for(request, "/annotations/1") page_url = request.build_absolute_uri(request.path) items = list(backend(identifier, request)) return _ld_json(build_annotation_page(page_url, canvas_id, items))
def _substring_hits(backend, identifier, q, request, canvas_id: str) -> list[dict]: """Filter an annotations backend to a case-insensitive substring match on ``q``. The free-search fallback: annotations from ``IIIF_ANNOTATIONS_BACKEND`` carry no ``canvas_id`` (their canvas is implied by the page URL), so each match is given the served manifest's ``canvas_id`` to locate the hit. Args: backend: The resolved annotations backend callable. identifier: The encoded identifier passed to the backend. q: The query string (matched case-insensitively as a substring). request: The incoming ``HttpRequest`` passed to the backend. canvas_id: The canvas URI to attach to matching hits. Returns: The matching hits as resolved annotation dicts. """ needle = q.lower() hits = [] for entry in backend(identifier, request): ann = resolve_annotation(entry) text = ann["text"] if isinstance(text, str) and needle in text.lower(): if ann["canvas_id"] is None: ann["canvas_id"] = canvas_id hits.append(ann) return hits