from collections import OrderedDict
import json
import os.path
import urllib
from functools import cached_property
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.templatetags.static import static
from django.utils.html import format_html
from piffle import image as iiif
from piffle import presentation
import rdflib
from rdflib.namespace import DC, SKOS, RDFS
import requests
from requests.exceptions import ConnectionError
[docs]
def get_iiif_url(url):
"""Wrapper around :meth:`requests.get` to support conditionally
adding an auth token based on the domain of the request url and
any **AUTH_TOKENS** configured in django settings."""
request_options = {}
AUTH_TOKENS = getattr(settings, "DJIFFY_AUTH_TOKENS", None)
if AUTH_TOKENS:
domain = urllib.parse.urlparse(url).netloc
if domain in AUTH_TOKENS:
request_options["params"] = {"auth_token": AUTH_TOKENS[domain]}
return requests.get(url, **request_options)
[docs]
class IIIFPresentation(presentation.IIIFPresentation):
"""Extend iiif presentation class to add support for auth tokens
when making requests on iiif urls."""
[docs]
@classmethod
def get_iiif_url(cls, url):
return get_iiif_url(url)
[docs]
class Manifest(models.Model):
"""Minimal db model representation of an IIIF presentation manifest"""
#: label
label = models.TextField()
#: short id extracted from URI
short_id = models.CharField(max_length=255, unique=True)
#: URI
uri = models.URLField()
#: iiif presentation metadata for display
metadata = models.JSONField(default=dict)
#: date local manifest cache was created
created = models.DateField(auto_now_add=True)
#: date local manifest cache was last modified
last_modified = models.DateField(auto_now=True)
#: extra data provided via a 'seeAlso' reference
extra_data = models.JSONField(default=dict)
class Meta:
verbose_name = "IIIF Manifest"
# add custom permissions; change and delete provided by django
permissions = (("view_canvas", "Can view %s" % verbose_name),)
# todo: metadata? thumbnail references
# - should we cache the actual manifest file?
# TODO: thumbnail doesn't have to be a IIIF image! Support thumbnail url?
def __str__(self):
return self.label or self.short_id
@property
def thumbnail(self):
"""thumbnail url for associated canvas"""
return self.canvases.filter(thumbnail=True).first()
[docs]
def get_absolute_url(self):
"""'url for this manifest within the django site"""
return reverse("djiffy:manifest", args=[self.short_id])
[docs]
def admin_thumbnail(self):
"""thumbnail for convenience display in admin interface"""
if self.thumbnail:
return self.thumbnail.admin_thumbnail()
admin_thumbnail.short_description = "Thumbnail"
@cached_property
def logo(self):
"""manifest logo, if there is one"""
return self.extra_data.get("logo", None)
@cached_property
def attribution(self):
"""manifest attribution, if there is one"""
return self.extra_data.get("attribution", None)
@cached_property
def license(self):
"""manifest license, if there is one"""
return self.extra_data.get("license", None)
@cached_property
def license_uri(self):
"""manifest license as :class:`rdflib.URIRef`, if there is a license"""
license = self.license
if license:
# CC uri is also http rather than https
if urllib.parse.urlparse(license).hostname == "creativecommons.org":
# remove language from url if present
url_parts = license.rstrip("/").split("/")
# url looks like https://creativecommons.org/publicdomain/mark/1.0/deed.de
# if the last part is a language code, remove it
if url_parts[-1].startswith("deed."):
url_parts = url_parts[:-1]
license = "%s/" % "/".join(url_parts) # URI requires trailing slash
return rdflib.URIRef(license)
@cached_property
def rights_statement_id(self):
"""short id for rightstatement.org license"""
# rightstatement uri is http, not https
if (
self.license
and urllib.parse.urlparse(self.license).hostname == "rightsstatements.org"
):
return self.license.rstrip(" /").split("/")[-2]
@cached_property
def creativecommons_id(self):
"""short id for creative commons license"""
if (
self.license
and urllib.parse.urlparse(self.license).hostname == "creativecommons.org"
):
if "publicdomain/zero/" in self.license:
return "cc-zero"
if "publicdomain/mark/" in self.license:
return "publicdomain"
else:
# strip last slash then split; last is version, preceding is code
return self.license.rstrip("/").split("/")[-2]
@cached_property
def license_image(self):
"""license image, if we can generate one"""
if self.rights_statement_id:
return static("img/rightsstatements_org/%s.svg" % self.rights_statement_id)
if self.creativecommons_id:
return static("img/creativecommons/%s.svg" % self.creativecommons_id)
_rights_graph = None
# TODO: should use django current language if possible
[docs]
def license_label(self, lang="en"):
"""Get the text label for the rights license. Uses local
value from edm rights if available; otherwise uses
data for the URI to get the preferred label or title."""
# Some manifests have a seeAlso data contains an "edm_rights"
# section with a label for the rights statement.
# Use that if available (NOTE: ignores specified language)
# NOTE: possibly PUL specific, but shouldn't hurt to look locally first
for data in self.extra_data.values():
if "edm_rights" in data and "pref_label" in data["edm_rights"]:
return data["edm_rights"]["pref_label"]
# if license/rights label is not available locally, get via uri
if self._rights_graph is None:
# if license is defined and a url
if self.license and urllib.parse.urlparse(self.license).scheme in [
"http",
"https",
]:
self._rights_graph = rdflib.Graph()
url_hostname = urllib.parse.urlparse(self.license).hostname
try:
# rights statement org does content-negotiation for json-jd,
# but rdflib doesn't handle that automatically
if url_hostname == "rightsstatements.org":
resp = requests.get(
self.license,
headers={"Accept": "application/json"},
allow_redirects=False,
)
if resp.status_code == requests.codes.see_other:
self._rights_graph.parse(
resp.headers["location"], format="json-ld"
)
# creative commons doesn't support content negotiation,
# but you can add rdf to the end of the url
elif url_hostname == "creativecommons.org":
# license uri removes language if present and adds trailing slash
self._rights_graph.parse("%srdf" % self.license_uri)
except Exception:
# possible to get an exception when parsing the
# rdf, maybe on the request; don't choke if we do!
# NOTE: using generic Exception here becuase unfortunately
# that is what rdflib raises when it can't parse RDF
pass
# get the preferred label for this license in the requested language;
# returns a list of label, value; use the first value
if self._rights_graph:
return preferredLabel(self._rights_graph, self.license_uri, lang=lang)
[docs]
def preferredLabel(graph, uri, lang):
"""Get label or title from rdf graph for given uri with specified language.
Checks for SKOS.prefLabel, RDFS.label, DC.title in that order.
"""
for predicate in [SKOS.prefLabel, RDFS.label, DC.title]:
for value in graph.objects(uri, predicate):
if value.language is None or value.language == lang:
return str(value)
[docs]
class IIIFImage(iiif.IIIFImageClient):
"""Subclass of :class:`piffle.iiif.IIIFImageClient`, for generating
IIIF Image URIs for manifest canvas images."""
#: long edge size for single page display
single_page_size = 1000
#: long edge size for thumbnail
thumbnail_size = 300
#: long edge size for mini thumbnail
mini_thumbnail_size = 100
thumbnail_format = getattr(settings, "DJIFFY_THUMBNAIL_FORMAT", "png")
[docs]
def thumbnail(self):
"""thumbnail"""
return self.size(
height=self.thumbnail_size, width=self.thumbnail_size, exact=True
).format(self.thumbnail_format)
[docs]
def mini_thumbnail(self):
"""mini thumbnail"""
return self.size(
height=self.mini_thumbnail_size, width=self.mini_thumbnail_size, exact=True
).format(self.thumbnail_format)
[docs]
def page_size(self):
"""page size for display: :attr:`SINGLE_PAGE_SIZE` on the long edge"""
return self.size(
height=self.single_page_size, width=self.single_page_size, exact=True
)
[docs]
class Canvas(models.Model):
"""Minimal db model representation of a canvas from an IIIF manifest"""
#: label
label = models.TextField()
#: short id extracted from URI
short_id = models.CharField(max_length=255)
#: URI
uri = models.URLField()
#: URL of IIIF image for this canvas
iiif_image_id = models.URLField()
#: :class:`Manifest` this canvas vbelongs to
manifest = models.ForeignKey(
Manifest, related_name="canvases", on_delete=models.CASCADE
)
#: boolean flag to indicate if this canvas should be used as thumbnail
thumbnail = models.BooleanField(default=False)
#: order of this canvas within associated manifest primary sequence
order = models.PositiveIntegerField()
# (for now only stores a single sequence, so just store order on the page)
# format? size? (ocr text eventually?)
#: extra data not otherwise given its own field, serialized as json
extra_data = models.JSONField(default=dict)
class Meta:
ordering = ["manifest", "order"]
verbose_name = "IIIF Canvas"
verbose_name_plural = "IIIF Canvases"
unique_together = ("short_id", "manifest")
# add custom permissions; change and delete provided by django
permissions = (("view_manifest", "Can view %s" % verbose_name),)
def __str__(self):
return "%s %d (%s)%s" % (
self.manifest,
self.order + 1,
self.label,
"*" if self.thumbnail else "",
)
@property
def image(self):
"""Associated IIIF image for this canvas as :class:`IIIFImage`"""
# NOTE: piffle iiif image wants service & id split out.
# Should update to handle iiif image ids as provided in manifests
# for now, split into service and image id. (is this reliable?)
return IIIFImage(*self.iiif_image_id.rsplit("/", 1))
@property
def plain_text_url(self):
"""Return plain text url for a canvas if one exists"""
rendering = self.extra_data.get("rendering", None)
if rendering:
# handle both cases where this is a list and where it is just
# a dictionary, to be safe
if isinstance(rendering, list):
for item in rendering:
# iterate over the list and return the first plain text url
# we find
if "format" in item and item["format"] == "text/plain":
return item["@id"]
else:
# otherwise, if it's a dictionary, check if it's plaintext and
# return
if "format" in rendering and rendering["format"] == "text/plain":
return rendering["@id"]
# finally return None if no plain text is available or no rendering
return None
@property
def width(self):
return self.extra_data.get("width", None)
@property
def height(self):
return self.extra_data.get("height", None)
[docs]
def get_absolute_url(self):
"""'url for this canvas within the django site"""
return reverse("djiffy:canvas", args=[self.manifest.short_id, self.short_id])
[docs]
def next(self):
"""Next canvas after this one in sequence (within manifest
primary sequence). Returns an empty queryset if there is no next
canvas."""
return Canvas.objects.filter(
manifest=self.manifest, order__gt=self.order
).first()
[docs]
def prev(self):
"""Previous canvas before this one in sequence
(within manifest primary sequence). Returns an empty queryset
if there is no next canvas."""
return Canvas.objects.filter(
manifest=self.manifest, order__lt=self.order
).last()
[docs]
def admin_thumbnail(self):
"""thumbnail for convenience display in admin interface"""
return format_html('<img src="{}" />', self.image.mini_thumbnail())
admin_thumbnail.short_description = "Thumbnail"