from attrdict import AttrDict
from annotator_store.models import BaseAnnotation, AnnotationQuerySet
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.urls import reverse
from djiffy.models import Canvas
from derrida.books.models import Language
from derrida.common.models import Named, Notable
from derrida.common.utils import absolutize_url
from derrida.people.models import Person
#: intervention type codes to distinguish annotations and insertions
INTERVENTION_TYPES = AttrDict({
'ANNOTATION': 'A',
'INSERTION': 'I',
'BOTH': 'AI',
})
[docs]def get_default_intervener():
"""Function to either return the pk of a :class:`~derrida.people.models.Person`
object representing Jacques Derrida if he exists in the database or None"""
try:
return (Person.objects.get(authorized_name='Derrida, Jacques')).pk
except ObjectDoesNotExist:
return None
[docs]class TagQuerySet(models.QuerySet):
'''Custom :class:`~django.db.models.QuerySet` for :class:`Tag` to
make it easy to find tags that apply to a particular kind of
Intervention.'''
[docs] def for_annotations(self):
'''Find tags that apply to annotations'''
return self.filter(applies_to__contains=INTERVENTION_TYPES.ANNOTATION)
[docs] def for_insertions(self):
'''Find tags that apply to insertions'''
return self.filter(applies_to__contains=INTERVENTION_TYPES.INSERTION)
[docs]class Tag(Named, Notable):
APPLIES_TO_CHOICES = (
(INTERVENTION_TYPES.ANNOTATION, 'Annotations only'),
(INTERVENTION_TYPES.INSERTION, 'Insertions only'),
(INTERVENTION_TYPES.BOTH, 'Both Annotations and Insertions'),
)
applies_to = models.CharField(max_length=2, choices=APPLIES_TO_CHOICES,
default=INTERVENTION_TYPES.BOTH,
help_text='Type or types of interventions this tag is applicable to.')
objects = TagQuerySet.as_manager()
[docs]class InterventionQuerySet(AnnotationQuerySet):
[docs] def sorted_by_page_loc(self):
'''
Return a list of :class:`~derrida.interventions.models.Intervention`
objects sorted by their y value on the page.
'''
def sort_y(item):
# assume zero if not present
y_percent = item.extra_data.get('image_selection', {}).get('y', '0').strip('%')
return float(y_percent)
# return sorted list of current queryset based on y coord image selection
return sorted(self, key=sort_y)
[docs]class Intervention(BaseAnnotation):
INTERVENTION_TYPE_CHOICES = (
(INTERVENTION_TYPES.ANNOTATION, 'Annotation'),
(INTERVENTION_TYPES.INSERTION, 'Insertion'),
)
intervention_type = models.CharField(
max_length=2,
choices=INTERVENTION_TYPE_CHOICES,
default=INTERVENTION_TYPES.ANNOTATION,
)
#: associated IIIF :class:`djiffy.models.Canvas` for interventions
#: related to an image
canvas = models.ForeignKey(Canvas, null=True, blank=True)
#: Tags to describe the intervention and its characteristics;
#: many-to-many relationship to :class:`Tag`
tags = models.ManyToManyField(Tag, blank=True,
help_text='Tags to describe this intervation and its characteristics')
#: language of the intervention text (i.e. :attr:`text`)
text_language = models.ForeignKey(Language, null=True, blank=True,
help_text='Language of the intervention text', related_name='+')
#: translation language of the intervention text (i.e. :attr:`text`)
text_translation = models.TextField(blank=True,
help_text='Translation of the intervention text (optional)')
#: language of the quoted text or anchor text (i.e. :attr:`quote`)
quote_language = models.ForeignKey(Language, null=True, blank=True,
help_text='Language of the anchor text', related_name='+')
#: Associated author, instance of :class:`~derrida.people.models.Person`
author = models.ForeignKey(Person, null=True, blank=True,
default=get_default_intervener)
objects = InterventionQuerySet.as_manager()
def __str__(self):
"""Override str to make sure that something is displayed
for Django admin and autocompletes"""
if not self.quote and not self.text:
string = '%s with no text' % self.get_intervention_type_display()
if self.tags.all():
tag_names = ', '.join(
sorted([tag.name for tag in self.tags.all()])
)
string = '%s, tagged as %s' % (string, tag_names)
# Organize so that self.quote is set if it exists
if self.text:
string = self.text
if self.quote:
string = self.quote
# If there's an associated canvas, supply that
if self.canvas:
string = '%s (%s)' % (string, self.canvas.label)
return string
class Meta:
# extend default permissions to add a view option
# change_annotation and delete_annotation provided by django
permissions = (
('view_intervention', 'View intervention'),
)
[docs] def save(self, *args, **kwargs):
# for image annotation, URI should be set to canvas URI; look up
# canvas by URI and associate with the record
# if canvas is already set and uri matches annotation uri, do nothing
if self.canvas and self.uri == self.canvas.uri:
pass
else:
# otherwise, lookup canvas and associate
# (clear out in case there is no match for the new uri)
self.canvas = None
try:
self.canvas = Canvas.objects.get(uri=self.uri)
except Canvas.DoesNotExist:
pass
super(Intervention, self).save()
[docs] def get_uri(self):
'''Return a public URI for this intervention that can be used as an identifier'''
return absolutize_url(reverse('interventions:view', args=[self.id]))
[docs] def is_verbal(self):
'''Return whether a :class:`Intervention` has a verbal component.'''
return bool(self.text)
# Sorts on the binary of whether an intervention does or does not
# have text
is_verbal.boolean = True
is_verbal.admin_order_field = 'text'
[docs] def is_annotation(self):
'''Return whether :class:`Intervention` object is an annotation.'''
return self.intervention_type == INTERVENTION_TYPES.ANNOTATION
[docs] def is_insertion(self):
'''Return whether :class:`Intervention` object is an insersetion.'''
return self.intervention_type == INTERVENTION_TYPES.INSERTION
@property
def digital_edition(self):
'''digital edition this annotation is associated, via
:class:`djiffy.models.Canvas`'''
return self.canvas.manifest
@property
def work_instance(self):
'''Annotated library work :class:`derrida.books.models.Instance`,
via associated :attr:`digital_edition`.'''
if self.canvas:
return self.canvas.manifest.instance
@property
def annotation_type(self):
'''List of annotation types. Generated from tags, excluding ink
and pencil tags, uncertain and illegible tags, and with the
addition of verbal or nonverbal annotation.'''
# FIXME: should we restrict to known types to prevent new
# tags from being treated as annotation types?
tags = [tag.name for tag in self.tags.all() if not any(
['ink' in tag.name, 'pencil' in tag.name, 'uncertain' in tag.name,
'illegible' in tag.name])]
if self.is_verbal():
tags.append('verbal annotation')
else:
tags.append('nonverbal annotation')
return tags
@property
def ink(self):
'''pen ink color or pencil, from tags'''
return [tag.name for tag in self.tags.all() if any(
['ink' in tag.name, 'pencil' in tag.name])]
# NOTE: iiif_image_selection and admin_thumbnail borrowed
# directly from cdh winthrop annotation code
img_info_to_iiif = {'w': 'width', 'h': 'height', 'x': 'x', 'y': 'y'}
[docs] def iiif_image_selection(self):
'''
Generate a IIIF image selection for a :class:`Intervention` if it
image selection information is present and a canvas is associated.
'''
# if image selection information is present in annotation
# and canvas is associated, generated a IIIF image for the
# selected portion of the canvas
if 'image_selection' in self.extra_data and self.canvas:
# convert stored image info into the format used by
# piffle for generating iiif image region
img_selection = {
self.img_info_to_iiif[key]: float(val.rstrip('%'))
for key, val in self.extra_data['image_selection'].items()
if key in self.img_info_to_iiif
}
return self.canvas.image.region(percent=True, **img_selection)
[docs] def admin_thumbnail(self):
'''
Provide an admin thumbnail image of associated IIIF image selection.
'''
img_selection = self.iiif_image_selection()
# if image selection is available, display small thumbnail
if img_selection:
return u'<img src="%s" />' % img_selection.mini_thumbnail()
# otherwise, if canvas is set, display canvas small thumbnail
elif self.canvas:
return u'<img src="%s" />' % self.canvas.image.mini_thumbnail()
admin_thumbnail.short_description = 'Thumbnail'
admin_thumbnail.allow_tags = True
[docs] def handle_extra_data(self, data, request):
'''Handle "extra" data that is not part of the stock annotation
data model. Used to support custom fields that are
specific to :class:`Intervention`. Data is as provided from json
request data, as sent by annotator.js.'''
# If the object does not yet exist in the database, it must be
# saved before adding foreign key or many-to-many relationships.
if self._state.adding:
super(Intervention, self).save()
# Add author if in the annotation
if 'author' in data:
try:
self.author = Person.objects.get(authorized_name=data['author'])
except ObjectDoesNotExist:
self.author = None
# If it doesn't exist, also explicitly set None to avoid default
else:
self.author = None
# Set any tags that are passed if they already exist in the db
# (tag vocabulary is enforced; unrecognized tags are ignored)
if 'tags' in data:
tags = Tag.objects.filter(name__in=data['tags'])
self.tags.set(tags)
del data['tags']
# annotation text language; unset or invalid clears out the language
try:
self.text_language = Language.objects.get(name=data.get('text_language', None))
except Language.DoesNotExist:
self.text_language = None
# quote/anchor text language; unset or invalid clears it out
try:
self.quote_language = Language.objects.get(name=data.get('quote_language', None))
except Language.DoesNotExist:
self.quote_language = None
self.text_translation = data.get('text_translation', '')
# remove fields if present, but don't error if they are not
for field in ['text_language', 'quote_language', 'text_translation']:
try:
del data[field]
except KeyError:
pass
return data
[docs] def info(self):
'''Return a dictionary of fields and values for
display in the JSON object representation of the annotation.'''
# Must include all local database fields in the output
info = super(Intervention, self).info()
info.update({
'tags': [tag.name for tag in self.tags.all()],
})
# languages - display language name
if self.text_language:
info['text_language'] = self.text_language.name
if self.quote_language:
info['quote_language'] = self.quote_language.name
if self.text_translation:
info['text_translation'] = self.text_translation
# author - display author name
if self.author:
info['author'] = self.author.authorized_name
return info