import datetime
import json
from dal import autocomplete
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db.models import Q, Max, Min
from django.urls import reverse
from django.views.generic import ListView
from django.views.generic.base import RedirectView
from django.shortcuts import get_object_or_404
from djiffy import views as djiffy_views
from haystack.inputs import Raw
from haystack.query import SearchQuerySet
from derrida.books.models import Language
from derrida.common.solr_backend import facet_sort_ignoreaccents
from derrida.interventions.models import Intervention, Tag, get_default_intervener
from derrida.interventions.forms import InterventionSearchForm
from derrida.people.models import Person
[docs]class TagAutocomplete(autocomplete.Select2QuerySetView):
'''Autocomplete view for :class:`~derrida.intervention.models.Tag`
to use in association with :class:`~derrida.intervention.models.Intervention`'''
[docs] def get_queryset(self):
tags = Tag.objects.filter(name__icontains=self.q)
# if mode is specified, filter tags accordingly
if 'mode' in self.kwargs:
if self.kwargs['mode'] == 'annotation':
tags = tags.for_annotations()
elif self.kwargs['mode'] == 'insertion':
tags = tags.for_insertions()
return tags
[docs]class LoginPermissionRequired(PermissionRequiredMixin):
'''Customization of :class:`django.contrib.auth.mixins.PermissionRequiredMixin`
that redirects to the configured login url if the user is not authenticated,
and raises a 403 Forbidden if they already are authenticated.'''
# NOTE: django provides a raise_exception to raise a 403 rather
# than prompting login, but there is no way to set that for the
# permission without also setting it for the login required check.
# raise_exception = True # raise 403 rather than prompting login
# override handle no permissions to raise 403 *only* if the
# user is not authenticated; otherwise redirect to login page normally
# adapted from https://github.com/brack3t/django-braces/issues/88
[docs] def handle_no_permission(self):
'''Redirect to login url or raise 403.'''
if self.request and self.request.user.is_authenticated():
raise PermissionDenied
return super(LoginPermissionRequired, self).handle_no_permission()
[docs]class ManifestList(LoginPermissionRequired, djiffy_views.ManifestList):
permission_required = 'djiffy.view_manifest'
[docs]class ManifestDetail(LoginPermissionRequired, djiffy_views.ManifestDetail):
permission_required = 'djiffy.view_manifest'
[docs]class CanvasDetail(LoginPermissionRequired, djiffy_views.CanvasDetail):
permission_required = 'djiffy.view_canvas'
[docs] def get_context_data(self, **kwargs):
context = super(CanvasDetail, self).get_context_data(**kwargs)
# pass in list of languages for use in annotator edit form
languages = list(Language.objects.all().values_list('name', flat=True))
# insert a blank option, since language is optional
languages.insert(0, '')
context['languages_js'] = json.dumps(languages)
# pass in default authorized name for Derrida, if he exists, else a
# literal '' for annotator_init.html
default_intervener_pk = get_default_intervener()
context['default_intervener'] = json.dumps('')
if default_intervener_pk:
context['default_intervener'] = json.dumps(
(Person.objects.get(pk=default_intervener_pk)).authorized_name)
return context
[docs]class CanvasAutocomplete(LoginPermissionRequired, djiffy_views.CanvasAutocomplete):
"""Override the default
:class:`~djiffy.views.CanvasAutocomplete.get_queryset()` in order to
allow forms that specify an :class:`~derrida.books.models.Instance`
object to filter based on annotations associated only
with that instance.
This lets instances of :class:`~django.forms.ModelForm` that have a
:class:`~djiffy.models.Canvas` autocomplete pass a set instance
value to restrict autocomplete results only to the Instance currently
being edited.
:class:`dal.autocomplete.Select2QuerySetView` allows a ``forward``
parameter that passes JSON object as a string after as a query string
named ``forward``.
:meth:`dal.autocomplete.Select2QuerySetView.forwarded.get()` can
access those variables easily.
The autocomplete looks for an instance primary key passed with the key
``instance`` in the JSON object.
"""
permission_required = 'djiffy.view_canvas'
[docs] def get_queryset(self):
query = super(CanvasAutocomplete, self).get_queryset()
# Add an extra filter based on the forwarded value of 'instance',
# if provided
instance = self.forwarded.get('instance', None)
if instance:
query = query.filter(manifest__instance__pk=instance)
# filter on manifest id if present
manifest_id = self.forwarded.get('manifest', None)
if manifest_id:
query = query.filter(manifest__pk=manifest_id)
return query
[docs]class InterventionAutocomplete(LoginPermissionRequired, autocomplete.Select2QuerySetView):
"""Provides autocomplete to search on several fields of
:class:`~derrida.books.models.Intervention` and filter by an instance
primary key provided by a form.
This lets instances of :class:`~django.forms.ModelForm` that have a
:class:`~derrida.models.Intervention` autocomplete pass a set instance
value to restrict autocomplete results only to the Instance currently
being edited.
:class:`dal.autocomplete.Select2QuerySetView` allows a ``forward``
parameter that passes JSON object as a string after as a querystring
named ``forward``.
:meth:`dal.autocomplete.Select2QuerySetView.forwarded.get()` can
access those variables easily.
The autocomplete looks for an instance primary key passed with the key
``instance``.
"""
permission_required = 'annotator_store.view_annotation'
[docs] def get_queryset(self):
interventions = Intervention.objects.all()
if self.q:
# Filter by quote, translations, languages, or (exact) tags
interventions = interventions.filter(
Q(quote__icontains=self.q) |
Q(text__icontains=self.q) |
Q(text_translation__icontains=self.q) |
Q(text_language__name__icontains=self.q) |
Q(quote_language__name__icontains=self.q) |
Q(tags__name__in=[self.q.lower()])
)
instance = self.forwarded.get('instance', None)
if instance:
interventions = interventions.filter(
canvas__manifest__instance__pk=instance
)
return interventions
[docs]class InterventionListView(ListView):
# NOTE: adapted directly from derrida.books.views.InstanceListView
# (probably could be generalized into a haystack faceted list view)
model = Intervention
form_class = InterventionSearchForm
paginate_by = 16
template_name = 'interventions/intervention_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)
# request facet counts and filter for solr
# 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')
# 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=search_opts['query'])
# 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 = {
'item_work_year_max': Max('canvas__manifest__instance__work__year'),
'item_work_year_min': Min('canvas__manifest__instance__work__year'),
'item_copyright_year_max': Max('canvas__manifest__instance__copyright_year'),
'item_copyright_year_min': Min('canvas__manifest__instance__copyright_year'),
'item_print_year_max': Max('canvas__manifest__instance__print_date'),
'item_print_year_min': Min('canvas__manifest__instance__print_date'),
}
# check for a namespaced _ranges variable in Django cache
# returns None if not found by default
ranges = cache.get('intervention_ranges')
if not ranges:
ranges = Intervention.objects.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('intervention_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'])
# since primary sort is by book author or title,
# always include secondary sort by annotated page
sqs = sqs.order_by(solr_sort, 'annotated_page')
# save for access in context data
self.queryset = sqs
return sqs
[docs] def get_context_data(self, **kwargs):
context = super(InterventionListView, self).get_context_data(**kwargs)
facets = facet_sort_ignoreaccents(self.queryset.facet_counts(), 'item_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 InterventionView(RedirectView):
'''View for a single intervention, so we can provide a URI for identifiers
in datasets that resolve to something meaningful. Currently redirects
to the public canvas view with the annotation highlighted if possible,
or an intervention search for that item if the intervention
is not associated with a canvas.
'''
[docs] def get(self, *args, **kwargs):
'''Patch the response to set status code to 303 See Other'''
response = super().get(*args, **kwargs)
response.status_code = 303
return response
[docs] def get_redirect_url(self, *args, **kwargs):
intervention = get_object_or_404(Intervention, id=kwargs['id'])
if intervention.canvas:
# link to public canvas view with the intervention selected
return '{}#annotations/{}'.format(
reverse('books:canvas-detail', kwargs={
'slug': intervention.canvas.manifest.instance.slug,
'short_id': intervention.canvas.short_id}),
intervention.id)
# if for some reason we have an intervention without a canvas
# (not likely in current set but possible), link to intervention
# search for this item
return '{}?query={}'.format(
reverse('interventions:list'), intervention.id)