import datetime
import json
import logging
from dal import autocomplete
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.core.cache import cache
from django.db.models import Max, Min
from django.http import HttpResponseRedirect, HttpResponse, Http404
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.generic import DetailView, ListView, View
from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView
from djiffy.models import Canvas, get_iiif_url
from haystack.query import SearchQuerySet
from haystack.inputs import Clean, Raw
import requests
from derrida.books.forms import ReferenceSearchForm, InstanceSearchForm, \
SearchForm, SuppressImageForm
from derrida.books.models import Publisher, Language, Instance, Reference, \
DerridaWork, DerridaWorkSection
from derrida.common.utils import absolutize_url
from derrida.common.solr_backend import facet_sort_ignoreaccents
from derrida.interventions.models import Intervention
from derrida.outwork.models import Outwork
logger = logging.getLogger(__name__)
[docs]class PublisherAutocomplete(autocomplete.Select2QuerySetView):
'''Basic publisher autocomplete lookup, for use with
django-autocomplete-light. Restricted to staff only.'''
# NOTE staff restriction applied in url config
[docs] def get_queryset(self):
return Publisher.objects.filter(name__icontains=self.q)
[docs]class LanguageAutocomplete(autocomplete.Select2QuerySetView):
'''Autocomplete lookup for :class:`derrida.books.models.Language`, for use with
django-autocomplete-light. Restricted to staff only.'''
# NOTE staff restriction applied in url config
[docs] def get_queryset(self):
return Language.objects.filter(name__icontains=self.q)
[docs]class InstanceDetailView(DetailView):
''':class:`~django.views.generic.DetailView` for
:class:`~derrida.books.models.Instance`. Returns only Instances that have
digital editions set.'''
model = Instance
slug_field = 'slug'
[docs] def get_queryset(self):
instances = super(InstanceDetailView, self).get_queryset()
return instances.filter(digital_edition__isnull=False)
[docs]class InstanceURIView(DetailView):
'''Generic view for Instance by URI identifier. Redirects
to the best view for that item.'''
model = Instance
def get(self, *args, **kwargs):
# if this instance is a book with a digital edition, redirect
# to the book detail view
# NOTE: not sure why get_object isn't called automatically
self.object = self.get_object()
redirect_url = search_slug = None
found = False
if self.object.digital_edition:
redirect_url = self.object.get_absolute_url()
# 1-for-1 relationship, this is not a see other redirect
found = True
# if this is a section of a book with a digital edition,
# redirect to book detail view, and jump to book section anchor
elif self.object.collected_in and self.object.collected_in.digital_edition:
redirect_url = '{}#sections'.format(
self.object.collected_in.get_absolute_url())
# if this is a book, link to a library search for this item
# (or book section)
if redirect_url is None:
if self.object.item_type == 'Book':
search_slug = self.object.slug
elif self.object.item_type == 'Book Section':
search_slug = self.object.collected_in.slug
if search_slug:
redirect_url = '{}?query={}&is_extant=false'.format(
reverse('books:list'), search_slug)
if redirect_url:
response = HttpResponseRedirect(redirect_url)
# set redirect code to See Other unless redirecting to
# the detail display for *this* item exactly
if not found:
response.status_code = 303
return response
# otherwise: (i.e., for journal articles), there is no meaningful
# view to redirect to, so display a minimal page
# (fall through to template display)
return super().get(*args, **kwargs)
[docs] def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context.update({
'hide_placeholder': True,
'hide_nav': True
})
return context
[docs]class InstanceReferenceDetailView(InstanceDetailView):
template_name = 'books/detail/references.html'
[docs] def get_context_data(self, *args, **kwargs):
context = super(InstanceReferenceDetailView, self)\
.get_context_data(*args, **kwargs)
refs = SearchQuerySet().models(Reference) \
.filter(instance_slug=self.object.slug)
sort = self.request.GET.get('order_by', None)
if sort == 'book_page':
refs = refs.order_by('book_page_sort')
context['order_by'] = 'book_page'
else:
refs = refs.order_by('derridawork_page')
context['references'] = refs
return context
[docs]class InstanceListView(ListView):
# NOTE: haystack includes generic views, but they are not well documented
# and don't seem to work quite right, so sticking with stock django
# class-based views and forms.
model = Instance
form_class = InstanceSearchForm
paginate_by = 16
template_name = 'books/instance_list.html'
form = None
queryset = None
[docs] def get_queryset(self):
sqs = SearchQuerySet().models(self.model)
# restrict to cited books
sqs = sqs.filter(item_type_exact='Book', cited_in='*')
# Note: using item_type_exact to avoid matching book section
# initialize form with user search parameters and form defaults
# preserve as QueryDict to get smart single item/list behavior
form_opts = self.request.GET.copy()
# set default values
for key, val in self.form_class.defaults.items():
# set as list to avoid nested lists
if isinstance(val, list):
form_opts.setlistdefault(key, val)
else:
form_opts.setdefault(key, val)
self.form = self.form_class(form_opts)
# request facet counts and filter for solr
# form handles the solr name for the fields
for facet_field in self.form.facet_fields:
field_values = form_opts.getlist(facet_field, None)
# if the field has a value
if field_values:
# narrow adds to fq but not q and creates a tag to use
# in excluding later
sqs = sqs.narrow(
'{!tag=%s}%s_exact:(%s)' %
(
facet_field,
facet_field,
' OR '.join('"%s"' % val for val in field_values)
)
)
# sort by alpha instead of solr default of count
# facet adds to the list of generate facets but excludes
# so that OR behavior exists for counts within a filter rather
# than and
sqs = sqs.facet('{!ex=%s}%s_exact' % (
facet_field, facet_field), sort='index')
# form shouldn't normally be invalid since no fields are
# required, but cleaned data isn't available until we validate
if self.form.is_valid():
search_opts = self.form.cleaned_data
else:
# fallback to defaults (i.e. sort only)
search_opts = self.form.defaults
# filter solr query based on search options
if search_opts.get('query', None):
sqs = sqs.filter(content=Clean(search_opts['query']))
if search_opts.get('is_annotated', None):
sqs = sqs.filter(is_annotated=search_opts['is_annotated'])
if search_opts.get('is_extant', None):
sqs = sqs.filter(is_extant=search_opts['is_extant'])
# request range facets
# get max/min from database to specify range start & end values
# set the aggregate queries for this particular query and their
# kwarg names as a dictionary
aggregate_queries = {
'work_year_max': Max('work__year'),
'work_year_min': Min('work__year'),
'copyright_year_max': Max('copyright_year'),
'copyright_year_min': Min('copyright_year'),
'print_year_max': Max('print_date'),
'print_year_min': Min('print_date'),
}
# check for a namespaced _ranges variable in Django cache
# return None if not found by default
ranges = cache.get('instance_ranges')
if not ranges:
# NOTE: restricting to cited books currently returns null for copyright
# which breaks the logic here; get a larger range for now
# ranges = Instance.objects.filter(is_extant=True, cited_in__isnull=False) \
ranges = Instance.objects.filter(is_extant=True) \
.aggregate(**aggregate_queries)
# pre-process datetime.date instances to get just
# year as an integer
for field, value in ranges.items():
if isinstance(value, datetime.date):
ranges[field] = value.year
cache.set('instance_ranges', ranges)
# request range facets values and optionally filter ranges
# on configured range facet fields
for range_facet in self.form.range_facets:
start = end = None
# range filter requested in search options
if range_facet in search_opts and search_opts[range_facet]:
start, end = search_opts[range_facet].split('-')
# could have both start and end or just one
# NOTE: haystack includes a range field lookup, but
# it converts numbers to strings, so this is easier
range_filter = '[%s TO %s]' % (start or '*', end or '*')
sqs = sqs.filter(**{range_facet: Raw(range_filter)})
# current range filter becomes start/end if specified
range_opts = {
'start': int(start) if start else ranges['%s_min' % range_facet],
'end': int(end) if end else ranges['%s_max' % range_facet],
}
# calculate gap based start and end & desired number of slices
# ideally, generate 15 slices; minimum gap size of 1
range_opts['gap'] = max(1, int((range_opts['end'] - range_opts['start']) / 15.0))
# restrict last range to *actual* maximum value
range_opts['hardend'] = True
# request the range facet with the specified options
sqs = sqs.facet(range_facet, range=True, **range_opts)
# sort should always be set
if search_opts['order_by']:
# convert sort option to corresponding solr field
solr_sort = self.form.solr_field(search_opts['order_by'])
sqs = sqs.order_by(solr_sort)
# store for retrieving facets in get context data
self.queryset = sqs
return sqs
[docs] def get_context_data(self, **kwargs):
context = super(InstanceListView, self).get_context_data(**kwargs)
facets = facet_sort_ignoreaccents(self.queryset.facet_counts(), 'author')
# update multi-choice fields based on facets in the data
self.form.set_choices_from_facets(facets.get('fields'))
context.update({
'facets': facets, # now includes ranges as facets.ranges
'total': self.queryset.count(),
'form': self.form,
})
return context
[docs]class ReferenceListView(ListView):
# full reference list; eventually will have filter/sort options
model = Reference
form_class = ReferenceSearchForm
paginate_by = 16
template_name = 'books/reference_list.html'
form = None
queryset = None
[docs] def get_queryset(self):
sqs = SearchQuerySet().models(self.model)
# initialize form with user search parameters and form defaults
# preserve as QueryDict to get smart single item/list behavior
form_opts = self.request.GET.copy()
# set default values
for key, val in self.form_class.defaults.items():
# set as list to avoid nested lists
if isinstance(val, list):
form_opts.setlistdefault(key, val)
else:
form_opts.setdefault(key, val)
self.form = self.form_class(form_opts)
# add facet fields to filter query and tag for exclusion in generating
# facets
# form handles the solr name for the fields, but in the lookup below
# if it's a mapped field, i.e. instance_author ->
# instance, then map for the field value lookup (but not for solr fq).
for facet_field in self.form.facet_fields:
form_field = facet_field
if facet_field in self.form.solr_facet_fields:
form_field = self.form.solr_facet_fields[facet_field]
field_values = form_opts.getlist(form_field, None)
# if the field has a value
if field_values:
# narrow adds to fq but not q and creates a tag to use
# in excluding later
sqs = sqs.narrow(
'{!tag=%s}%s_exact:(%s)' %
(
facet_field,
facet_field,
' OR '.join('"%s"' % val for val in field_values)
)
)
# sort by alpha instead of solr default of count
# facet adds to the list of generate facets but excludes
# so that OR behavior exists for counts within a filter rather
# than and
sqs = sqs.facet('{!ex=%s}%s_exact' % (
facet_field, facet_field), sort='index')
# request facet counts and filter for solr
# form shouldn't normally be invalid since no fields are
# required, but cleaned data isn't available until we validate
if self.form.is_valid():
search_opts = self.form.cleaned_data
else:
# fallback to defaults (i.e. sort only)
search_opts = self.form.defaults
# filter solr query based on search options
if search_opts.get('query', None):
sqs = sqs.filter(text=Clean(search_opts['query']))
if search_opts.get('is_extant', None):
sqs = sqs.filter(instance_is_extant=search_opts['is_extant'])
if search_opts.get('is_annotated', None):
sqs = sqs.filter(instance_is_annotated=search_opts['is_annotated'])
if search_opts.get('corresponding_intervention', None):
sqs = sqs.filter(corresponding_intervention=search_opts['corresponding_intervention'])
# request range facets for References, adapated from logic
# above for Instances
# get max/min from database to specify range start & end values
# set the aggregate queries for this particular query and their
# kwarg names as a dictionary
aggregate_queries = {
'instance_work_year_max': Max('instance__work__year'),
'instance_work_year_min': Min('instance__work__year'),
'instance_copyright_year_max': Max('instance__copyright_year'),
'instance_copyright_year_min': Min('instance__copyright_year'),
'instance_print_year_max': Max('instance__print_date'),
'instance_print_year_min': Min('instance__print_date'),
}
# check for a namespaced _ranges variable in Django cache
# return None if not found by default
ranges = cache.get('reference_ranges')
if not ranges:
ranges = Reference.objects.filter(instance__is_extant=True) \
.aggregate(**aggregate_queries)
# pre-process datetime.date instances to get just
# year as an integer
for field, value in ranges.items():
if isinstance(value, datetime.date):
ranges[field] = value.year
cache.set('reference_ranges', ranges)
# request range facets values and optionally filter ranges
# on configured range facet fields
for range_facet in self.form.range_facets:
start = end = None
# range filter requested in search options
if range_facet in search_opts and search_opts[range_facet]:
start, end = search_opts[range_facet].split('-')
# could have both start and end or just one
# NOTE: haystack includes a range field lookup, but
# it converts numbers to strings, so this is easier
range_filter = '[%s TO %s]' % (start or '*', end or '*')
sqs = sqs.filter(**{range_facet: Raw(range_filter)})
# current range filter becomes start/end if specified
range_opts = {
'start': int(start) if start else ranges['%s_min' % range_facet],
'end': int(end) if end else ranges['%s_max' % range_facet],
}
# calculate gap based start and end & desired number of slices
# ideally, generate 15 slices; minimum gap size of 1
range_opts['gap'] = max(1, int((range_opts['end'] - range_opts['start']) / 15.0))
# restrict last range to *actual* maximum value
range_opts['hardend'] = True
# request the range facet with the specified options
sqs = sqs.facet(range_facet, range=True, **range_opts)
# sort should always be set
if search_opts['order_by']:
# convert sort option to corresponding solr field
solr_sort = self.form.solr_field(search_opts['order_by'])
sqs = sqs.order_by(solr_sort)
# store for accessing counts & facets in context data
self.queryset = sqs
return sqs
[docs] def get_context_data(self, **kwargs):
context = super(ReferenceListView, self).get_context_data(**kwargs)
facets = facet_sort_ignoreaccents(self.queryset.facet_counts(), 'instance_author')
# update multi-choice fields based on facets in the data
self.form.set_choices_from_facets(facets.get('fields'))
context.update({
'facets': facets,
'total': self.queryset.count(),
'form': self.form,
})
return context
[docs]class ReferenceHistogramView(ListView):
template_name = 'books/reference_histogram.html'
model = Reference
[docs] def get_queryset(self):
refs = super(ReferenceHistogramView, self).get_queryset()
# sort based on specified mode
# NOTE: eventually this willl need to filter/segment
# on derrida work, when we have more than one.
if self.kwargs.get('mode', None) == 'section':
return refs.order_by_source_page() \
.summary_values()
else:
# including authors results in multiple entries
# for multi-author works, so only include if needed
return refs.order_by_author() \
.summary_values(include_author=True)
[docs] def get_context_data(self):
context = super(ReferenceHistogramView, self).get_context_data()
context.update({
'derrida_works': DerridaWork.objects.all(),
'derridawork_slug': self.kwargs.get('derridawork_slug', None)
})
if self.kwargs.get('mode', None) == 'section':
# get sections for the specified derrida work
sections = DerridaWorkSection.objects \
.filter(derridawork__slug=self.kwargs['derridawork_slug'])
context.update({
'mode': self.kwargs['mode'],
'sections': sections
})
return context
[docs]class ReferenceDetailView(DetailView):
# reference detail view for loading via ajax
model = Reference
ajax_template_name = 'components/citation-list-item.html'
template_name = 'books/reference_detail.html'
[docs] def get_template_names(self):
# when queried via ajax, return partial html for pop-up display
# in the visualization
# (don't render the form or base template)
if self.request.is_ajax():
return self.ajax_template_name
return self.template_name
[docs] def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
# NOTE: this is returning two results for some cases
# (seems to be an error in the data; just return the first match)
return queryset.filter(
derridawork_page=self.kwargs['page'],
derridawork_pageloc=self.kwargs['pageloc'],
derridawork__slug=self.kwargs['derridawork_slug']
).first()
[docs]class SearchView(TemplateView):
form_class = SearchForm
template_name = 'books/multi_search.html'
max_per_type = 3
[docs] def get(self, *args, **kwargs):
'''
Process form for :class:`SearchView`.
'''
# ignore page number when checking if options are set
form_opts = self.request.GET.copy()
try:
del form_opts['page']
except KeyError:
pass
self.form = self.form_class(form_opts or self.form_class.defaults)
# if search on a single type is requested, forward to the
# appropriate view
if self.form.is_valid():
search_opts = self.form.cleaned_data
if search_opts['content_type'] != 'all':
if search_opts['content_type'] == 'book':
url = reverse('books:list')
elif search_opts['content_type'] == 'reference':
url = reverse('books:reference-list')
elif search_opts['content_type'] == 'intervention':
url = reverse('interventions:list')
elif search_opts['content_type'] == 'outwork':
url = reverse('outwork:list')
url = '%s?query=%s' % (url, search_opts['query'])
response = HttpResponseRedirect(url)
response.status_code = 303 # see other
return response
return super(SearchView, self).get(*args, **kwargs)
[docs] def get_context_data(self, **kwargs):
'''Retrieve Solr queries for :class:`SearchView` context.'''
search_opts = self.form.cleaned_data
sqs = SearchQuerySet().filter()
if search_opts['query']:
sqs = sqs.filter(text=search_opts['query'])
# NOTE: Solr supports grouping results in a single search, but
# haystack does not. For now, query each content type separately.
instance_query = sqs.models(Instance).all()
reference_query = sqs.models(Reference).all()
intervention_query = sqs.models(Intervention).all()
outwork_query = sqs.models(Outwork).all()
return {
'query': search_opts['query'],
'instance_list': instance_query[:self.max_per_type],
'instance_count': instance_query.count(),
'reference_list': reference_query[:self.max_per_type],
'reference_count': reference_query.count(),
'intervention_list': intervention_query[:self.max_per_type],
'intervention_count': intervention_query.count(),
'outwork_list': outwork_query[:self.max_per_type],
'outwork_count': outwork_query.count()
}
[docs]class CanvasDetail(DetailView):
model = Canvas
template_name = 'books/public_canvas_detail.html'
[docs] def get_object(self, queryset=None):
'''
Limit canvas detail view to those with
:class:`derrida.interventions.models.Intervention` objects associated.
'''
self.instance = get_object_or_404(Instance, slug=self.kwargs['slug'])
canvas = self.instance.images() \
.filter(short_id=self.kwargs['short_id']).first()
# only show canvas detail page for insertions, overview images,
# and pages with documented interventions
if canvas and Instance.allow_canvas_detail(canvas):
return canvas
else:
raise Http404
[docs] def get_context_data(self, *args, **kwargs):
'''
Set extra context for :class:`CanvasDetail` view.
'''
context = super(CanvasDetail, self).get_context_data(*args, **kwargs)
# If there is a plain_text_url in info, use a Djiffy method to get the
# text from Figgy and pass it to the view, default ocr_text to None
ocr_text = res = None
# Make sure we have a variable to test against in case of a connect error
if self.object.plain_text_url:
# get the text
try:
res = get_iiif_url(self.object.plain_text_url)
except requests.ConnectionError:
# log the stack trace using exception handler and
# provide the url where the error happened to the log
logger.exception('Connection error getting OCR text for %s'
% self.request.get_full_path('?'))
# check that we got a valid response and set ocr_text if so.
if res and res.status_code == 200:
ocr_text = res.text
context.update({
'instance': self.instance,
'canvas_suppressed': self.instance.suppress_all_images or \
self.object in self.instance.suppressed_images.all(),
'ocr_text': ocr_text
})
if self.request.user.has_perm('books.change_instance'):
context.update({
'suppress_form': SuppressImageForm(initial={'canvas_id': self.object.short_id}),
})
return context
[docs]class CanvasSuppress(FormView):
'''Form view to process an admin request to suppress a single
canvas image or all annotated pages from a volume. Requires
user to have change_instance permission.'''
form_class = SuppressImageForm
@method_decorator(permission_required('books.change_instance'))
def dispatch(self, *args, **kwargs):
return super(CanvasSuppress, self).dispatch(*args, **kwargs)
[docs] def get(self, *args, **kwargs):
'''
Return 303 for suppressed canvas and redirect to detail view
for :class:`derria.books.models.Instance`.
'''
# no get display; redirect to book detail
response = HttpResponseRedirect(reverse('books:detail',
kwargs={'slug': self.kwargs['slug']}))
response.status_code = 303 # see other
return response
[docs]class ProxyView(View):
# ProxyView, modeled on Django's RedirectView
# adapted from the Readux codebase (readux.books.views)
[docs] def get(self, request, *args, **kwargs):
'''
Set headers for image requests to :class:`ProxyView`. Ensures
HTTP cache headers are set.
'''
url = self.get_proxy_url(*args, **kwargs)
# use headers to allow browsers to cache downloaded copies
headers = {}
for header in ['HTTP_IF_MODIFIED_SINCE', 'HTTP_IF_UNMODIFIED_SINCE',
'HTTP_IF_MATCH', 'HTTP_IF_NONE_MATCH']:
if header in request.META:
headers[header.replace('HTTP_', '')] = request.META.get(header)
remote_response = requests.get(url, headers=headers)
local_response = HttpResponse()
local_response.status_code = remote_response.status_code
# include response headers, except for server-specific items
for header, value in remote_response.headers.items():
if header not in ['Connection', 'Server', 'Keep-Alive', 'Link']:
# 'Access-Control-Allow-Origin', 'Link']:
# NOTE: link header is valuable, but would
# need to be made relative to current url
local_response[header] = value
# special case, for deep zoom (hack)
if kwargs['mode'] == 'info':
data = remote_response.json()
# need to adjust the id to be relative to current url
# this is a hack, patching in a proxy iiif interface at this url
data['@id'] = absolutize_url(request.path.replace('/info/', '/iiif'),
request)
local_response.content = json.dumps(data)
# upate content-length for change in data
local_response['content-length'] = len(local_response.content)
# needed to allow external site (i.e. jekyll export)
# to use deepzoom
local_response['Access-Control-Allow-Origin'] = '*'
else:
# include response content if any
local_response.content = remote_response.content
return local_response
[docs] def head(self, request, *args, **kwargs):
'''
Proxy HTTP headers for image.
'''
url = self.get_proxy_url(*args, **kwargs)
remote_response = requests.head(url)
response = HttpResponse()
for header, value in remote_response.headers.iteritems():
if header not in ['Connection', 'Server', 'Keep-Alive',
'Access-Control-Allow-Origin', 'Link']:
response[header] = value
return response
[docs]class CanvasImageByPageNumber(View):
'''Get a canvas image from an :class:`~derrida.books.models.Instance`
by page number. Searches by page label, if no match is found returns
the thumbnail for the Item if there is one. 404 if not found or
the Instance has no digital edition associated.'''
[docs] def get(self, request, *args, **kwargs):
'''Return a canvas looked up by page number on GET request.'''
self.instance = get_object_or_404(Instance, slug=self.kwargs['slug'])
# look up canvas for requested page number in this item
page = 'p. %s' % self.kwargs['page_num']
if self.instance.digital_edition:
canvas = self.instance.images().filter(label__exact=page).first()
# if page is not found, fallback to book cover
if not canvas:
canvas = self.instance.digital_edition.thumbnail
# if we have a canvas, redirect to thumbnail image view
if canvas and canvas.short_id:
url_args = {'slug': self.kwargs['slug'],
'short_id': canvas.short_id, 'mode': 'smthumb'}
# only include @2x option when present
if self.kwargs.get('x', None):
url_args['x'] = self.kwargs['x']
canvas_url = reverse('books:canvas-image', kwargs=url_args)
response = HttpResponseRedirect(canvas_url)
response.status_code = 303 # see other
return response
# 404 if no canvas was found
raise Http404
[docs]class CanvasImage(ProxyView):
'''Local view for canvas images. This proxies the
configured IIIF image viewer in order to avoid exposing IIIF image
urls for copyright content and to allow controlled access
to restrict public viewable material to annotated pages,
overview images, and insertions.'''
# Minimum width o/ height is based on requested image size.
# Thumbnail sizes are based on grid layout at maximum;
# calculations based on max column width 52.5, max gutter width 30px
# small thumbnail: 2 columns + 1 gutter = 135 (2x = 270)
SMALL_THUMBNAIL_WIDTH = 135
# large thumbnail: 3 columns + 2 gutters ~=218 (2x = 435)
THUMBNAIL_WIDTH = 218
# large image set by height for display in the browser page: 900/1800px
LARGE_HEIGHT = 900
[docs] def get_proxy_url(self, *args, **kwargs):
'''
Return a proxy url for client browsers to access IIIF images from.
'''
instance = get_object_or_404(Instance, slug=self.kwargs['slug'])
# if instance has no digital edition associated, there are
# no images to be found
if not instance.digital_edition:
raise Http404
canvas_id = self.kwargs.get('short_id', None)
if canvas_id and canvas_id != 'default':
canvas = instance.images() \
.filter(short_id=self.kwargs['short_id']).first()
# if not found or default requested, use designated thumbnail
else:
canvas = instance.digital_edition.thumbnail
if not canvas:
raise Http404
mode = kwargs['mode']
if mode == 'info':
return canvas.image.info()
elif mode == 'iiif':
# also restrict iiif tiles based on large image permission
if not instance.allow_canvas_large_image(canvas):
raise Http404
return canvas.image.info().replace('info.json', kwargs['url'].strip('/'))
# if large image is requested, make sure it is allowed before
# any further processing
if mode == 'large':
# only allow large images for insertions, overview images,
# and pages with documented interventions
# - also checks if an image has been suppressed
if not instance.allow_canvas_large_image(canvas):
raise Http404
# for specific sizes, request image info to determine available
# preset sizes and use the closest size larger than what we need
# (if the server supports it and provides sizes)
if mode in ['thumbnail', 'large', 'smthumb']:
resp = requests.get(canvas.image.info())
available_sizes = resp.json().get('sizes', [])
min_width = min_height = None
if mode == 'smthumb':
# small thumbnail: 2 columns + 1 gutter = 135 (2x = 270)
min_width = self.SMALL_THUMBNAIL_WIDTH
elif mode == 'thumbnail':
# large thumbnail: 3 columns + 2 gutters ~=218 (2x = 435)
min_width = self.THUMBNAIL_WIDTH
elif mode == 'large':
# large image set by height for display in the browser
# page: min-height: 900/1800px
min_height = self.LARGE_HEIGHT
# if 2x is requested, double minimum size
if self.kwargs.get('x', None) == '@2x':
min_width = min_width * 2 if min_width else None
min_height = min_height * 2 if min_height else None
# iterate through available image sizes and use the nearest size
# larger than our minimum
for size in available_sizes:
if min_width and size['width'] >= min_width:
return canvas.image.size(**size)
if min_height and size['height'] >= min_height:
return canvas.image.size(**size)
# if no match was found or sizes are not available, use exact size
if min_width:
return canvas.image.size(width=min_width)
elif min_height:
return canvas.image.size(height=min_height)