Source code for derrida.books.models

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))