import json
import string
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from djiffy.models import Canvas, Manifest
from sortedm2m.fields import SortedManyToManyField
from unidecode import unidecode
from derrida.common.models import DateRange, Named, Notable
from derrida.common.utils import absolutize_url
from derrida.footnotes.models import Footnote
from derrida.people.models import Person
from derrida.places.models import Place
# TODO: could work/instance count be refactored for more general use?
[docs]class WorkCount(models.Model):
'''Mix-in for models related to works; adds work count property and link to
associated works'''
class Meta:
abstract = True
[docs] def work_count(self):
'''
Return number of associated :class:`derrida.books.models.Works` for
a given object as an HTML snippet for the Django admin.
'''
base_url = reverse('admin:books_work_changelist')
return mark_safe('<a href="%s?%ss__id__exact=%s">%s</a>' % (
base_url,
self.__class__.__name__.lower(),
self.pk,
self.work_set.count()
))
work_count.short_description = '# works'
# NOTE: possible to use a count field for admin ordering!
# see https://mounirmesselmeni.github.io/2016/03/21/how-to-order-a-calculated-count-field-in-djangos-admin/
# book_count.admin_order_field = 'work__count'
[docs]class InstanceCount(models.Model):
'''Mix-in for models related to books; adds book count property and link to
associated books'''
class Meta:
abstract = True
[docs] def instance_count(self):
'''
Return a count of associated :class:`derrida.books.models.Instance` for
object as an HTML snippet for the Django admin.
'''
base_url = reverse('admin:books_instance_changelist')
return mark_safe('<a href="%s?%ss__id__exact=%s">%s</a>' % (
base_url,
self.__class__.__name__.lower(),
self.pk,
self.instance_set.count()
))
instance_count.short_description = '# instances'
[docs]class Subject(Named, Notable, WorkCount):
'''Subject categorization for books'''
#: optional uri
uri = models.URLField(blank=True, null=True)
[docs]class Language(Named, Notable, WorkCount, InstanceCount):
'''Language that a book is written in or a language included in a book'''
#: optional uri
uri = models.URLField(blank=True, null=True)
code = models.CharField(blank=True, null=True, max_length=3,
help_text='two or three letter language code from ISO 639')
[docs]class Publisher(Named, Notable, InstanceCount):
'''Publisher of a book'''
pass
[docs]class OwningInstitution(Named, Notable, InstanceCount):
'''Institution that owns the extant copy of a book'''
#: short name (optioal)
short_name = models.CharField(max_length=255, blank=True,
help_text='Optional short name for admin display')
#: contact information
contact_info = models.TextField()
#: :class:`~derrida.places.models.Place`
place = models.ForeignKey(Place)
def __str__(self):
return self.short_name or self.name
[docs]class Journal(Named, Notable):
'''List of associated journals for items published as journal articles'''
pass
[docs]class Work(Notable):
'''A platonic work. Stores common information about multiple
instances, copies, or editions of the same work. Aggregates one
or more :class:`Instance` objects.'''
#: primary title
primary_title = models.TextField()
#: short title
short_title = models.CharField(max_length=255)
#: original publication date
year = models.IntegerField(blank=True, null=True,
help_text='Original publication date')
# NOTE: this is inteneded for a generic linked data URI;
# finding aid URL should be tracked on Instance rather than Work
#: optional URI
uri = models.URLField('URI', blank=True, help_text='Linked data URI',
default='')
#: relation to :class:`Person` authors
authors = models.ManyToManyField(Person, blank=True)
#: :class:`Subject` related through :class:`WorkSubject`
subjects = models.ManyToManyField(Subject, through='WorkSubject')
#: :class:`Language` related through :class:`WorkLanguage`
languages = models.ManyToManyField(Language, through='WorkLanguage')
class Meta:
ordering = ['primary_title']
verbose_name = 'Derrida library work'
def __str__(self):
return '%s (%s)' % (self.short_title, self.year or 'n.d.')
[docs] def author_names(self):
'''Display author names; convenience access for display in admin'''
# NOTE: possibly might want to use last names here
return ', '.join(str(auth) for auth in self.authors.all())
author_names.short_description = 'Authors'
author_names.admin_order_field = 'authors__authorized_name'
[docs] def instance_count(self):
'''
Return count of :class:`derrida.book.models.Instance` associated with
:class:`Work` formatted as an HTML snippet for the Django admin.
'''
base_url = reverse('admin:books_instance_changelist')
return mark_safe('<a href="%s?%ss__id__exact=%s">%s</a>' % (
base_url,
self.__class__.__name__.lower(),
self.pk,
self.instance_set.count()
))
instance_count.short_description = '# instances'
[docs]class InstanceQuerySet(models.QuerySet):
'''Custom :class:`~django.db.models.QuerySet` for :class:`Instance` to
make it easy to find all instances that have a digital
edition'''
[docs] def with_digital_eds(self):
'''
Return :class:`derrida.books.models.Instance` queryset filtered by
having a digital edition.
'''
return self.exclude(digital_edition__isnull=True)
[docs]class Instance(Notable):
'''A single instance of a :class:`Work` - i.e., a specific copy or edition
or translation. Can also include books that appear as sections
of a collected works.'''
#: :class:`Work` this instance belongs to
work = models.ForeignKey(Work)
#: alternate title (optional)
alternate_title = models.CharField(blank=True, max_length=255)
#: :class:`Publisher` (optional)
publisher = models.ForeignKey(Publisher, blank=True, null=True)
#: publication :class:`~derrida.places.models.Place` (optional, sorted many to many)
pub_place = SortedManyToManyField(Place,
verbose_name='Place(s) of Publication', blank=True)
#: Zotero identifier
zotero_id = models.CharField(max_length=8, default='', blank=True)
# identifying slug for use in get_absolute_url, indexed for speed
slug = models.SlugField(max_length=255,
unique=True,
help_text=(
'To auto-generate a valid slug for a new '
'instance, choose a work then click '
'"Save and Continue Editing" in the lower '
'right. Editing slugs of previously saved '
'instances should be done with caution, '
'as this may break permanent links.'
),
blank=True
)
#: item is extant
is_extant = models.BooleanField(help_text='Extant in PUL JD', default=False)
#: item is annotated
is_annotated = models.BooleanField(default=False)
#: item is translated
is_translation = models.BooleanField(default=False)
#: description of item dimensions (optional)
dimensions = models.CharField(max_length=255, blank=True)
#: copyright year
copyright_year = models.PositiveIntegerField(blank=True, null=True)
#: related :class:`Journal` for a journal article
journal = models.ForeignKey(Journal, blank=True, null=True)
print_date_help_text = 'Date as YYYY-MM-DD, YYYY-MM, or YYYY format. Use' \
+ ' print date day/month/year known flags to indicate' \
+ ' that the information is not known.'
#: print date
print_date = models.DateField('Print Date',
blank=True, null=True, help_text=print_date_help_text)
#: print date day is known
print_date_day_known = models.BooleanField(default=False)
#: print date month is known
print_date_month_known = models.BooleanField(default=False)
#: print date year is known
print_date_year_known = models.BooleanField(default=True)
#: finding aid URL
uri = models.URLField('URI', blank=True, default='',
help_text='Finding Aid URL for items in PUL Derrida Library')
# item has a dedication
has_dedication = models.BooleanField(default=False)
# item has insertiosn
has_insertions = models.BooleanField(default=False)
# page range: using character fields to support non-numeric pages, e.g.
# roman numerals for introductory pages; using two fields to support
# sorting within a volume of collected works.
#: start page for book section or journal article
start_page = models.CharField(max_length=20, blank=True, null=True)
#: end page for book section or journal article
end_page = models.CharField(max_length=20, blank=True, null=True)
#: optional label to distinguish multiple copies of the same work
copy = models.CharField(max_length=1, blank=True,
help_text='Label to distinguish multiple copies of the same edition',
validators=[RegexValidator(r'[A-Z]',
message='Please set a capital letter from A-Z.'
)],
)
#: :class:`Language` this item is written in;
# uses :class:`InstanceLanguage` to indicate primary language
languages = models.ManyToManyField(Language, through='InstanceLanguage')
#: :class:`Instance` that collects this item, for book section
collected_in = models.ForeignKey('self', related_name='collected_set',
on_delete=models.SET_NULL, blank=True, null=True,
help_text='Larger work instance that collects or includes this item')
# work instances are connected to owning institutions via the Catalogue
# model; mapping as a many-to-many with a through
# model in case we want to access owning instutions directly
#: :class:`OwningInstitution`; connected through :class:`InstanceCatalogue`
owning_institutions = models.ManyToManyField(OwningInstitution,
through='InstanceCatalogue')
#: :class:`DerridaWork` this item is cited in
cited_in = models.ManyToManyField('DerridaWork',
help_text='Derrida works that cite this edition or instance',
blank=True)
#: digital edition via IIIF as instance of :class:`djiffy.models.Manifest`
digital_edition = models.OneToOneField(Manifest, blank=True, null=True,
on_delete=models.SET_NULL,
help_text='Digitized edition of this book, if available')
#: flag to suppress content page images, to comply with copyright
#: owner take-down request
suppress_all_images = models.BooleanField(default=False,
help_text='''Suppress large image display for all annotated pages
in this volume, to comply with copyright take-down requests.
(Overview images, insertions, and thumbnails will still display.)''')
#: specific page images to be suppressed, to comply with copyright
#: owner take-down request
suppressed_images = models.ManyToManyField(Canvas, blank=True,
help_text='''Suppress large image for specific annotated images to comply
with copyright take-down requests.''')
# proof-of-concept generic relation to footnotes
#: generic relation to :class:~`derrida.footnotes.models.Footnote`
footnotes = GenericRelation(Footnote)
objects = InstanceQuerySet.as_manager()
class Meta:
ordering = ['alternate_title', 'work__primary_title'] ## ??
verbose_name = 'Derrida library work instance'
unique_together = (("work", "copyright_year", "copy"),)
[docs] def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.generate_safe_slug()
super(Instance, self).save(*args, **kwargs)
[docs] def clean(self):
# Don't allow both journal and collected work
if self.journal and self.collected_in:
raise ValidationError('Cannot belong to both a journal and a collection')
def __str__(self):
return '%s (%s%s)' % (self.display_title(),
self.copyright_year or 'n.d.',
' %s' % self.copy if self.copy else '')
[docs] def get_absolute_url(self):
'''URL for this :class:`Instance` on the website.'''
return reverse('books:detail', kwargs={'slug': self.slug})
[docs] def get_uri(self):
'''public URI for this instance to be used as an identifier'''
return absolutize_url(reverse('books:instance', args=[self.id]))
[docs] def generate_base_slug(self):
'''Generate a slug based on first author, work title, and year.
Not guaranteed to be unique if there are multiple copies of
the same instance/edition of a work.
:rtype str: String in the format ``lastname-title-of-work-year``
'''
# get the first author, if there is one
author = self.work.authors.first()
if author:
# use the last name of the first author
author = author.authorized_name.split(',')[0]
else:
# otherwise, set it to an empty string
author = ''
# truncate the title to first several words of the title
title = ' '.join(self.work.primary_title.split()[:9])
# use copyright year if available, with fallback to work year if
year = self.copyright_year or self.work.year or ''
# # return a slug (not unique for multiple copies of same instance)
return slugify('%s %s %s' % (unidecode(author), unidecode(title), year))
[docs] def generate_safe_slug(self):
'''Generate a unique slug. Checks for duplicates and calculates
an appropriate copy letter if needed.
:rtype str: String in the format `lastname-title-of-work-year-copy`
'''
# base slug, without any copy letter
base_slug = self.generate_base_slug()
if self.copy:
slug = '-'.join([base_slug, self.copy])
else:
slug = base_slug
# check for any copies with the same base slug
duplicates = Instance.objects.filter(
slug__icontains=base_slug).order_by('-slug')
# exclude current record if it has already been saved
if self.pk:
duplicates = duplicates.exclude(pk=self.pk)
# any new copies should start with 'B' since 'A' is implicit in already
# saved slug for original
new_copy_letter = 'B'
# check for duplicates
if duplicates.exists():
# get the list of matching slugs
slugs = duplicates.values_list('slug', flat=True)
# if slug with specified copy is already unique, use that without
# further processing
if not slug in slugs:
return slug
# otherwise, calculate the appropriate copy letter to use
# collect copy suffixes from the slugs
# (trailing single uppercase letters only)
letters = [ltr for slug in slugs
for ltr in slug.rsplit('-', 1)[1]
if len(ltr) == 1 and ltr in string.ascii_uppercase]
# if existing copies letters are found, increment from the
# highest one (already sorted properly from queryset return)
if letters:
next_copy = chr(ord(letters[0]) + 1)
else:
# otherwise, default next copy is B (first is assumed to be A)
next_copy = 'B'
slug = '-'.join([base_slug, next_copy])
# also store the new copy letter as instance copy
self.copy = next_copy
return slug
[docs] def display_title(self):
'''display title - alternate title or work short title'''
return self.alternate_title or self.work.short_title or '[no title]'
display_title.short_description = 'Title'
[docs] def is_digitized(self):
'''boolean indicator if there is an associated digital edition'''
return bool(self.digital_edition) or \
bool(self.collected_in and self.collected_in.digital_edition)
# technically sorts on the foreign key, but that effectively filters
# instances with/without digital additions
is_digitized.admin_order_field = 'digital_edition'
is_digitized.boolean = True
[docs] def primary_language(self):
'''Primary :class:`Language` for this work instance. Use only
language or primary language for the instance if available; falls
back to only or primary language for the associated work.'''
langs = self.languages.all()
# if instance has only one language, use that
# (whether or not marked as primary)
if langs.exists():
# if more than one, filter to just primary
if langs.count() > 1:
langs = langs.filter(instancelanguage__is_primary=True)
# otherwise, return language for the work
if not langs and self.work.languages.exists():
langs = self.work.languages.all()
# filter by primary if more than one
if langs.count() > 1:
langs = langs.filter(worklanguage__is_primary=True)
if langs:
return langs.first()
@property
def location(self):
'''Location in Derrida's library (currently only available for
digitized books).'''
# NOTE: PUL digital editions from the Finding Aid include the
# location in the item title
if self.is_digitized():
# Split manifest label on dashes; at most we want the first two
location_parts = self.digital_edition.label.split(' - ')[:2]
# some volumes include a "Gift Books" notation we don't care about
if location_parts[-1].startswith('Gift Books'):
location_parts = location_parts[:-1]
return ', '.join(location_parts)
@property
def item_type(self):
'''item type: book, book section, or journal article'''
if self.journal:
return 'Journal Article'
if self.collected_in:
return 'Book Section'
return 'Book'
[docs] def author_names(self):
'''Display Work author names; convenience access for display in admin'''
return self.work.author_names()
author_names.short_description = 'Authors'
author_names.admin_order_field = 'work__authors__authorized_name'
[docs] def catalogue_call_numbers(self):
'''Convenience access to catalogue call numbers, for display in admin'''
return ', '.join([c.call_number for c in self.instancecatalogue_set.all()
if c.call_number])
catalogue_call_numbers.short_description = 'Call Numbers'
catalogue_call_numbers.admin_order_field = 'catalogue__call_number'
[docs] def print_year(self):
'''Year from :attr:`print_date` if year is known'''
if self.print_date and self.print_date_year_known:
return self.print_date.year
@property
def year(self):
'''year for indexing and display; :attr:`print_date` if known,
otherwise :attr:`copyright_year`'''
return self.print_year() or self.copyright_year
[docs] def images(self):
'''Queryset containing all :class:`djiffy.models.Canvas` objects
associated with the digital edition for this item.'''
if self.digital_edition:
return self.digital_edition.canvases.all()
return Canvas.objects.none()
#: terms in an image label that indicate a canvas should be
#: considered an overview image (e.g., cover & outside views)
overview_labels = ['cover', 'spine', 'back', 'edge', 'view']
[docs] def overview_images(self):
'''Overview images for this book - cover, spine, etc.
Filtered based on canvas label naming conventions.'''
label_query = models.Q()
for overview_label in self.overview_labels:
label_query |= models.Q(label__icontains=overview_label)
return self.images().filter(label_query) \
.exclude(label__icontains='insertion')
[docs] def annotated_pages(self):
'''Annotated pages for this book. Filtered based on the presence
of a documented :class:`~derrida.interventions.models.Intervention`
in the database.'''
return self.images().filter(intervention__isnull=False).distinct()
[docs] def insertion_images(self):
'''Insertion images for this book.
Filtered based on canvas label naming conventions.'''
# NOTE: using Insertion because of possible case-sensitive
# search on mysql even when icontains is used
return self.images().filter(label__icontains='Insertion')
[docs] @classmethod
def allow_canvas_detail(cls, canvas):
'''Check if canvas detail view is allowed. Allows insertion images,
overview images, and pages with documented interventions.'''
return any([
'insertion' in canvas.label.lower(),
any(label in canvas.label.lower()
for label in cls.overview_labels),
canvas.intervention_set.exists()
])
[docs] def allow_canvas_large_image(self, canvas):
'''Check if canvas large image view is allowed. Always allows
insertion images and overview images; other pages with documented
interventions are allowed as long as they are not suppressed,
either via :attr:`suppress_all_images` or specific
:attr:`suppressed_images`.'''
# insertion & overview always allowed
if any(['insertion' in canvas.label.lower(),
any(label in canvas.label.lower()
for label in self.overview_labels)]):
# allow
return True
# if all other images are suppressed, deny without checking further
if self.suppress_all_images:
return False
# if image has interventions, check if it is suppressed
if canvas.intervention_set.exists():
# deny if suppressed
if canvas in self.suppressed_images.all():
return False
else:
# otherwise, allow
return True
@property
def related_instances(self):
'''Find related works; for now, this means works by the
same author. For a work that collects item, include
work by any book section authors.'''
authors = list(self.work.authors.all())
if self.collected_set.exists():
for instance in self.collected_set.all():
authors.extend(instance.work.authors.all())
return Instance.objects.filter(work__authors__in=authors) \
.exclude(pk=self.pk) \
.exclude(digital_edition__isnull=True)
#: map local :attr:`item_type` to equivalent zotero template name
zotero_template_by_itemtype = {
'Book': 'book',
'Book Section': 'bookSection',
'Journal Article': 'journalArticle'
}
[docs] def as_zotero_item(self, library):
'''Serialize the instance as an item suitable for export to a Zotero
library. Requires a :class:`pyzotero.zotero.Zotero` instance for API
calls to retrieve item type templates and creator types.'''
# get the item template/creator types based on item type
# retrieve appropriate item and creator templates based on item type
zotero_template = self.zotero_template_by_itemtype[self.item_type]
template = library.item_template(zotero_template)
creator_types = library.item_creator_types(zotero_template)
# set common properties
# zotero id, if set (API will reject if it's set to an empty string)
if self.zotero_id:
template['key'] = self.zotero_id
# use local instance URI for zotero url, for compatibility
# with other data exports
template['url'] = self.get_uri()
# metadata
template['title'] = self.alternate_title or self.work.primary_title
template['shortTitle'] = self.work.short_title
template['date'] = self.copyright_year
template['publisher'] = self.publisher.name if self.publisher else ''
# place is not valid for journal articles
if self.pub_place.count() and not self.item_type == 'Journal Article':
template['place'] = '; '.join([place.name for place in self.pub_place.all()])
# no series, volume, or edition information stored in db
# author
template['creators'] = [] # clear out the default one first
for author in self.work.authors.all(): # authors come from work
template['creators'].append({
'creatorType': 'author',
'firstName': author.firstname,
'lastName': author.lastname
})
# other creators
# create a lookup dict of zotero's "localized" creator type names
# for matching on local creator_type names
type_names = {c['localized']: c['creatorType'] for c in creator_types}
author = CreatorType.objects.get(name='Author')
# all creators that are not authors
for creator in self.instancecreator_set.exclude(creator_type=author):
# match on localized name, because we use it
if creator.creator_type.name in type_names:
template['creators'].append({
# lookup on localized name and send the "type name"
'creatorType': type_names[creator.creator_type.name],
'firstName': creator.person.firstname,
'lastName': creator.person.lastname
})
# add to collections based on derrida works that cited this item;
# use collection zotero id from DerridaWork
template['collections'] = [derrida_work.zotero_id for derrida_work in \
self.cited_in.exclude(zotero_id='')]
# page range; only stored for book sections and journal articles
if self.start_page and self.end_page:
template['pages'] = '-'.join((self.start_page, self.end_page))
# convert boolean fields to tags
tags = []
for attr in ['is_extant', 'is_annotated', 'is_translation',
'has_dedication', 'has_insertions', 'digital_edition']:
if getattr(self, attr):
# use attribute name as tag
# strip "is_" and convert underscores to spaces
tags.append(attr.replace('is_', '').replace('_', ' '))
# zotero template requires a list of dictionaries
template['tags'] = [{'tag': tagval} for tagval in tags]
# try to use primary language, otherwise pick first language
language = self.languages.filter(instancelanguage__is_primary=True).first()
if not language and self.languages.exists():
language = self.languages.first()
template['language'] = language.code if language else ''
# use finding aids URL as archive location
if self.uri and 'princeton' in self.uri:
template['archiveLocation'] = self.uri
# if we have a princeton URI,
# use catalogue for location in archive / library catalog
# set archive based on catalogue information (i.e., PUL)
# NOTE: only applying to items with princeton urls, because
# import seems to have associated all items with princeton
# as owning institution, whether they are extant or not
current_catalog = self.instancecatalogue_set.filter(is_current=True).first()
if current_catalog:
template['archive'] = current_catalog.institution.name
# item-type specific metadata
if self.item_type == 'Book Section':
# title of the book this work appears in
template['bookTitle'] = self.collected_in.display_title()
# publication information stored on the book, but don't override
# if anything was set on the book section
book_metadata = self.collected_in.as_zotero_item(library)
for field in ['date', 'publisher', 'place', 'language',
'archive', 'archiveLocation']:
if not template.get(field, None) and field in book_metadata:
template[field] = book_metadata[field]
if self.item_type == 'Journal Article':
template['publicationTitle'] = self.journal.name
# add notes to abstract field
notes = []
# include copy information, if present, to indicate multiple copies
if self.copy:
notes.append('Copy {}'.format(self.copy))
# include total reference count
if self.reference_set.exists():
# in future, this should be reference count *per* derrida work
ref_count = self.reference_set.count()
notes.append('{} reference{}'.format(
ref_count, 's' if ref_count != 1 else ''))
# number of pages with annotations documented
annotated_page_count = self.annotated_pages().count()
if annotated_page_count:
notes.append('{} page{} with documented annotations'.format(
annotated_page_count, 's' if annotated_page_count != 1 else ''))
if notes:
template['abstractNote'] = '\n'.join(notes)
return template
[docs]class WorkSubject(Notable):
'''Through-model for work-subject relationship, to allow designating
a particular subject as primary or adding notes.'''
#: :class:`Subject`
subject = models.ForeignKey(Subject)
#: :class:`Work`
work = models.ForeignKey(Work)
#: boolean flag indicating if this subject is primary for this work
is_primary = models.BooleanField(default=False)
class Meta:
unique_together = ('subject', 'work')
verbose_name = 'Subject'
def __str__(self):
return '%s %s%s' % (self.work, self.subject,
' (primary)' if self.is_primary else '')
[docs]class WorkLanguage(Notable):
'''Through-model for work-language relationship, to allow designating
one language as primary or adding notes.'''
#: :class:`Language`
language = models.ForeignKey(Language)
#: :class:`Work`
work = models.ForeignKey(Work)
#: boolean flag indicating if this language is primary for this work
is_primary = models.BooleanField()
class Meta:
unique_together = ('work', 'language')
verbose_name = 'Language'
def __str__(self):
return '%s %s%s' % (self.work, self.language,
' (primary)' if self.is_primary else '')
[docs]class InstanceLanguage(Notable):
'''Through-model for instance-language relationship, to allow designating
one language as primary or adding notes.'''
#: :class:`Language`
language = models.ForeignKey(Language)
#: :class:`Instance`
instance = models.ForeignKey(Instance)
#: boolean flag indicating if this language is primary for this instance
is_primary = models.BooleanField()
class Meta:
unique_together = ('instance', 'language')
verbose_name = 'Language'
def __str__(self):
return '%s %s%s' % (self.instance, self.language,
' (primary)' if self.is_primary else '')
[docs]class InstanceCatalogue(Notable, DateRange):
'''Location of a work instance in the real world, associating it with an
owning instutition.'''
institution = models.ForeignKey(OwningInstitution)
instance = models.ForeignKey(Instance)
is_current = models.BooleanField()
# using char instead of int because assuming call numbers may contain
# strings as well as numbers
call_number = models.CharField(max_length=255, blank=True, null=True,
help_text='Used for Derrida shelf mark')
class Meta:
verbose_name = 'Catalogue'
def __str__(self):
dates = ''
if self.dates:
dates = ' (%s)' % self.dates
return '%s / %s%s' % (self.instance, self.institution, dates)
[docs]class CreatorType(Named, Notable):
'''Type of creator role a person can have to a book - author,
editor, translator, etc.'''
uri = models.URLField(blank=True, null=True)
[docs]class InstanceCreator(Notable):
creator_type = models.ForeignKey(CreatorType)
# technically should disallow author here, but can clean that up later
person = models.ForeignKey(Person)
instance = models.ForeignKey(Instance)
def __str__(self):
return '%s %s %s' % (self.person, self.creator_type, self.instance)
[docs]class PersonBookRelationshipType(Named, Notable):
'''Type of non-annotation relationship assocating a person
with a book.'''
uri = models.URLField(blank=True, null=True)
[docs]class PersonBook(Notable, DateRange):
'''Interactions or connections between books and people other than
annotation.'''
# FIXME: better name? concept/thing/model
person = models.ForeignKey(Person)
book = models.ForeignKey(Instance)
relationship_type = models.ForeignKey(PersonBookRelationshipType)
class Meta:
verbose_name = 'Person/Book Interaction'
def __str__(self):
dates = ''
if self.dates:
dates = ' (%s)' % self.dates
return '%s - %s%s' % (self.person, self.book, dates)
# New citationality model
[docs]class DerridaWork(Notable):
'''This models the reference copy used to identify all citations, not
part of Derrida's library'''
#: short title
short_title = models.CharField(max_length=255)
#: full citation
full_citation = models.TextField()
#: boolean indicator for primary work
is_primary = models.BooleanField()
#: slug for use in URLs
slug = models.SlugField(
help_text='slug for use in URLs (changing after creation will break URLs)')
#: zotero collection ID for use in populating library
zotero_id = models.CharField(max_length=8, default='', blank=True)
def __str__(self):
return self.short_title
[docs]class DerridaWorkSection(models.Model):
'''Sections of a :class:`DerridaWork` (e.g. chapters). Used to look at
:class:`Reference` by sections of the work.'''
name = models.CharField(max_length=255)
derridawork = models.ForeignKey(DerridaWork)
order = models.PositiveIntegerField('Order')
start_page = models.IntegerField(blank=True, null=True,
help_text='Sections with no pages will be treated as headers.')
end_page = models.IntegerField(blank=True, null=True)
class Meta:
ordering = ['derridawork', 'order']
def __str__(self):
return self.name
[docs]class ReferenceType(Named, Notable):
'''Type of reference, i.e. citation, quotation, footnotes, epigraph, etc.'''
pass
[docs]class ReferenceQuerySet(models.QuerySet):
'''Custom :class:`~django.db.models.QuerySet` for :class:`Reference`.'''
[docs] def order_by_source_page(self):
'''Order by page in derrida work (attr:`Reference.derridawork_page`)'''
return self.order_by('derridawork_page')
[docs] def order_by_author(self):
'''Order by author of cited work'''
return self.order_by('instance__work__authors__authorized_name')
[docs] def summary_values(self, include_author=False):
'''Return a values list of summary information for display or
visualization. Currently used for histogram visualization.
Author of cited work is aliased to `author`.
:param include_author: optionally include author information;
off by default, since this creates repeated records for
references to multi-author works
'''
extra_fields = {}
if include_author:
extra_fields['author'] = models.F('instance__work__authors__authorized_name')
return self.values(
'id', 'instance__slug', 'derridawork__slug',
'derridawork_page', 'derridawork_pageloc', **extra_fields)
[docs]class Reference(models.Model):
'''Reference to a book from a work by Derrida. Can be a citation,
quotation, or other kind of reference.'''
#: :class:`Instance` that is referenced
instance = models.ForeignKey(Instance, blank=True, null=True)
#: :class:`DerridaWork` that references the item
derridawork = models.ForeignKey(DerridaWork)
#: page in the Derrida work.
# FIXME: does this have to be char and not integer?
derridawork_page = models.IntegerField()
#: location/identifier on the page
derridawork_pageloc = models.CharField(max_length=2)
#: page in the referenced item
book_page = models.CharField(max_length=255, blank=True)
#: :class:`ReferenceType`
reference_type = models.ForeignKey(ReferenceType)
#: anchor text
anchor_text = models.TextField(blank=True)
#: ManyToManyField to :class:`djiffy.models.Canvas`
canvases = models.ManyToManyField(Canvas, blank=True,
help_text="Scanned images from Derrida's Library | ")
#: ManyToManyField to :class:`derrida.interventions.Intervention`
interventions = models.ManyToManyField('interventions.Intervention',
blank=True) # Lazy reference to avoid a circular import
objects = ReferenceQuerySet.as_manager()
class Meta:
ordering = ['derridawork', 'derridawork_page', 'derridawork_pageloc']
def __str__(self):
return "%s, %s%s: %s, %s, %s" % (
self.derridawork.short_title,
self.derridawork_page,
self.derridawork_pageloc,
# instance is technically optional...
self.instance.display_title() if self.instance else '[no instance]',
self.book_page,
self.reference_type
)
[docs] def get_absolute_url(self):
'''URL for this reference on the site'''
# NOTE: currently view is html snippet for loading via ajax only
return reverse('books:reference', kwargs={
'derridawork_slug': self.derridawork.slug,
'page': self.derridawork_page,
'pageloc': self.derridawork_pageloc
})
[docs] def get_uri(self):
'''public URI for this instance to be used as an identifier'''
return absolutize_url(self.get_absolute_url())
[docs] def anchor_text_snippet(self):
'''Anchor text snippet, for admin display'''
snippet = self.anchor_text[:100]
if len(self.anchor_text) > 100:
return ''.join([snippet, ' ...'])
return snippet
anchor_text_snippet.short_description = 'Anchor Text'
anchor_text.admin_order_field = 'anchor_text'
@property
def instance_slug(self):
'''Slug for the work instance used to display this reference.
For a reference to a book section, returns the slug
for the book that collects it.
'''
return self.book.slug
@property
def instance_url(self):
'''absolute url for the work instance where this reference
is displayed; uses :attr:`instance_slug`'''
return reverse('books:detail', args=[self.instance_slug])
@property
def book(self):
'''The "book" this reference is associated with; for a book section,
this is the work instance the section is collected in; for all other
cases, it is the work instance associated with this reference.
'''
if self.instance.collected_in:
return self.instance.collected_in
else:
return self.instance
[docs] @staticmethod
def instance_ids_with_digital_editions():
'''Used as a convenience method to provide a readonly field in the
admin change form for :class:`Reference` with a list of JSON formatted
primary keys. This is used by jQuery in the :class:`Reference`
change_form and reference inlines on the :class:`Instance`change_form
to disable the autocomplete fields when there is or is not a digital
edition. See ``sitemedia/js/reference-instance-canvas-toggle.js`` for
this logic.
:rtype: JSON formatted string of :class:`Instance` primary keys
'''
with_digital_eds = Instance.objects.with_digital_eds()
# Flatten to just the primary keys
ids = with_digital_eds.values_list('id', flat=True).order_by('id')
# Return serialized JSON
return json.dumps(list(ids))