addons-server/apps/stats/models.py

453 строки
16 KiB
Python

import datetime
from django.conf import settings
from django.db import models
from django.template import Context, loader
from django.utils import translation
from babel import Locale, numbers
import caching.base
from jingo import env
from jinja2.filters import do_dictsort
import tower
from tower import ugettext as _
import amo
from amo.helpers import absolutify, urlparams
from amo.models import ModelBase, SearchMixin
from amo.fields import DecimalCharField
from amo.utils import send_mail, send_mail_jinja
from zadmin.models import DownloadSource
from .db import StatsDictField
class AddonCollectionCount(models.Model):
addon = models.ForeignKey('addons.Addon')
collection = models.ForeignKey('bandwagon.Collection')
count = models.PositiveIntegerField()
date = models.DateField()
class Meta:
db_table = 'stats_addons_collections_counts'
class CollectionCount(SearchMixin, models.Model):
collection = models.ForeignKey('bandwagon.Collection')
count = models.PositiveIntegerField()
date = models.DateField()
class Meta:
db_table = 'stats_collections_counts'
class CollectionStats(models.Model):
"""In the running for worst-named model ever."""
collection = models.ForeignKey('bandwagon.Collection')
name = models.CharField(max_length=255, null=True)
count = models.PositiveIntegerField()
date = models.DateField()
class Meta:
db_table = 'stats_collections'
class DownloadCount(SearchMixin, models.Model):
addon = models.ForeignKey('addons.Addon')
count = models.PositiveIntegerField()
date = models.DateField()
sources = StatsDictField(db_column='src', null=True)
class Meta:
db_table = 'download_counts'
class UpdateCount(SearchMixin, models.Model):
addon = models.ForeignKey('addons.Addon')
count = models.PositiveIntegerField()
date = models.DateField()
versions = StatsDictField(db_column='version', null=True)
statuses = StatsDictField(db_column='status', null=True)
applications = StatsDictField(db_column='application', null=True)
oses = StatsDictField(db_column='os', null=True)
locales = StatsDictField(db_column='locale', null=True)
class Meta:
db_table = 'update_counts'
class AddonShareCount(models.Model):
addon = models.ForeignKey('addons.Addon')
count = models.PositiveIntegerField()
service = models.CharField(max_length=255, null=True)
date = models.DateField()
class Meta:
db_table = 'stats_share_counts'
class AddonShareCountTotal(models.Model):
addon = models.ForeignKey('addons.Addon')
count = models.PositiveIntegerField()
service = models.CharField(max_length=255, null=True)
class Meta:
db_table = 'stats_share_counts_totals'
# stats_collections_share_counts exists too, but we don't touch it.
class CollectionShareCountTotal(models.Model):
collection = models.ForeignKey('bandwagon.Collection')
count = models.PositiveIntegerField()
service = models.CharField(max_length=255, null=True)
class Meta:
db_table = 'stats_collections_share_counts_totals'
class ContributionError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class Contribution(amo.models.ModelBase):
# TODO(addon): figure out what to do when we delete the add-on.
addon = models.ForeignKey('addons.Addon')
amount = DecimalCharField(max_digits=9, decimal_places=2,
nullify_invalid=True, null=True)
currency = models.CharField(max_length=3,
choices=do_dictsort(amo.PAYPAL_CURRENCIES),
default=amo.CURRENCY_DEFAULT)
source = models.CharField(max_length=255, null=True)
client_data = models.ForeignKey('stats.ClientData', null=True)
source_locale = models.CharField(max_length=10, null=True)
uuid = models.CharField(max_length=255, null=True)
comment = models.CharField(max_length=255)
transaction_id = models.CharField(max_length=255, null=True)
bluevia_transaction_id = models.CharField(max_length=255, null=True)
paykey = models.CharField(max_length=255, null=True)
post_data = StatsDictField(null=True)
# Voluntary Contribution specific.
charity = models.ForeignKey('addons.Charity', null=True)
annoying = models.PositiveIntegerField(default=0,
choices=amo.CONTRIB_CHOICES,)
is_suggested = models.BooleanField()
suggested_amount = DecimalCharField(max_digits=254, decimal_places=2,
nullify_invalid=True, null=True)
# Marketplace specific.
# TODO(andym): figure out what to do when we delete the user.
user = models.ForeignKey('users.UserProfile', blank=True, null=True)
type = models.PositiveIntegerField(default=amo.CONTRIB_TYPE_DEFAULT,
choices=do_dictsort(amo.CONTRIB_TYPES))
price_tier = models.ForeignKey('market.Price', blank=True, null=True,
on_delete=models.PROTECT)
# If this is a refund or a chargeback, which charge did it relate to.
related = models.ForeignKey('self', blank=True, null=True,
on_delete=models.PROTECT)
class Meta:
db_table = 'stats_contributions'
def __unicode__(self):
return u'%s: %s' % (self.addon.name, self.amount)
@property
def date(self):
try:
return datetime.date(self.created.year,
self.created.month, self.created.day)
except AttributeError:
# created may be None
return None
@property
def contributor(self):
try:
return u'%s %s' % (self.post_data['first_name'],
self.post_data['last_name'])
except (TypeError, KeyError):
# post_data may be None or missing a key
return None
@property
def email(self):
try:
return self.post_data['payer_email']
except (TypeError, KeyError):
# post_data may be None or missing a key
return None
def handle_chargeback(self, reason):
"""
Hook to handle a payment chargeback.
When a chargeback is received from a PayPal IPN
for this contribution, the hook is called.
reason is either 'reversal' or 'refund'
"""
# Sigh. AMO does not have inapp_pay installed and it does not have
# the database tables. Since both mkt and AMO share this code we
# need to hide it from AMO.
if ('mkt.inapp_pay' in settings.INSTALLED_APPS
and self.inapp_payment.count()):
self.inapp_payment.get().handle_chargeback(reason)
def _switch_locale(self):
if self.source_locale:
lang = self.source_locale
else:
lang = self.addon.default_locale
tower.activate(lang)
return Locale(translation.to_locale(lang))
def _mail(self, template, subject, context):
template = env.get_template(template)
body = template.render(context)
send_mail(subject, body, settings.MARKETPLACE_EMAIL,
[self.user.email], fail_silently=True)
def mail_chargeback(self):
"""Send to the purchaser of an add-on about reversal from Paypal."""
locale = self._switch_locale()
amt = numbers.format_currency(abs(self.amount), self.currency,
locale=locale)
self._mail('users/support/emails/chargeback.txt',
# L10n: the adddon name.
_(u'%s payment reversal' % self.addon.name),
{'name': self.addon.name, 'amount': amt})
def record_failed_refund(self, e):
self.enqueue_refund(amo.REFUND_FAILED,
rejection_reason=str(e))
self._switch_locale()
self._mail('users/support/emails/refund-failed.txt',
# L10n: the addon name.
_(u'%s refund failed' % self.addon.name),
{'name': self.addon.name})
send_mail_jinja(
'Refund failed', 'devhub/email/refund-failed.txt',
{'name': self.user.email,
'error': str(e)},
settings.MARKETPLACE_EMAIL,
[str(self.addon.support_email)], fail_silently=True)
def mail_approved(self):
"""The developer has approved a refund."""
locale = self._switch_locale()
amt = numbers.format_currency(abs(self.amount), self.currency,
locale=locale)
self._mail('users/support/emails/refund-approved.txt',
# L10n: the adddon name.
_(u'%s refund approved' % self.addon.name),
{'name': self.addon.name, 'amount': amt})
def mail_declined(self):
"""The developer has declined a refund."""
self._switch_locale()
self._mail('users/support/emails/refund-declined.txt',
# L10n: the adddon name.
_(u'%s refund declined' % self.addon.name),
{'name': self.addon.name})
def mail_thankyou(self, request=None):
"""
Mail a thankyou note for a completed contribution.
Raises a ``ContributionError`` exception when the contribution
is not complete or email addresses are not found.
"""
locale = self._switch_locale()
# Thankyous must be enabled.
if not self.addon.enable_thankyou:
# Not an error condition, just return.
return
# Contribution must be complete.
if not self.transaction_id:
raise ContributionError('Transaction not complete')
# Send from support_email, developer's email, or default.
from_email = settings.DEFAULT_FROM_EMAIL
if self.addon.support_email:
from_email = str(self.addon.support_email)
else:
try:
author = self.addon.listed_authors[0]
if author.email and not author.emailhidden:
from_email = author.email
except (IndexError, TypeError):
# This shouldn't happen, but the default set above is still ok.
pass
# We need the contributor's email.
to_email = self.post_data['payer_email']
if not to_email:
raise ContributionError('Empty payer email')
# Make sure the url uses the right language.
# Setting a prefixer would be nicer, but that requires a request.
url_parts = self.addon.meet_the_dev_url().split('/')
url_parts[1] = locale.language
# Buildup the email components.
t = loader.get_template('stats/contribution-thankyou-email.ltxt')
c = {
'thankyou_note': self.addon.thankyou_note,
'addon_name': self.addon.name,
'learn_url': '%s%s?src=emailinfo' % (settings.SITE_URL,
'/'.join(url_parts)),
'domain': settings.DOMAIN,
}
body = t.render(Context(c))
subject = _('Thanks for contributing to {addon_name}').format(
addon_name=self.addon.name)
# Send the email
if send_mail(subject, body, from_email, [to_email],
fail_silently=True, perm_setting='dev_thanks'):
# Clear out contributor identifying information.
del(self.post_data['payer_email'])
self.save()
def enqueue_refund(self, status, refund_reason=None,
rejection_reason=None):
"""Keep track of a contribution's refund status."""
from market.models import Refund
refund, c = Refund.objects.safer_get_or_create(contribution=self)
refund.status = status
# Determine which timestamps to update.
timestamps = []
if status in (amo.REFUND_PENDING, amo.REFUND_APPROVED_INSTANT,
amo.REFUND_FAILED):
timestamps.append('requested')
if status in (amo.REFUND_APPROVED, amo.REFUND_APPROVED_INSTANT):
timestamps.append('approved')
elif status == amo.REFUND_DECLINED:
timestamps.append('declined')
for ts in timestamps:
setattr(refund, ts, datetime.datetime.now())
if refund_reason:
refund.refund_reason = refund_reason
if rejection_reason:
refund.rejection_reason = rejection_reason
refund.save()
return refund
@staticmethod
def post_save(sender, instance, **kwargs):
from . import tasks
tasks.addon_total_contributions.delay(instance.addon_id)
def get_amount_locale(self, locale=None):
"""Localise the amount paid into the current locale."""
if not locale:
lang = translation.get_language()
locale = Locale(translation.to_locale(lang))
return numbers.format_currency(self.amount or 0,
self.currency or 'USD',
locale=locale)
def get_refund_url(self):
return urlparams(self.addon.get_dev_url('issue_refund'),
transaction_id=self.transaction_id)
def get_absolute_refund_url(self):
return absolutify(self.get_refund_url())
def is_instant_refund(self):
if self.type != amo.CONTRIB_PURCHASE:
return False
limit = datetime.timedelta(seconds=settings.PAYPAL_REFUND_INSTANT)
return datetime.datetime.now() < (self.created + limit)
def is_refunded(self):
"""
If related has been set, then this transaction has been refunded or
charged back. This is a bit expensive, so refrain from using on listing
pages.
"""
return (Contribution.objects.filter(related=self,
type__in=[amo.CONTRIB_REFUND,
amo.CONTRIB_CHARGEBACK])
.exists())
models.signals.post_save.connect(Contribution.post_save, sender=Contribution)
class SubscriptionEvent(ModelBase):
"""Save subscription info for future processing."""
post_data = StatsDictField()
class Meta:
db_table = 'subscription_events'
class GlobalStat(caching.base.CachingMixin, models.Model):
name = models.CharField(max_length=255)
count = models.IntegerField()
date = models.DateField()
objects = caching.base.CachingManager()
class Meta:
db_table = 'global_stats'
unique_together = ('name', 'date')
get_latest_by = 'date'
class ClientData(models.Model):
"""
Helps tracks user agent and download source data of installs and purchases.
"""
download_source = models.ForeignKey('zadmin.DownloadSource', null=True)
device_type = models.CharField(max_length=255)
user_agent = models.CharField(max_length=255)
is_chromeless = models.BooleanField()
language = models.CharField(max_length=7)
region = models.IntegerField(null=True)
@classmethod
def get_or_create(cls, request):
"""Get or create a client data object based on the current request."""
download_source = request.REQUEST.get('src', '')
try:
download_source = DownloadSource.objects.get(name=download_source)
except DownloadSource.DoesNotExist:
download_source = None
region = None
if settings.MARKETPLACE:
import mkt
if hasattr(request, 'REGION') and request.REGION:
region = request.REGION.id
else:
region = mkt.regions.WORLDWIDE.id
if hasattr(request, 'LANG'):
lang = request.LANG
else:
lang = translation.get_language()
client_data, c = cls.objects.get_or_create(
download_source=download_source,
device_type=request.POST.get('device_type', ''),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
is_chromeless=request.POST.get('chromeless', False),
language=lang,
region=region)
return client_data
class Meta:
db_table = 'client_data'
unique_together = ('download_source', 'device_type', 'user_agent',
'is_chromeless', 'language', 'region')