regluit/core/models/__init__.py

1417 lines
56 KiB
Python
Executable File

import binascii
import hashlib
import logging
import math
import random
import re
import urllib
import urllib2
from datetime import timedelta, datetime
from decimal import Decimal
from tempfile import SpooledTemporaryFile
import requests
from ckeditor.fields import RichTextField
from notification import models as notification
from postmonkey import PostMonkey, MailChimpException
#django imports
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.core.files.base import ContentFile
from django.db import models
from django.db.models import F, Q
from django.db.models.signals import post_save
from django.utils.translation import ugettext_lazy as _
#regluit imports
import regluit
import regluit.core.isbn
import regluit.core.cc as cc
from regluit.booxtream import BooXtream
from regluit.libraryauth.auth import AVATARS
from regluit.libraryauth.models import Library
from regluit.payment.parameters import (
TRANSACTION_STATUS_ACTIVE,
TRANSACTION_STATUS_COMPLETE,
TRANSACTION_STATUS_CANCELED,
TRANSACTION_STATUS_ERROR,
TRANSACTION_STATUS_FAILED,
TRANSACTION_STATUS_INCOMPLETE
)
from regluit.utils import crypto
from regluit.utils.localdatetime import now, date_today
from regluit.core.parameters import (
REWARDS,
BUY2UNGLUE,
THANKS,
INDIVIDUAL,
LIBRARY,
BORROWED,
TESTING,
RESERVE,
THANKED,
OFFER_CHOICES,
ACQ_CHOICES,
)
from regluit.core.epub import personalize, ungluify, ask_epub
from regluit.core.pdf import ask_pdf, pdf_append
from regluit.core import mobi
from regluit.core.signals import (
successful_campaign,
unsuccessful_campaign,
wishlist_added
)
watermarker = BooXtream()
from .bibmodels import (
Author,
Ebook,
EbookFile,
Edition,
EditionNote,
good_providers,
Identifier,
path_for_file,
Publisher,
PublisherName,
Relation,
Relator,
safe_get_work,
send_to_kindle_limit,
Subject,
WasWork,
Work,
WorkRelation,
)
pm = PostMonkey(settings.MAILCHIMP_API_KEY)
logger = logging.getLogger(__name__)
class UnglueitError(RuntimeError):
pass
class Key(models.Model):
"""an encrypted key store"""
name = models.CharField(max_length=255, unique=True)
encrypted_value = models.TextField(null=True, blank=True)
def _get_value(self):
return crypto.decrypt_string(binascii.a2b_hex(self.encrypted_value), settings.SECRET_KEY)
def _set_value(self, value):
self.encrypted_value = binascii.b2a_hex(crypto.encrypt_string(value, settings.SECRET_KEY))
value = property(_get_value, _set_value)
def __unicode__(self):
return "Key with name {0}".format(self.name)
class CeleryTask(models.Model):
created = models.DateTimeField(auto_now_add=True)
task_id = models.CharField(max_length=255)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="tasks", null=True)
description = models.CharField(max_length=2048, null=True) # a description of what the task is
function_name = models.CharField(max_length=1024) # used to reconstitute the AsyncTask with which to get status
function_args = models.IntegerField(null=True) # not full generalized here -- takes only a single arg for now.
active = models.NullBooleanField(default=True)
def __unicode__(self):
return "Task %s arg:%s ID# %s %s: State %s " % (self.function_name, self.function_args, self.task_id, self.description, self.state)
@property
def AsyncResult(self):
f = getattr(regluit.core.tasks, self.function_name)
return f.AsyncResult(self.task_id)
@property
def state(self):
try:
f = getattr(regluit.core.tasks, self.function_name)
return f.AsyncResult(self.task_id).state
except AttributeError:
return None
@property
def result(self):
f = getattr(regluit.core.tasks, self.function_name)
return f.AsyncResult(self.task_id).result
@property
def info(self):
f = getattr(regluit.core.tasks, self.function_name)
return f.AsyncResult(self.task_id).info
class Claim(models.Model):
STATUSES = ((u'active', u'Claim has been accepted.'),
(u'pending', u'Claim is pending acceptance.'),
(u'release', u'Claim has not been accepted.'),
)
created = models.DateTimeField(auto_now_add=True)
rights_holder = models.ForeignKey("RightsHolder", related_name="claim", null=False)
work = models.ForeignKey("Work", related_name="claim", null=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="claim", null=False)
status = models.CharField(max_length=7, choices=STATUSES, default='active')
@property
def can_open_new(self):
# whether a campaign can be opened for this claim
#must be an active claim
if self.status != 'active':
return False
#can't already be a campaign
for campaign in self.campaigns:
if campaign.status in ['ACTIVE', 'INITIALIZED']:
return 0 # cannot open a new campaign
if campaign.status in ['SUCCESSFUL']:
return 2 # can open a THANKS campaign
return 1 # can open any type of campaign
def __unicode__(self):
return self.work.title
@property
def campaign(self):
return self.work.last_campaign()
@property
def campaigns(self):
return self.work.campaigns.all()
def notify_claim(sender, created, instance, **kwargs):
if 'example.org' in instance.user.email or hasattr(instance, 'dont_notify'):
return
try:
(rights, new_rights) = User.objects.get_or_create(email='rights@gluejar.com', defaults={'username':'RightsatUnglueit'})
except:
rights = None
if instance.user == instance.rights_holder.owner:
ul = (instance.user, rights)
else:
ul = (instance.user, instance.rights_holder.owner, rights)
notification.send(ul, "rights_holder_claim", {'claim': instance,})
post_save.connect(notify_claim, sender=Claim)
class RightsHolder(models.Model):
created = models.DateTimeField(auto_now_add=True)
email = models.CharField(max_length=100, blank=True)
rights_holder_name = models.CharField(max_length=100, blank=False)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="rights_holder", null=False)
can_sell = models.BooleanField(default=False)
def __unicode__(self):
return self.rights_holder_name
class Premium(models.Model):
PREMIUM_TYPES = ((u'00', u'Default'), (u'CU', u'Custom'), (u'XX', u'Inactive'))
TIERS = {"supporter":25, "patron":50, "bibliophile":100} #should load this from fixture
created = models.DateTimeField(auto_now_add=True)
type = models.CharField(max_length=2, choices=PREMIUM_TYPES)
campaign = models.ForeignKey("Campaign", related_name="premiums", null=True)
amount = models.DecimalField(max_digits=10, decimal_places=0, blank=False)
description = models.TextField(null=True, blank=False)
limit = models.IntegerField(default=0)
@property
def premium_count(self):
t_model = apps.get_model('payment', 'Transaction')
return t_model.objects.filter(premium=self).count()
@property
def premium_remaining(self):
t_model = apps.get_model('payment', 'Transaction')
return self.limit - t_model.objects.filter(premium=self).count()
def __unicode__(self):
return (self.campaign.work.title if self.campaign else '') + ' $' + str(self.amount)
class PledgeExtra:
def __init__(self, premium=None, anonymous=False, ack_name='', ack_dedication='', offer=None):
self.anonymous = anonymous
self.premium = premium
self.offer = offer
self.extra = {}
if ack_name:
self.extra['ack_name'] = ack_name
if ack_dedication:
self.extra['ack_dedication'] = ack_dedication
class CampaignAction(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
# anticipated types: activated, withdrawn, suspended, restarted, succeeded, failed, unglued
type = models.CharField(max_length=15)
comment = models.TextField(null=True, blank=True)
campaign = models.ForeignKey("Campaign", related_name="actions", null=False)
class Offer(models.Model):
work = models.ForeignKey("Work", related_name="offers", null=False)
price = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=False)
license = models.PositiveSmallIntegerField(null=False, default=INDIVIDUAL,
choices=OFFER_CHOICES)
active = models.BooleanField(default=False)
@property
def days_per_copy(self):
return Decimal(float(self.price) / self.work.last_campaign().dollar_per_day)
@property
def get_thanks_display(self):
if self.license == LIBRARY:
return 'Suggested contribution for libraries'
else:
return 'Suggested contribution for individuals'
class Acq(models.Model):
"""
Short for Acquisition, this is a made-up word to describe the thing you acquire when you buy or borrow an ebook
"""
created = models.DateTimeField(auto_now_add=True, db_index=True,)
expires = models.DateTimeField(null=True)
refreshes = models.DateTimeField(auto_now_add=True)
refreshed = models.BooleanField(default=True)
work = models.ForeignKey("Work", related_name='acqs', null=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='acqs')
license = models.PositiveSmallIntegerField(null=False, default=INDIVIDUAL,
choices=ACQ_CHOICES)
watermarked = models.ForeignKey("booxtream.Boox", null=True)
nonce = models.CharField(max_length=32, null=True)
# when the acq is a loan, this points at the library's acq it's derived from
lib_acq = models.ForeignKey("self", related_name="loans", null=True)
class mock_ebook(object):
def __init__(self, acq):
self.url = acq.get_mobi_url()
self.format = 'mobi'
self.filesize = 0
def save(self):
return True
def get_archive(self):
try:
r = urllib2.urlopen(self.url)
try:
self.filesize = int(r.info().getheaders("Content-Length")[0])
except IndexError:
# response has no Content-Length header probably a bad link
logging.error('Bad link error: {}'.format(self.url))
return r
except IOError:
logger.error(u'could not open {}'.format(self.url))
def ebook(self):
return self.mock_ebook(self)
def __unicode__(self):
if self.lib_acq:
return "%s, %s: %s for %s" % (self.work.title, self.get_license_display(), self.lib_acq.user, self.user)
else:
return "%s, %s for %s" % (self.work.title, self.get_license_display(), self.user,)
@property
def expired(self):
if self.expires is None:
return False
else:
return self.expires < datetime.now()
def get_mobi_url(self):
if self.expired:
return ''
return self.get_watermarked().download_link_mobi
def get_epub_url(self):
if self.expired:
return ''
return self.get_watermarked().download_link_epub
def get_watermarked(self):
if self.watermarked is None or self.watermarked.expired:
if self.on_reserve:
self.borrow(self.user)
do_watermark = self.work.last_campaign().do_watermark
params = {
'customeremailaddress': self.user.email if do_watermark else '',
'customername': self.user.username if do_watermark else 'an ungluer',
'languagecode':'1033',
'expirydays': 1,
'downloadlimit': 7,
'exlibris':0,
'chapterfooter': 0,
'disclaimer':0,
'referenceid': '%s:%s:%s' % (self.work_id, self.user_id, self.id) if do_watermark else 'N/A',
'kf8mobi': True,
'epub': True,
}
personalized = personalize(self.work.epubfiles()[0].file, self)
personalized.seek(0)
self.watermarked = watermarker.platform(epubfile=personalized, **params)
self.save()
return self.watermarked
def _hash(self):
return hashlib.md5('%s:%s:%s:%s'%(settings.SOCIAL_AUTH_TWITTER_SECRET, self.user_id, self.work_id, self.created)).hexdigest()
def expire_in(self, delta):
self.expires = (now() + delta) if delta else now()
self.save()
if self.lib_acq:
self.lib_acq.refreshes = now() + delta
self.lib_acq.refreshed = False
self.lib_acq.save()
@property
def on_reserve(self):
return self.license == RESERVE
def borrow(self, user=None):
if self.on_reserve:
self.license = BORROWED
self.expire_in(timedelta(days=14))
self.user.wishlist.add_work(self.work, "borrow")
notification.send([self.user], "library_borrow", {'acq':self})
return self
elif self.borrowable and user:
user.wishlist.add_work(self.work, "borrow")
borrowed = Acq.objects.create(user=user, work=self.work, license=BORROWED, lib_acq=self)
from regluit.core.tasks import watermark_acq
notification.send([user], "library_borrow", {'acq':borrowed})
watermark_acq.delay(borrowed)
return borrowed
@property
def borrowable(self):
if self.license == RESERVE and not self.expired:
return True
if self.license == LIBRARY:
return self.refreshes < datetime.now()
else:
return False
@property
def holds(self):
return Hold.objects.filter(library__user=self.user, work=self.work).order_by('created')
def config_acq(sender, instance, created, **kwargs):
if created:
instance.nonce = instance._hash()
instance.save()
if instance.license == RESERVE:
instance.expire_in(timedelta(hours=24))
if instance.license == BORROWED:
instance.expire_in(timedelta(days=14))
post_save.connect(config_acq, sender=Acq)
class Hold(models.Model):
created = models.DateTimeField(auto_now_add=True)
work = models.ForeignKey("Work", related_name='holds', null=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='holds', null=False)
library = models.ForeignKey(Library, related_name='holds', null=False)
def __unicode__(self):
return '%s for %s at %s' % (self.work, self.user.username, self.library)
def ahead(self):
return Hold.objects.filter(work=self.work, library=self.library, created__lt=self.created).count()
STATUS_CHOICES = (
('INITIALIZED','INITIALIZED'),
('ACTIVE', 'ACTIVE'),
('SUSPENDED', 'SUSPENDED'),
('WITHDRAWN', 'WITHDRAWN'),
('SUCCESSFUL', 'SUCCESSFUL'),
('UNSUCCESSFUL', 'UNSUCCESSFUL')
)
class Campaign(models.Model):
LICENSE_CHOICES = cc.FREECHOICES
created = models.DateTimeField(auto_now_add=True,)
name = models.CharField(max_length=500, null=True, blank=False)
description = RichTextField(null=True, blank=False)
details = RichTextField(null=True, blank=True)
target = models.DecimalField(max_digits=14, decimal_places=2, null=True, default=0.00)
license = models.CharField(max_length=255, choices=LICENSE_CHOICES, default='CC BY-NC-ND')
left = models.DecimalField(max_digits=14, decimal_places=2, null=True, db_index=True,)
deadline = models.DateTimeField(db_index=True, null=True)
dollar_per_day = models.FloatField(null=True)
cc_date_initial = models.DateTimeField(null=True)
activated = models.DateTimeField(null=True, db_index=True,)
paypal_receiver = models.CharField(max_length=100, blank=True)
amazon_receiver = models.CharField(max_length=100, blank=True)
work = models.ForeignKey("Work", related_name="campaigns", null=False)
managers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="campaigns", null=False)
# status: INITIALIZED, ACTIVE, SUSPENDED, WITHDRAWN, SUCCESSFUL, UNSUCCESSFUL
status = models.CharField( max_length=15, null=True, blank=False, default="INITIALIZED",
db_index=True, choices=STATUS_CHOICES)
type = models.PositiveSmallIntegerField(null=False, default=REWARDS,
choices=((REWARDS, 'Pledge-to-unglue campaign'),
(BUY2UNGLUE, 'Buy-to-unglue campaign'),
(THANKS, 'Thanks-for-ungluing campaign'),
))
edition = models.ForeignKey("Edition", related_name="campaigns", null=True)
email = models.CharField(max_length=100, blank=True)
publisher = models.ForeignKey("Publisher", related_name="campaigns", null=True)
do_watermark = models.BooleanField(default=True)
use_add_ask = models.BooleanField(default=True)
def __init__(self, *args, **kwargs):
self.problems = []
super(Campaign, self).__init__(*args, **kwargs)
def __unicode__(self):
try:
return u"Campaign for %s" % self.work.title
except:
return u"Campaign %s (no associated work)" % self.name
def clone(self):
"""use a previous UNSUCCESSFUL campaign's data as the basis for a new campaign
assume that B2U campaigns don't need cloning
"""
if self.clonable():
old_managers = self.managers.all()
# copy custom premiums
new_premiums = self.premiums.filter(type='CU')
# setting pk to None will insert new copy https://stackoverflow.com/a/4736172/7782
self.pk = None
self.status = 'INITIALIZED'
# set deadline far in future
# presumably RH will set deadline to proper value before campaign launched
self.deadline = date_today() + timedelta(days=int(settings.UNGLUEIT_LONGEST_DEADLINE))
# allow created, activated dates to be autoset by db
self.created = None
self.name = 'copy of %s' % self.name
self.activated = None
self.update_left()
self.save()
self.managers = old_managers
# clone associated premiums
for premium in new_premiums:
premium.pk = None
premium.created = None
premium.campaign = self
premium.save()
return self
else:
return None
def clonable(self):
"""campaign clonable if it's UNSUCCESSFUL and is the last campaign associated with a Work"""
if self.status == 'UNSUCCESSFUL' and self.work.last_campaign().id == self.id:
return True
else:
return False
@property
def launchable(self):
may_launch = True
try:
if self.status != 'INITIALIZED':
if self.status == 'ACTIVE':
self.problems.append(_('The campaign is already launched'))
else:
self.problems.append(_('A campaign must initialized properly before it can be launched'))
may_launch = False
if not self.description:
self.problems.append(_('A campaign must have a description'))
may_launch = False
if self.type == REWARDS:
if self.deadline:
if self.deadline.date()- date_today() > timedelta(days=int(settings.UNGLUEIT_LONGEST_DEADLINE)):
self.problems.append(_('The chosen closing date is more than %s days from now' % settings.UNGLUEIT_LONGEST_DEADLINE))
may_launch = False
else:
self.problems.append(_('A pledge campaign must have a closing date'))
may_launch = False
if self.target:
if self.target < Decimal(settings.UNGLUEIT_MINIMUM_TARGET):
self.problems.append(_('A pledge campaign may not be launched with a target less than $%s' % settings.UNGLUEIT_MINIMUM_TARGET))
may_launch = False
else:
self.problems.append(_('A campaign must have a target'))
may_launch = False
if self.type == BUY2UNGLUE:
if self.work.offers.filter(price__gt=0, active=True).count() == 0:
self.problems.append(_('You can\'t launch a buy-to-unglue campaign before setting a price for your ebooks'))
may_launch = False
if EbookFile.objects.filter(edition__work=self.work).count() == 0:
self.problems.append(_('You can\'t launch a buy-to-unglue campaign if you don\'t have any ebook files uploaded'))
may_launch = False
if (self.cc_date_initial is None) or (self.cc_date_initial > datetime.combine(settings.MAX_CC_DATE, datetime.min.time())) or (self.cc_date_initial < now()):
self.problems.append(_('You must set an initial Ungluing Date that is in the future and not after %s' % settings.MAX_CC_DATE))
may_launch = False
if self.target:
if self.target < Decimal(settings.UNGLUEIT_MINIMUM_TARGET):
self.problems.append(_('A buy-to-unglue campaign may not be launched with a target less than $%s' % settings.UNGLUEIT_MINIMUM_TARGET))
may_launch = False
else:
self.problems.append(_('A buy-to-unglue campaign must have a target'))
may_launch = False
if self.type == THANKS:
# the case in which there is no EbookFile and no Ebook associated with work (We have ebooks without ebook files.)
if EbookFile.objects.filter(edition__work=self.work).count() == 0 and self.work.ebooks().count() == 0:
self.problems.append(_('You can\'t launch a thanks-for-ungluing campaign if you don\'t have any ebook files uploaded'))
may_launch = False
except Exception as e:
self.problems.append('Exception checking launchability ' + str(e))
may_launch = False
return may_launch
def update_status(self, ignore_deadline_for_success=False, send_notice=False, process_transactions=False):
"""Updates the campaign's status. returns true if updated.
for REWARDS:
Computes UNSUCCESSFUL only after the deadline has passed
Computes SUCCESSFUL only after the deadline has passed if ignore_deadline_for_success is TRUE -- otherwise looks just at amount of pledges accumulated
by default, send_notice is False so that we have to explicitly send specify delivery of successful_campaign notice
for BUY2UNGLUE:
Sets SUCCESSFUL when cc_date is in the past.
if process_transactions is True, also execute or cancel associated transactions
"""
if not self.status == 'ACTIVE':
return False
elif self.type == REWARDS:
if (ignore_deadline_for_success or self.deadline < now()) and self.current_total >= self.target:
self.status = 'SUCCESSFUL'
self.save()
action = CampaignAction(campaign=self, type='succeeded', comment=self.current_total)
action.save()
if process_transactions:
p = PaymentManager()
results = p.execute_campaign(self)
if send_notice:
successful_campaign.send(sender=None, campaign=self)
# should be more sophisticated in whether to return True -- look at all the transactions?
return True
elif self.deadline < now() and self.current_total < self.target:
self.status = 'UNSUCCESSFUL'
self.save()
action = CampaignAction(campaign=self, type='failed', comment=self.current_total)
action.save()
if process_transactions:
p = PaymentManager()
results = p.cancel_campaign(self)
if send_notice:
regluit.core.signals.unsuccessful_campaign.send(sender=None, campaign=self)
# should be more sophisticated in whether to return True -- look at all the transactions?
return True
elif self.type == BUY2UNGLUE:
if self.cc_date < date_today():
self.status = 'SUCCESSFUL'
self.save()
action = CampaignAction(campaign=self, type='succeeded', comment=self.current_total)
action.save()
self.watermark_success()
if send_notice:
successful_campaign.send(sender=None, campaign=self)
# should be more sophisticated in whether to return True -- look at all the transactions?
return True
return False
_current_total = None
@property
def current_total(self):
if self._current_total is None:
p = PaymentManager()
self._current_total = p.query_campaign(self, summary=True, campaign_total=True)
return self._current_total
def set_dollar_per_day(self):
if self.status != 'INITIALIZED' and self.dollar_per_day:
return self.dollar_per_day
if self.cc_date_initial is None:
return None
start_datetime = self.activated if self.activated else datetime.today()
time_to_cc = self.cc_date_initial - start_datetime
self.dollar_per_day = float(self.target)/float(time_to_cc.days)
if self.status != 'DEMO':
self.save()
return self.dollar_per_day
def set_cc_date_initial(self, a_date=settings.MAX_CC_DATE):
self.cc_date_initial = datetime.combine(a_date, datetime.min.time())
@property
def cc_date(self):
if self.type in {REWARDS, THANKS}:
return None
if self.dollar_per_day is None:
return self.cc_date_initial.date()
cc_advance_days = float(self.current_total) / self.dollar_per_day
return (self.cc_date_initial-timedelta(days=cc_advance_days)).date()
def update_left(self):
self._current_total = None
if self.type == THANKS:
self.left = Decimal(0.00)
elif self.type == BUY2UNGLUE:
self.left = Decimal(self.dollar_per_day*float((self.cc_date_initial - datetime.today()).days))-self.current_total
else:
self.left = self.target - self.current_total
if self.status != 'DEMO':
self.save()
def transactions(self, **kwargs):
p = PaymentManager()
# handle default parameter values
kw = {'summary':False, 'campaign_total':True}
kw.update(kwargs)
return p.query_campaign(self, **kw)
def activate(self):
status = self.status
if status != 'INITIALIZED':
raise UnglueitError(_('Campaign needs to be initialized in order to be activated'))
try:
active_claim = self.work.claim.filter(status="active")[0]
except IndexError, e:
raise UnglueitError(_('Campaign needs to have an active claim in order to be activated'))
if not self.launchable:
raise UnglueitError('Configuration issues need to be addressed before campaign is activated: %s' % unicode(self.problems[0]))
self.status = 'ACTIVE'
self.left = self.target
self.activated = datetime.today()
if self.type == THANKS:
# make ebooks from ebookfiles
if self.use_add_ask:
self.add_ask_to_ebfs()
else:
self.revert_asks()
self.work.remove_old_ebooks()
self.save()
action = CampaignAction(campaign=self, type='activated', comment=self.get_type_display())
ungluers = self.work.wished_by()
notification.queue(ungluers, "wishlist_active", {'campaign':self}, True)
return self
def suspend(self, reason):
status = self.status
if status != 'ACTIVE':
raise UnglueitError(_('Campaign needs to be active in order to be suspended'))
action = CampaignAction(campaign=self, type='suspended', comment=reason)
action.save()
self.status = 'SUSPENDED'
self.save()
return self
def withdraw(self, reason):
status = self.status
if status != 'ACTIVE':
raise UnglueitError(_('Campaign needs to be active in order to be withdrawn'))
action = CampaignAction(campaign=self, type='withdrawn', comment=reason)
action.save()
self.status = 'WITHDRAWN'
self.save()
return self
def resume(self, reason):
"""Change campaign status from SUSPENDED to ACTIVE. We may want to track reason for resuming and track history"""
status = self.status
if status != 'SUSPENDED':
raise UnglueitError(_('Campaign needs to be suspended in order to be resumed'))
if not reason:
reason = ''
action = CampaignAction(campaign=self, type='restarted', comment=reason)
action.save()
self.status = 'ACTIVE'
self.save()
return self
def supporters(self):
# expensive query used in loop; stash it
if hasattr(self, '_translist_'):
return self._translist_
"""nb: returns (distinct) supporter IDs, not supporter objects"""
self._translist_ = self.transactions().filter(status=TRANSACTION_STATUS_ACTIVE).values_list('user', flat=True).distinct()
return self._translist_
@property
def supporters_count(self):
# avoid transmitting the whole list if you don't need to; let the db do the count.
active = self.transactions().filter(status=TRANSACTION_STATUS_ACTIVE).values_list('user', flat=True).distinct().count()
complete = self.transactions().filter(status=TRANSACTION_STATUS_COMPLETE).values_list('user', flat=True).distinct().count()
return active+complete
@property
def anon_count(self):
# avoid transmitting the whole list if you don't need to; let the db do the count.
complete = self.transactions().filter(status=TRANSACTION_STATUS_COMPLETE, user=None).count()
return complete
def transaction_to_recharge(self, user):
"""given a user, return the transaction to be recharged if there is one -- None otherwise"""
# only if a campaign is SUCCESSFUL, we allow for recharged
if self.status == 'SUCCESSFUL':
if self.transaction_set.filter(Q(user=user) & (Q(status=TRANSACTION_STATUS_COMPLETE) | Q(status=TRANSACTION_STATUS_ACTIVE))).count():
# presence of an active or complete transaction means no transaction to recharge
return None
else:
transactions = self.transaction_set.filter(Q(user=user) & (Q(status=TRANSACTION_STATUS_ERROR) | Q(status=TRANSACTION_STATUS_FAILED)))
# assumption --that the first failed/errored transaction has the amount we need to recharge
if transactions.count():
return transactions[0]
else:
return None
else:
return None
def ungluers(self):
# expensive query used in loop; stash it
if hasattr(self, '_ungluers_'):
return self._ungluers_
p = PaymentManager()
ungluers = {"all":[], "supporters":[], "patrons":[], "bibliophiles":[]}
if self.status == "ACTIVE":
translist = p.query_campaign(self, summary=False, pledged=True, authorized=True)
elif self.status == "SUCCESSFUL":
translist = p.query_campaign(self, summary=False, pledged=True, completed=True)
else:
translist = []
for transaction in translist:
ungluers['all'].append(transaction.user)
if not transaction.anonymous:
if transaction.amount >= Premium.TIERS["bibliophile"]:
ungluers['bibliophiles'].append(transaction.user)
elif transaction.amount >= Premium.TIERS["patron"]:
ungluers['patrons'].append(transaction.user)
elif transaction.amount >= Premium.TIERS["supporter"]:
ungluers['supporters'].append(transaction.user)
self._ungluers_ = ungluers
return ungluers
def ungluer_transactions(self):
"""
returns a list of authorized transactions for campaigns in progress,
or completed transactions for successful campaigns
used to build the acks page -- because ack_name, _link, _dedication adhere to transactions,
it's easier to return transactions than ungluers
"""
p = PaymentManager()
ungluers = {"all":[], "supporters":[], "anon_supporters": 0, "patrons":[], "anon_patrons": 0, "bibliophiles":[]}
if self.status == "ACTIVE":
translist = p.query_campaign(self, summary=False, pledged=True, authorized=True)
elif self.status == "SUCCESSFUL":
translist = p.query_campaign(self, summary=False, pledged=True, completed=True)
else:
translist = []
for transaction in translist:
ungluers['all'].append(transaction)
if transaction.amount >= Premium.TIERS["bibliophile"]:
ungluers['bibliophiles'].append(transaction)
elif transaction.amount >= Premium.TIERS["patron"]:
if transaction.anonymous:
ungluers['anon_patrons'] += 1
else:
ungluers['patrons'].append(transaction)
elif transaction.amount >= Premium.TIERS["supporter"]:
if transaction.anonymous:
ungluers['anon_supporters'] += 1
else:
ungluers['supporters'].append(transaction)
return ungluers
def effective_premiums(self):
"""returns the available premiums for the Campaign including any default premiums"""
if self.type is BUY2UNGLUE:
return Premium.objects.none()
q = Q(campaign=self) | Q(campaign__isnull=True)
return Premium.objects.filter(q).exclude(type='XX').order_by('amount')
def custom_premiums(self):
"""returns only the active custom premiums for the Campaign"""
if self.type is BUY2UNGLUE:
return Premium.objects.none()
return Premium.objects.filter(campaign=self).filter(type='CU').order_by('amount')
@property
def library_offer(self):
return self._offer(LIBRARY)
@property
def individual_offer(self):
return self._offer(INDIVIDUAL)
def _offer(self, license):
if self.type is REWARDS:
return None
try:
return Offer.objects.get(work=self.work, active=True, license=license)
except Offer.DoesNotExist:
return None
@property
def ask_money(self):
# true if there's an offer asking for money
if self.type is REWARDS:
return True
try:
Offer.objects.get(work=self.work, active=True, price__gt=0.00)
return True
except Offer.DoesNotExist:
return False
except Offer.MultipleObjectsReturned:
return True
@property
def days_per_copy(self):
if self.individual_offer:
return Decimal(float(self.individual_offer.price) / self.dollar_per_day)
else:
return Decimal(0)
@property
def rh(self):
"""returns the rights holder for an active or initialized campaign"""
try:
q = Q(status='ACTIVE') | Q(status='INITIALIZED')
rh = self.work.claim.filter(q)[0].rights_holder
return rh
except:
return None
@property
def rightsholder(self):
"""returns the name of the rights holder for an active or initialized campaign"""
try:
return self.rh.rights_holder_name
except:
return ''
@property
def license_url(self):
return cc.CCLicense.url(self.license)
@property
def license_badge(self):
return cc.CCLicense.badge(self.license)
@property
def success_date(self):
if self.status == 'SUCCESSFUL':
try:
return self.actions.filter(type='succeeded')[0].timestamp
except:
return ''
return ''
def percent_of_goal(self):
if self.type == THANKS:
return 100
percent = 0
if self.status == 'SUCCESSFUL' or self.status == 'ACTIVE':
if self.type == BUY2UNGLUE:
percent = int(100 - 100*self.left/self.target)
else:
percent = int(self.current_total/self.target*100)
return percent
@property
def countdown(self):
from math import ceil
if not self.deadline:
return ''
time_remaining = self.deadline - now()
countdown = ""
if time_remaining.days:
countdown = "%s days" % str(time_remaining.days + 1)
elif time_remaining.seconds > 3600:
countdown = "%s hours" % str(time_remaining.seconds/3600 + 1)
elif time_remaining.seconds > 60:
countdown = "%s minutes" % str(time_remaining.seconds/60 + 1)
else:
countdown = "Seconds"
return countdown
@classmethod
def latest_ending(cls):
return timedelta(days=int(settings.UNGLUEIT_LONGEST_DEADLINE)) + now()
def make_mobis(self):
# make archive files for ebooks, make mobi files for epubs
versions = set()
for ebook in self.work.ebooks().filter(provider__in=good_providers, format='mobi'):
versions.add(ebook.version_label)
for ebook in self.work.ebooks_all().exclude(provider='Unglue.it').filter(provider__in=good_providers, format='epub'):
if not ebook.version_label in versions:
# now make the mobi file
ebf = ebook.get_archive_ebf()
ebf.make_mobi()
def add_ask_to_ebfs(self, position=0):
if not self.use_add_ask or self.type != THANKS:
return
format_versions = []
to_dos = []
for ebf in self.work.ebookfiles().filter(asking=False, ebook__provider='Unglue.it').order_by('-created'):
format_version = '{}_{}'.format(ebf.ebook.format, ebf.ebook.version_label)
if ebf.format in ('pdf', 'epub') and not format_version in format_versions:
ebf.file.open()
to_dos.append({'content': ebf.file.read(), 'ebook': ebf.ebook})
format_versions.append(format_version)
for ebook in self.work.ebooks_all().exclude(provider='Unglue.it').filter(provider__in=good_providers):
format_version = '{}_{}'.format(ebook.format, ebook.version_label)
if ebook.format in ('pdf', 'epub') and not format_version in format_versions:
to_dos.append({'content': ebook.get_archive().read(), 'ebook': ebook})
format_versions.append(format_version)
new_ebfs = []
for to_do in to_dos:
edition = to_do['ebook'].edition
version = {'label':to_do['ebook'].version_label, 'iter':to_do['ebook'].version_iter}
if to_do['ebook'].format == 'pdf':
try:
added = ask_pdf({'campaign':self, 'work':self.work, 'site':Site.objects.get_current()})
new_file = SpooledTemporaryFile()
old_file = SpooledTemporaryFile()
old_file.write(to_do['content'])
if position == 0:
pdf_append(added, old_file, new_file)
else:
pdf_append(old_file, added, new_file)
new_file.seek(0)
new_pdf_ebf = EbookFile.objects.create(edition=edition, format='pdf', asking=True)
new_pdf_ebf.version = version
new_pdf_ebf.file.save(path_for_file('ebf', None), ContentFile(new_file.read()))
new_pdf_ebf.save()
new_ebfs.append(new_pdf_ebf)
except Exception as e:
logger.error("error appending pdf ask %s" % (e))
elif to_do['ebook'].format == 'epub':
try:
old_file = SpooledTemporaryFile()
old_file.write(to_do['content'])
new_file = ask_epub(old_file, {'campaign':self, 'work':self.work, 'site':Site.objects.get_current()})
new_file.seek(0)
new_epub_ebf = EbookFile.objects.create(edition=edition, format='epub', asking=True)
new_epub_ebf.file.save(path_for_file(new_epub_ebf, None), ContentFile(new_file.read()))
new_epub_ebf.save()
new_epub_ebf.version = version
new_ebfs.append(new_epub_ebf)
# now make the mobi file
new_mobi_ebf = EbookFile.objects.create(edition=edition, format='mobi', asking=True)
new_mobi_ebf.file.save(path_for_file('ebf', None), ContentFile(mobi.convert_to_mobi(new_epub_ebf.file.url)))
new_mobi_ebf.save()
new_mobi_ebf.version = version
new_ebfs.append(new_mobi_ebf)
except Exception as e:
logger.error("error making epub ask or mobi %s" % (e))
for ebf in new_ebfs:
ebook = Ebook.objects.create(
edition=ebf.edition,
format=ebf.format,
rights=self.license,
provider="Unglue.it",
url=ebf.file.url,
version_label=ebf.version['label'],
version_iter=ebf.version['iter'],
filesize=ebf.file.size,
)
ebf.ebook = ebook
ebf.save()
new_ebf_pks = [ebf.pk for ebf in new_ebfs]
for old_ebf in self.work.ebookfiles().filter(asking=True).exclude(pk__in=new_ebf_pks):
obsolete = Ebook.objects.filter(url=old_ebf.file.url)
if old_ebf.ebook:
old_ebf.ebook.deactivate()
old_ebf.file.delete()
old_ebf.delete()
for non_asking in self.work.ebookfiles().filter(asking=False, ebook__active=True):
non_asking.ebook.deactivate()
def revert_asks(self):
# there should be a deactivated non-asking ebook for every asking ebook
if self.type != THANKS: # just to make sure that ebf's can be unglued by mistake
return
format_versions = []
for ebf in EbookFile.objects.filter(edition__work=self.work).exclude(file='').exclude(ebook=None).order_by('-created'):
format_version = '{}_{}'.format(ebf.format, ebf.ebook.version_label)
if ebf.asking:
ebf.ebook.deactivate()
elif format_version in format_versions:
# this ebook file has the wrong "asking"
ebf.ebook.deactivate()
else:
ebf.ebook.activate()
format_versions.append(format_version)
def make_unglued_ebf(self, format, watermarked):
r = urllib2.urlopen(watermarked.download_link(format))
ebf = EbookFile.objects.create(edition=self.work.preferred_edition, format=format)
ebf.file.save(path_for_file(ebf, None), ContentFile(r.read()))
ebf.file.close()
ebf.save()
ebook = Ebook.objects.create(
edition=self.work.preferred_edition,
format=format,
rights=self.license,
provider="Unglue.it",
url=settings.BASE_URL_SECURE + reverse('download_campaign', args=[self.work_id, format]),
version='unglued',
)
old_ebooks = Ebook.objects.exclude(pk=ebook.pk).filter(
edition=self.work.preferred_edition,
format=format,
rights=self.license,
provider="Unglue.it",
)
for old_ebook in old_ebooks:
old_ebook.deactivate()
return ebook.pk
def watermark_success(self):
if self.status == 'SUCCESSFUL' and self.type == BUY2UNGLUE:
params = {
'customeremailaddress': self.license,
'customername': 'The Public',
'languagecode':'1033',
'expirydays': 1,
'downloadlimit': 7,
'exlibris':0,
'chapterfooter':0,
'disclaimer':0,
'referenceid': '%s:%s:%s' % (self.work_id, self.id, self.license),
'kf8mobi': True,
'epub': True,
}
ungluified = ungluify(self.work.epubfiles()[0].file, self)
ungluified.filename.seek(0)
watermarked = watermarker.platform(epubfile=ungluified.filename, **params)
self.make_unglued_ebf('epub', watermarked)
self.make_unglued_ebf('mobi', watermarked)
return True
return False
def is_pledge(self):
return self.type == REWARDS
@property
def user_to_pay(self):
return self.rh.owner
### for compatibility with MARC output
def marc_records(self):
return self.work.marc_records()
class Wishlist(models.Model):
created = models.DateTimeField(auto_now_add=True)
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='wishlist')
works = models.ManyToManyField('Work', related_name='wishlists', through='Wishes')
def __unicode__(self):
return "%s's Books" % self.user.username
def add_work(self, work, source, notify=False):
try:
w = Wishes.objects.get(wishlist=self, work=work)
except:
Wishes.objects.create(source=source, wishlist=self, work=work)
work.update_num_wishes()
# only send notification in case of new wishes
# and only when they result from user action, not (e.g.) our tests
if notify:
wishlist_added.send(sender=self, work=work, supporter=self.user)
def remove_work(self, work):
w = Wishes.objects.filter(wishlist=self, work=work)
if w:
w.delete()
work.update_num_wishes()
def work_source(self, work):
w = Wishes.objects.filter(wishlist=self, work=work)
if w:
return w[0].source
else:
return ''
class Wishes(models.Model):
created = models.DateTimeField(auto_now_add=True, db_index=True,)
source = models.CharField(max_length=15, blank=True, db_index=True,)
wishlist = models.ForeignKey('Wishlist')
work = models.ForeignKey('Work', related_name='wishes')
class Meta:
db_table = 'core_wishlist_works'
class Badge(models.Model):
name = models.CharField(max_length=72, blank=True)
description = models.TextField(default='', null=True)
@property
def path(self):
return '/static/images/%s.png' % self.name
def __unicode__(self):
return self.name
def pledger():
if not pledger.instance:
pledger.instance = Badge.objects.get(name='pledger')
return pledger.instance
pledger.instance = None
def pledger2():
if not pledger2.instance:
pledger2.instance = Badge.objects.get(name='pledger2')
return pledger2.instance
pledger2.instance = None
ANONYMOUS_AVATAR = '/static/images/header/avatar.png'
(NO_AVATAR, GRAVATAR, TWITTER, FACEBOOK, UNGLUEITAR) = AVATARS
class Libpref(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='libpref')
marc_link_target = models.CharField(
max_length=6,
default='UNGLUE',
choices=settings.MARC_PREF_OPTIONS,
verbose_name="MARC record link targets",
)
class UserProfile(models.Model):
created = models.DateTimeField(auto_now_add=True)
user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='profile')
tagline = models.CharField(max_length=140, blank=True)
pic_url = models.URLField(blank=True)
home_url = models.URLField(blank=True)
twitter_id = models.CharField(max_length=15, blank=True)
facebook_id = models.BigIntegerField(null=True, blank=True)
librarything_id = models.CharField(max_length=31, blank=True)
badges = models.ManyToManyField('Badge', related_name='holders', blank=True)
kindle_email = models.EmailField(max_length=254, blank=True)
# keep track of work the user adds
works = models.ManyToManyField('Work', related_name='contributors', blank=True)
goodreads_user_id = models.CharField(max_length=32, null=True, blank=True)
goodreads_user_name = models.CharField(max_length=200, null=True, blank=True)
goodreads_auth_token = models.TextField(null=True, blank=True)
goodreads_auth_secret = models.TextField(null=True, blank=True)
goodreads_user_link = models.CharField(max_length=200, null=True, blank=True)
avatar_source = models.PositiveSmallIntegerField(
null=True,
default=UNGLUEITAR,
choices=(
(NO_AVATAR, 'No Avatar, Please'),
(GRAVATAR, 'Gravatar'),
(TWITTER, 'Twitter'),
(FACEBOOK, 'Facebook'),
(UNGLUEITAR, 'Unglueitar'),
)
)
def __unicode__(self):
return self.user.username
def reset_pledge_badge(self):
#count user pledges
n_pledges = self.pledge_count
if self.badges.exists():
self.badges.remove(pledger())
self.badges.remove(pledger2())
if n_pledges == 1:
self.badges.add(pledger())
elif n_pledges > 1:
self.badges.add(pledger2())
@property
def pledge_count(self):
return self.user.transaction_set.exclude(status='NONE').exclude(status='Canceled', reason=None).exclude(anonymous=True).count()
@property
def account(self):
# there should be only one active account per user
accounts = self.user.account_set.filter(date_deactivated__isnull=True)
if accounts.count() == 0:
return None
else:
return accounts[0]
@property
def old_account(self):
accounts = self.user.account_set.filter(date_deactivated__isnull=False).order_by('-date_deactivated')
if accounts.count() == 0:
return None
else:
return accounts[0]
@property
def pledges(self):
return self.user.transaction_set.filter(status=TRANSACTION_STATUS_ACTIVE, campaign__type=1)
@property
def last_transaction(self):
from regluit.payment.models import Transaction
try:
return Transaction.objects.filter(user=self.user).order_by('-date_modified')[0]
except IndexError:
return None
@property
def ack_name(self):
# use preferences from last transaction, if any
last = self.last_transaction
if last and last.extra:
return last.extra.get('ack_name', self.user.username)
else:
return self.user.username
@property
def anon_pref(self):
# use preferences from last transaction, if any
last = self.last_transaction
if last:
return last.anonymous
else:
return None
@property
def on_ml(self):
if "@example.org" in self.user.email:
# use @example.org email addresses for testing!
return False
try:
return settings.MAILCHIMP_NEWS_ID in pm.listsForEmail(email_address=self.user.email)
except MailChimpException, e:
if e.code != 215: # don't log case where user is not on a list
logger.error("error getting mailchimp status %s" % (e))
except Exception, e:
logger.error("error getting mailchimp status %s" % (e))
return False
def ml_subscribe(self, **kwargs):
if "@example.org" in self.user.email:
# use @example.org email addresses for testing!
return True
from regluit.core.tasks import ml_subscribe_task
ml_subscribe_task.delay(self, **kwargs)
def ml_unsubscribe(self):
if "@example.org" in self.user.email:
# use @example.org email addresses for testing!
return True
try:
return pm.listUnsubscribe(id=settings.MAILCHIMP_NEWS_ID, email_address=self.user.email)
except Exception, e:
logger.error("error unsubscribing from mailchimp list %s" % (e))
return False
def gravatar(self):
# construct the url
gravatar_url = "https://www.gravatar.com/avatar/" + hashlib.md5(self.user.email.lower()).hexdigest() + "?"
gravatar_url += urllib.urlencode({'d':'wavatar', 's':'50'})
return gravatar_url
def unglueitar(self):
# construct the url
gravatar_url = "https://www.gravatar.com/avatar/" + hashlib.md5(urllib.quote_plus(self.user.username.encode('utf-8')) + '@unglue.it').hexdigest() + "?"
gravatar_url += urllib.urlencode({'d':'wavatar', 's':'50'})
return gravatar_url
@property
def avatar_url(self):
if self.avatar_source is None or self.avatar_source is TWITTER:
if self.pic_url:
return self.pic_url
else:
return ANONYMOUS_AVATAR
elif self.avatar_source == UNGLUEITAR:
return self.unglueitar()
elif self.avatar_source == GRAVATAR:
return self.gravatar()
elif self.avatar_source == FACEBOOK and self.facebook_id != None:
return 'https://graph.facebook.com/v2.3/' + str(self.facebook_id) + '/picture?redirect=true'
else:
return ANONYMOUS_AVATAR
@property
def social_auths(self):
socials = self.user.social_auth.all()
auths = {}
for social in socials:
auths[social.provider] = True
return auths
@property
def libraries(self):
libs = []
for group in self.user.groups.all():
try:
libs.append(group.library)
except Library.DoesNotExist:
pass
return libs
class Press(models.Model):
url = models.URLField()
title = models.CharField(max_length=140)
source = models.CharField(max_length=140)
date = models.DateField(db_index=True,)
language = models.CharField(max_length=20, blank=True)
highlight = models.BooleanField(default=False)
note = models.CharField(max_length=140, blank=True)
class Gift(models.Model):
# the acq will contain the recipient, and the work
acq = models.ForeignKey('Acq', related_name='gifts')
to = models.CharField(max_length=75, blank=True) # store the email address originally sent to, not necessarily the email of the recipient
giver = models.ForeignKey(User, related_name='gifts')
message = models.TextField(max_length=512, default='')
used = models.DateTimeField(null=True)
@staticmethod
def giftee(email, t_id):
# return a user (create a user if necessary)
(giftee, new_user) = User.objects.get_or_create(email=email, defaults={'username':'giftee%s' % t_id})
giftee.new_user = new_user
return giftee
def use(self):
self.used = now()
self.save()
notification.send([self.giver], "purchase_got_gift", {'gift': self}, True)
# this was causing a circular import problem and we do not seem to be using
# anything from regluit.core.signals after this line
# from regluit.core import signals
from regluit.payment.manager import PaymentManager