from dal import autocomplete
from django.db.models import F, Q
from django.http import Http404, HttpResponsePermanentRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.html import strip_tags
from django.views.generic import DetailView, ListView
from django.views.generic.edit import FormMixin
from mep.accounts.models import Event
from mep.accounts.templatetags.account_tags import as_ranges
from mep.books.forms import WorkSearchForm
from mep.books.models import Work
from mep.books.queryset import WorkSolrQuerySet
from mep.common import SCHEMA_ORG
from mep.common.utils import absolutize_url, alpha_pagelabels
from mep.common.views import AjaxTemplateMixin, FacetJSONMixin, \
LabeledPagesMixin, RdfViewMixin, SolrLastModifiedMixin
from mep.footnotes.models import Footnote
[docs]class WorkList(LabeledPagesMixin, SolrLastModifiedMixin, ListView,
FormMixin, AjaxTemplateMixin, FacetJSONMixin, RdfViewMixin):
'''List page for searching and browsing library items.'''
model = Work
page_title = "Books"
page_description = "Search and lending library books by title, author," + \
" or keyword and filter by circulation date."
template_name = 'books/work_list.html'
ajax_template_name = 'books/snippets/work_results.html'
paginate_by = 100
context_object_name = 'works'
rdf_type = SCHEMA_ORG.SearchResultPage
solr_lastmodified_filters = {'item_type': 'work'}
form_class = WorkSearchForm
_form = None
initial = {'sort': 'title'}
#: mappings for Solr field names to form aliases
range_field_map = {
'event_years': 'circulation_dates',
}
#: fields to generate stats on in self.get_ranges
stats_fields = ('event_years',)
# adapted from member list view
[docs] def get_range_stats(self):
"""Return the min and max for fields specified in
:class:`WorkList`'s stats_fields
:returns: Dictionary keyed on form field name with a tuple of
(min, max) as integers. If stats are not returned from the field,
the key is not added to a dictionary.
:rtype: dict
"""
stats = WorkSolrQuerySet().stats(*self.stats_fields).get_stats()
min_max_ranges = {}
if not stats:
return min_max_ranges
for name in self.stats_fields:
try:
min_year = int(stats['stats_fields'][name]['min'])
max_year = int(stats['stats_fields'][name]['max'])
# map to form field name if an alias is provided
min_max_ranges[self.range_field_map.get(name, name)] \
= (min_year, max_year)
# If the field stats are missing, min and max will be NULL,
# rendered as None.
# The TypeError will catch and pass returning an empty entry
# for that field but allowing others to be passed on.
except TypeError:
pass
return min_max_ranges
# map form sort to solr sort
solr_sort = {
'relevance': '-score',
'title': 'sort_title_isort',
'author': 'sort_authors_isort',
'pubdate': '-pub_date_i',
'circulation': '-event_count_i',
'circulation_date': 'first_event_date_i',
}
# NOTE: might be able to infer reverse sort from _desc/_za
# instead of hard-coding here
#: bib data query alias field syntax (configured defaults is edismax)
search_bib_query = '{!qf=$bib_qf pf=$bib_pf v=$bib_query}'
[docs] def get_queryset(self):
# NOTE faceting so that response doesn't register as an error;
# data is currently unused
sqs = WorkSolrQuerySet().facet_field('format', exclude='format')
form = self.get_form()
# empty qs if not valid
if not form.is_valid():
sqs = sqs.none()
# otherwise apply filters, query, sort, etc.
else:
search_opts = form.cleaned_data
if search_opts.get('query', None):
sqs = sqs.search(self.search_bib_query) \
.raw_query_parameters(bib_query=search_opts['query']) \
.also('score') # include relevance score in results
sort_opt = self.solr_sort[search_opts['sort']]
sqs = sqs.order_by(sort_opt)
# when not sorting by title, use title as secondary sort
if self.solr_sort['title'] not in sort_opt:
sqs = sqs.order_by(self.solr_sort['title'])
# range filter by circulation dates, if set
if search_opts['circulation_dates']:
sqs = sqs.filter(
event_years__range=search_opts['circulation_dates'])
self.queryset = sqs
return sqs
[docs] def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
facets = self.object_list.get_facets().get('facet_fields', None)
error_message = ''
# facets are not set if there is an error on the query
if facets:
self._form.set_choices_from_facets(facets)
else:
# if facets are not set, the query errored
error_message = 'Something went wrong.'
context.update({
'page_title': self.page_title,
'page_description': self.page_description,
'error_message': error_message,
'uncertainty_message': Work.UNCERTAINTY_MESSAGE
})
return context
[docs] def get_page_labels(self, paginator):
'''generate labels for pagination'''
# if form is invalid, page labels should show 'N/A'
form = self.get_form()
if not form.is_valid():
return [(1, 'N/A')]
sort = form.cleaned_data['sort']
if sort in ['title', 'author', 'pubdate', 'circulation_date']:
sort_field = self.solr_sort[sort].lstrip('-')
# otherwise, when sorting by alpha, generate alpha page labels
# Only return sort name; get everything at once to avoid
# hitting Solr for each page / item.
pagination_qs = self.queryset.only(sort_field) \
.get_results(rows=100000)
# cast to string so integers (year) can be treated the same
alpha_labels = alpha_pagelabels(
paginator, pagination_qs, lambda x: str(x.get(sort_field, '')),
max_chars=4)
# alpha labels is a dict; use items to return list of tuples
return alpha_labels.items()
# otherwise use default page label logic
return super().get_page_labels(paginator)
[docs] def get_absolute_url(self):
'''Get the full URI of this page.'''
return absolutize_url(reverse('books:books-list'))
[docs] def get_breadcrumbs(self):
'''Get the list of breadcrumbs and links to display for this page.'''
# NOTE we can't set this as an attribute on the view because it calls
# reverse() via get_absolute_url(), which needs the urlconf to be loaded
return [
('Home', absolutize_url('/')),
(self.page_title, self.get_absolute_url())
]
[docs]class WorkLastModifiedListMixin(SolrLastModifiedMixin):
'''last modified mixin with common logic for all work detail views'''
[docs] def get_solr_lastmodified_filters(self):
'''filter solr query by item type and slug'''
# NOTE: slug_s because not using aliased queryset
return {'item_type': 'work', 'slug_s': self.kwargs['slug']}
[docs]class WorkPastSlugMixin:
'''View mixin to handle redirects for previously used slugs.
If the main view logic raises a 404, looks for a work
by past slug; if one is found, redirects to the corresponding
work detail page with the new slug.
'''
[docs] def get(self, request, *args, **kwargs):
'''Handle a 404 on the default GET logic — if the slug matches
a past slug, redirect to the equivalent url for that work; otherwise
raise the 404.'''
try:
return super().get(request, *args, **kwargs)
except Http404:
# if not found, check for a match on a past slug
work = Work.objects.filter(past_slugs__slug=self.kwargs['slug']) \
.first()
# if found, redirect to the correct url for this view
if work:
# patch in the correct slug for use with get absolute url
self.kwargs['slug'] = work.slug
self.object = work # used by member detail absolute url
return HttpResponsePermanentRedirect(self.get_absolute_url())
# otherwise, raise the 404
raise
[docs]class WorkDetail(WorkPastSlugMixin, WorkLastModifiedListMixin,
DetailView, RdfViewMixin):
'''Detail page for a single library book.'''
model = Work
template_name = 'books/work_detail.html'
context_object_name = 'work'
rdf_type = SCHEMA_ORG.ItemPage
[docs] def get_absolute_url(self):
'''Get the full URI of this page.'''
return absolutize_url(self.object.get_absolute_url())
[docs] def get_breadcrumbs(self):
'''Get the list of breadcrumbs and links to display for this page.'''
return [
('Home', absolutize_url('/')),
(WorkList.page_title, WorkList().get_absolute_url()),
(self.object.title, self.get_absolute_url())
]
[docs] def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
description = ''
if self.object.authors:
description = 'By %s' % ','.join(
[a.name for a in self.object.authors])
if self.object.year:
description += ', %s' % self.object.year
description += '. '
# text-only readable version of membership years for meta description
circ_years = strip_tags(as_ranges(self.object.event_years)
.replace('</span>', ',')).rstrip(',')
description += '%d event%s in %s. ' % \
(self.object.event_count,
'' if self.object.event_count == 1 else 's', circ_years)
if self.object.public_notes:
description += self.object.public_notes
context.update({
'page_title': self.object.title,
'page_description': description,
})
return context
[docs]class WorkCirculation(WorkPastSlugMixin, WorkLastModifiedListMixin,
ListView, RdfViewMixin):
'''Display a list of circulation events (borrows, purchases, etc)
for an individual work.'''
model = Event
template_name = 'books/circulation.html'
[docs] def get_queryset(self):
'''Fetch all events associated with this work.'''
return super().get_queryset() \
.filter(work__slug=self.kwargs['slug']) \
.select_related('borrow', 'purchase', 'account', 'edition') \
.prefetch_related('account__persons')
[docs] def get_context_data(self, **kwargs):
# should 404 if invalid work slug
# store work before calling super() so available for breadcrumbs
self.work = get_object_or_404(Work, slug=self.kwargs['slug'])
context = super().get_context_data(**kwargs)
context.update({
'work': self.work,
'page_title': '%s Circulation Activity' % self.work.title
})
return context
[docs] def get_absolute_url(self):
'''Get the full URI of this page.'''
return absolutize_url(reverse('books:book-circ', kwargs=self.kwargs))
[docs] def get_breadcrumbs(self):
'''Get the list of breadcrumbs and links to display for this page.'''
return [
('Home', absolutize_url('/')),
(WorkList.page_title, WorkList().get_absolute_url()),
(self.work.title, absolutize_url(self.work.get_absolute_url())),
('Circulation', self.get_absolute_url())
]
[docs]class WorkCardList(WorkPastSlugMixin, WorkLastModifiedListMixin,
ListView, RdfViewMixin):
'''Card thumbnails for lending card associated with a single library
member.'''
model = Footnote
template_name = 'books/work_cardlist.html'
context_object_name = 'footnotes'
[docs] def get_queryset(self):
# find the associated book; 404 if not found
self.work = get_object_or_404(Work, slug=self.kwargs['slug'])
# find footnotes for events associated with this work
# that have images
return super().get_queryset() \
.on_events() \
.filter(events__work__pk=self.work.pk) \
.filter(image__isnull=False) \
.prefetch_related('content_object', 'image') \
.order_by(F('events__start_date_precision').desc(),
F('events__start_date').asc(nulls_last=True))
# NOTE: sorting by date precision decending (with default nulls first)
# so that full precision dates (null or 7) come before partial dates
[docs] def get_absolute_url(self):
'''Full URI for work card list page.'''
return absolutize_url(reverse('books:book-card-list',
kwargs=self.kwargs))
[docs] def get_breadcrumbs(self):
'''Get the list of breadcrumbs and links to display for this page.'''
return [
('Home', absolutize_url('/')),
(WorkList.page_title, WorkList().get_absolute_url()),
(self.work.title,
absolutize_url(self.work.get_absolute_url())),
('Cards', self.get_absolute_url())
]
[docs] def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
page_title = 'Lending library cards that reference %s' % \
self.work.title
# there should always be at least one card, but handle in
# case of data errors
page_image = None
if self.object_list.count():
page_image = self.object_list.first().image.image
card_count = self.object_list.count()
page_description = '%d card%s' % \
(card_count, 's' if card_count != 1 else '')
context.update({
'work': self.work,
'page_title': page_title,
'page_description': page_description,
'page_iiif_image': page_image
})
return context
[docs]class WorkAutocomplete(autocomplete.Select2QuerySetView):
'''Basic autocomplete lookup, for use with django-autocomplete-light and
:class:`mep.books.models.Work` for borrowing and purchasing events'''
[docs] def get_queryset(self):
'''Get a queryset filtered by query string. Only
searches on title, mep id and notes for now, since that is all
our stub records include.
'''
return Work.objects.filter(
Q(title__icontains=self.q) |
Q(mep_id__icontains=self.q) |
Q(notes__icontains=self.q)
).order_by('title') # meaningful default sort?