# -*- coding: utf-8 -*-
import re
from cached_property import cached_property
from dateutil.relativedelta import relativedelta
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError
from django.db import models
from django.template.defaultfilters import pluralize
from djiffy.models import Canvas
from mep.accounts.event_set import EventSetMixin
from mep.accounts.partial_date import DatePrecisionField, PartialDate, \
PartialDateMixin
from mep.books.models import Edition, Work
from mep.common.models import Named, Notable
from mep.footnotes.models import Bibliography, Footnote
from mep.people.models import Location, Person
[docs]class Account(models.Model, EventSetMixin):
'''Central model for all account and related information, M2M explicity to
:class:`people.Person`'''
persons = models.ManyToManyField(Person, blank=True,
verbose_name='Account holder(s)')
# convenience access to associated locations, although
# we will probably use Address for most things
locations = models.ManyToManyField(Location, through='Address', blank=True)
card = models.ForeignKey(Bibliography, blank=True, null=True,
help_text='Lending Library Card for this account',
limit_choices_to={'source_type__name': 'Lending Library Card'},
on_delete=models.SET_NULL)
def __repr__(self):
names = ''
if self.pk and self.persons.count():
names = ' %s' % \
';'.join([str(person) for person in self.persons.all()])
return '<Account pk:%s%s>' % (self.pk or '??', names)
def __str__(self):
if not self.persons.exists() and not self.locations.exists():
return 'Account #%s' % self.pk
if self.persons.exists():
return 'Account #%s: %s' % (
self.pk,
', '.join(person.name for person in self.persons.all())
)
if self.locations.exists():
return 'Account #%s: %s' % (
self.pk,
'; '.join(address.name if address.name else
address.street_address if address.street_address else
address.city for address in
self.locations.all().order_by(
'city', 'street_address', 'name'))
)
class Meta:
ordering = ('persons__sort_name',)
[docs] def list_persons(self):
'''List :class:`mep.people.models.Person` instances associated with this
account.
'''
return ', '.join(person.name for
person in self.persons.all().order_by('name'))
list_persons.short_description = 'Account holder(s)'
@property
def subscription_set(self):
'''associated subscription events, as queryset of
:class:`Subscription`'''
return Subscription.objects.filter(account_id=self.id)
@property
def reimbursement_set(self):
'''
associated reimbursement events, as queryset of :class:`Reimbursement`
'''
return Reimbursement.objects.filter(account_id=self.id)
[docs] def list_locations(self):
'''List of associated :class:`mep.people.models.Location` '''
return '; '.join([str(loc) for loc in self.locations.distinct()])
list_locations.short_description = 'Locations'
[docs] def has_card(self):
'''Account has an associated lending card'''
return bool(self.card)
has_card.boolean = True
@staticmethod
def validate_etype(etype):
etype = etype.lower()
if etype not in ['borrow', 'event', 'subscription',
'purchase', 'reimbursement']:
raise ValueError('etype must be one of borrow, event, purchase,'
' subscription, or reimbursement')
@staticmethod
def str_to_model(etype):
# moving mapping here so that we can forward reference classes
# not yet declared
mapping = {
'borrow': Borrow,
'reimbursement': Reimbursement,
'event': Event,
'purchase': Purchase,
'subscription': Subscription
}
return mapping[etype]
[docs] def add_event(self, etype='event', **kwargs):
'''Helper function to add a :class:`Event` or subclass to an
instance of :class:`Account`. Requires that the :class:`Account`
object be saved first (so it has a set primary key).
This provides functionality normally in the ``self.*_set``
functionality of Django, but not provided with subclassed
table inheritence.
:param etype: ``str``
One of ``borrow``, ``event``, ``subscription``,
``purchase``, ``reimbursement``
'''
# Catch an invalid class of event or subevent and raise
# ValueError
self.validate_etype(etype)
# Create the event
self.str_to_model(etype).objects.create(account=self, **kwargs)
[docs] def get_events(self, etype='event', **kwargs):
'''Helper function to retrieve related events of any valid type for
:class:`Account.add_event()`. This provides functionality normally in the
``self.*_set`` functionality, but not provided with subclassed
table inheritence.
:param etype: ``str``
One of ``borrow``, ``event``, ``subscription``,
``purchase``, ``reimbursement``
:Keyword Arguments:
Any valid query kwargs for :class:`Account`, defaults to equivalent
of ``Foo.objects.all()``.
'''
# Catch an invalid class of event or subevent
self.validate_etype(etype)
return self.str_to_model(etype).objects.filter(account=self, **kwargs)
[docs] def member_card_images(self):
'''Return a queryset for card images that are part of this account's
associated card manifest OR that have events for this account.
Note that this returns a union queryset, which puts some retrictions
on supported operations.
'''
if not self.card or not self.card.manifest:
return Canvas.objects.none()
# get all canvases that belong to the manifest assigned as the
# card for this account (including blanks)
# mark as priority 1 to allow sorting
manifest_cards = self.card.manifest.canvases.all() \
.annotate(priority=models.Value('1', output_field=models.IntegerField()))
# find all canvas associated with events for this account via footnote
# (excluding those already in the manifest)
event_cards = Canvas.objects.exclude(manifest=self.card.manifest) \
.filter(footnote__events__account__pk=self.pk) \
.annotate(priority=models.Value('2', output_field=models.IntegerField())) \
.distinct()
# combine the two sets; removes duplicates by default
# Sort primary manifests cards first, then sort by order
return manifest_cards.union(event_cards).order_by('priority', 'order')
[docs]class Address(Notable, PartialDateMixin):
'''Address associated with an :class:`Account` or
a :class:`~mep.people.models.Person`. Used to associate locations with
people and accounts, with optional start and end dates and
a care/of person.'''
location = models.ForeignKey(Location, on_delete=models.CASCADE)
account = models.ForeignKey(
Account, blank=True, null=True, on_delete=models.CASCADE,
help_text='Associated library account')
person = models.ForeignKey(
Person, blank=True, null=True, on_delete=models.CASCADE,
help_text='For personal addresses not associated with library accounts.')
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
care_of_person = models.ForeignKey(
Person, blank=True, null=True, on_delete=models.SET_NULL,
related_name='care_of_addresses')
class Meta:
verbose_name_plural = 'Addresses'
def __repr__(self):
# use pk to make it easy to find again; string representation
# for recognizability
return '<Address pk:%s %s>' % (self.pk or '??', str(self))
def __str__(self):
details = self.account or self.person or ''
if self.start_date or self.end_date:
details = '%s (%s)' % (details, ' – '.join([
date.strftime('%Y') if date else ''
for date in [self.start_date, self.end_date]]))
if self.care_of_person:
details = '%s c/o %s' % (details, self.care_of_person)
# include details if there are any
if details:
return '%s — %s' % (self.location, details)
# otherwise just location
return str(self.location)
[docs] def clean(self):
'''Validate to require one and only one of :class:`Account` or
:class:`~mep.people.models.Person`'''
if not self.account and not self.person:
raise ValidationError('Address must be associated with an account or person')
if self.account and self.person:
raise ValidationError('Address must only be associated with one of account or person')
[docs]class EventQuerySet(models.QuerySet):
'''Custom :class:`~django.db.models.Queryset` for :class:`Event`
with filter methods for generic events and each event subtype.'''
[docs] def generic(self):
'''Generic events only (excludes subscriptions, reimbursements,
borrows, and purchases).'''
return self.filter(subscription__isnull=True,
reimbursement__isnull=True,
borrow__isnull=True,
purchase__isnull=True)
def _subtype(self, event_type):
return self.filter(**{'%s__isnull' % event_type: False})
[docs] def subscriptions(self):
'''Events with associated subscription event only'''
return self._subtype('subscription')
[docs] def reimbursements(self,):
'''Events with associated reimbursement event only'''
return self._subtype('reimbursement')
[docs] def borrows(self):
'''Events with associated borrow event only'''
return self._subtype('borrow')
[docs] def purchases(self):
'''Events with associated purchase event only'''
return self._subtype('purchase')
[docs] def membership_activities(self):
'''Subscription and reimbursement events'''
return self.filter(models.Q(subscription__isnull=False) |
models.Q(reimbursement__isnull=False))
[docs] def book_activities(self):
'''All events tied to a :class:`~mep.books.models.Work`.'''
return self.filter(work__isnull=False)
[docs] def known_years(self):
'''Filter out any events with unknown years for start or
end date.'''
return self.exclude(start_date_precision__knownyear=False) \
.exclude(end_date_precision__knownyear=False)
[docs]class Event(Notable, PartialDateMixin):
'''Base table for events in the Shakespeare and Co. Lending Library'''
account = models.ForeignKey(Account, on_delete=models.CASCADE)
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
work = models.ForeignKey(
Work, null=True, blank=True,
help_text='Work associated with this event, if any.',
on_delete=models.deletion.SET_NULL)
edition = models.ForeignKey(
Edition, null=True, blank=True,
help_text='Edition of the work, if known.',
on_delete=models.deletion.SET_NULL)
footnotes = GenericRelation(Footnote, related_query_name='events')
objects = EventQuerySet.as_manager()
class Meta:
# NOTE: ordering events by account person seems to be very slow
# disabling for now
# ordering = ('start_date', 'account__persons__sort_name')
ordering = ('start_date', )
def __repr__(self):
'''Generic representation string for Event and subclasses'''
return '<%s pk:%d account:%s %s>' % \
(self.__class__.__name__, self.pk, self.account.pk,
self.date_range)
def __str__(self):
'''Generic string method for Event and subclasses'''
return '%s for account #%s %s' % \
(self.__class__.__name__, self.account.pk, self.date_range)
@cached_property
def event_type(self):
try:
return self.subscription.get_subtype_display()
except ObjectDoesNotExist:
pass
if getattr(self, 'reimbursement', None):
return 'Reimbursement'
if getattr(self, 'borrow', None):
return 'Borrow'
if getattr(self, 'purchase', None):
return 'Purchase'
return 'Generic'
#: notation in private notes indicating kind of nonstandard events
nonstandard_notation = {
'NOTATION: LOAN': 'Loan',
'NOTATION: SBGIFT': 'Gift',
'NOTATION: BOUGHTFOR': 'Purchase',
'NOTATION: SOLDFOR': 'Purchase',
'NOTATION: REQUEST': 'Request',
'STRIKETHRU': 'Crossed out',
'NOTATION: PERIODICALSUBSCRIPTION': 'Periodical Subscription'
}
re_nonstandard_notation = re.compile(
'(%s)' % '|'.join(nonstandard_notation.keys()))
@cached_property
def event_label(self):
'''Event type label that includes nonstandard events indicated
by notation in private notes as well as all the standard types.'''
# NOTE: takes precedence over generic type if it occurs
if self.notes:
match = re.search(self.re_nonstandard_notation, self.notes)
if match:
return self.nonstandard_notation[match.group(0)]
return self.event_type
[docs]class SubscriptionType(Named, Notable):
'''Type of subscription'''
[docs]class CurrencyMixin(models.Model):
'''Mixin for currency field with currency symbol display'''
USD = 'USD'
FRF = 'FRF'
GBP = 'GBP'
# NOTE: Preliminary currency set for now
CURRENCY_CHOICES = (
('', '----'),
(USD, 'US Dollar'),
(FRF, 'French Franc'),
(GBP, 'British Pound')
)
symbols = {
FRF: '₣',
USD: '$',
GBP: '£'
}
currency = models.CharField(max_length=3, blank=True,
choices=CURRENCY_CHOICES, default=FRF)
class Meta:
abstract = True
[docs] def currency_symbol(self):
'''symbol for the selected currency'''
return self.symbols.get(self.currency, self.currency)
# NOTE: could use ¤ (generic currency), but probably not that well known
currency_symbol.short_description = '$'
currency_symbol.admin_order_field = 'currency'
[docs]class Subscription(Event, CurrencyMixin):
'''Records subscription events in the MEP database'''
duration = models.PositiveIntegerField(
'Days', blank=True, null=True,
help_text='Subscription duration in days. ' +
'Automatically calculated from start and end date.')
volumes = models.DecimalField(
blank=True, null=True, max_digits=4, decimal_places=2,
help_text='Number of volumes for checkout')
category = models.ForeignKey(
SubscriptionType, null=True, blank=True, on_delete=models.SET_NULL,
help_text='Code to indicate the kind of subscription')
#: date the purchase was bought; not necessarily the day it started!
purchase_date = models.DateField(
blank=True, null=True,
help_text='Date the subscription was purchased.')
purchase_date_precision = DatePrecisionField(null=True, blank=True)
partial_purchase_date = PartialDate(
'purchase_date', 'purchase_date_precision',
PartialDateMixin.UNKNOWN_YEAR, label='purchase date')
# NOTE: Using decimal field to take advantage of Python's decimal handling
price_paid = models.DecimalField(max_digits=10, decimal_places=2,
blank=True, null=True)
deposit = models.DecimalField(
max_digits=10,
decimal_places=2,
blank=True,
null=True
)
SUPPLEMENT = 'sup'
RENEWAL = 'ren'
OTHER = 'oth'
EVENT_TYPE_CHOICES = (
('', 'Subscription'),
(SUPPLEMENT, 'Supplement'),
(RENEWAL, 'Renewal'),
(OTHER, 'Other'),
)
subtype = models.CharField(
verbose_name='Type', max_length=50, blank=True,
choices=EVENT_TYPE_CHOICES,
help_text='Type of subscription event, e.g. supplement or renewal.')
[docs] def save(self, *args, **kwargs):
# recalculate duration on save if dates are available,
# so that duration is always accurate even if dates change
if self.start_date and self.end_date:
self.calculate_duration()
super(Subscription, self).save(*args, **kwargs)
[docs] def calculate_duration(self):
'''calculate and set subscription duration based on start and end
date, when both are known'''
# NOTE: duration calculation currently ignores partial dates;
# assumes when partial dates are used they are used
# consistently for both start and end dates
if self.start_date and self.end_date:
# calculate duration in days as timedelta from end to start
self.duration = (self.end_date - self.start_date).days
[docs] def validate_unique(self, *args, **kwargs):
'''Validation check to prevent duplicate events from being
added to the system. Does not allow more than one subscription
for the same account and date.'''
super(Subscription, self).validate_unique(*args, **kwargs)
# check to prevent duplicate event
# should not have same date + account + event subtype
# (can't use unique_together because of multi-table inheritance)
# adapted from https://stackoverflow.com/questions/7366363/adding-custom-django-model-validation
qs = Subscription.objects.filter(start_date=self.start_date,
account=self.account, subtype=self.subtype)
# if current work is already saved, exclude it from the queryset
if not self._state.adding and self.pk is not None:
qs = qs.exclude(pk=self.pk)
if qs.exists():
raise ValidationError('Subscription event is not unique')
[docs] def readable_duration(self):
'''Generate a human-readable version of the subscription duration.
Intended to follow Beach's conventions, e.g. 1 year rather than
12 months; 1 week rather than 7 days.'''
# simple case - days/weeks less than a month
if self.duration and self.duration < 28:
# weeks are sets of 7 days exactly
if self.duration % 7 == 0:
weeks = self.duration / 7
return '%d week%s' % (weeks, pluralize(weeks))
# days less than a week
if self.duration < 7:
return '%d day%s' % (self.duration, pluralize(self.duration))
# otherwise, use relativedelta to generate duration in years/months/days
# and aggregate the different units
parts = []
rel_dur = relativedelta(self.end_date, self.start_date)
if rel_dur.years:
parts.append('%d year%s' % (rel_dur.years, pluralize(rel_dur.years)))
if rel_dur.months:
parts.append('%d month%s' % (rel_dur.months, pluralize(rel_dur.months)))
if rel_dur.days:
parts.append('%d day%s' % (rel_dur.days, pluralize(rel_dur.days)))
# if there are multiple parts (e.g., 1 month and 11 days) and
# duration is evenly divisible by 7, display as weeks
# NOTE: this could potentially match 1 year + some number of months;
# unclear what behavior would be preferred in that case,
# but unlikely to happen with current MEP data
if len(parts) > 1 and self.duration % 7 == 0:
weeks = self.duration / 7
return '%d week%s' % (weeks, pluralize(weeks))
# otherwise, combine months & days
return ', '.join(parts)
readable_duration.short_description = 'Duration'
readable_duration.admin_order_field = 'duration'
[docs] def total_amount(self):
'''total amount paid (price paid + deposit if any)'''
# NOTE: using sum to simplify decimal/float issues for zeroes
return sum([x for x in (self.price_paid, self.deposit) if x])
[docs]class Borrow(Event):
'''Inherited table indicating borrow events'''
#: :class:`~mep.books.models.Work` that was borrowed;
#: optional to account for unclear titles
ITEM_RETURNED = 'R'
ITEM_BOUGHT = 'B'
ITEM_MISSING = 'M'
STATUS_CHOICES = (
('', 'Unknown'),
(ITEM_RETURNED, 'Returned'),
(ITEM_BOUGHT, 'Bought'),
(ITEM_MISSING, 'Missing'),
)
item_status = models.CharField(
max_length=2, blank=True,
help_text='Status of borrowed item (bought, missing, returned)',
choices=STATUS_CHOICES)
[docs] def save(self, *args, **kwargs):
# if end date is set and item status is not, automatically set
# status to returned
if self.end_date and not self.item_status:
self.item_status = self.ITEM_RETURNED
super(Borrow, self).save(*args, **kwargs)
[docs]class Purchase(CurrencyMixin, Event):
'''Inherited table indicating purchase events; extends :class:`Event`'''
price = models.DecimalField(max_digits=8, decimal_places=2,
blank=True, null=True)
[docs] def date(self):
'''alias of :attr:`date_range` for display; since reimbersument
is a single-day event will always be a single partial date.'''
return self.date_range
date.admin_order_field = 'start_date'
[docs] def save(self, *args, **kwargs):
# override save to always set start = end, end will be disabled in
# admin
self.end_date_precision = self.start_date_precision
self.end_date = self.start_date
super().save(*args, **kwargs)
[docs]class Reimbursement(Event, CurrencyMixin):
'''Reimbursement event; extends :class:`Event`'''
refund = models.DecimalField(max_digits=8, decimal_places=2, null=True,
blank=True)
[docs] def date(self):
'''alias of :attr:`start_date` for display, since reimbersument
is a single-day event'''
return self.partial_start_date
date.admin_order_field = 'start_date'
[docs] def save(self, *args, **kwargs):
'''Reimbursement is a single-day event; populate end date on save
to make that explicit and simplify any generic event date
range searching and filtering.'''
self.end_date = self.start_date
# copy precision in case of partially-known start date
self.end_date_precision = self.start_date_precision
super(Reimbursement, self).save(*args, **kwargs)
[docs] def validate_unique(self, *args, **kwargs):
'''Validation check to prevent duplicate events from being
added to the system. Does not allow more than one reimbursement
for the account and date. Used instead of
`unique_together` because of multi-table inheritance.'''
super(Reimbursement, self).validate_unique(*args, **kwargs)
# check to prevent duplicate event (reimbursement + date + account)
# should not have same date + account
try:
qs = Reimbursement.objects.filter(start_date=self.start_date,
account=self.account)
except ObjectDoesNotExist:
# bail out without making any further assertions because
# we've had a missing related field and other checks
# will catch it
return
# if current work is already saved, exclude it from the queryset
if not self._state.adding and self.pk is not None:
qs = qs.exclude(pk=self.pk)
if qs.exists():
raise ValidationError('Reimbursement event is not unique')