class PriceCurrency(amo.models.ModelBase): # The carrier for this currency. carrier = models.IntegerField(choices=CARRIER_CHOICES, blank=True, null=True) currency = models.CharField(max_length=10, choices=do_dictsort(amo.OTHER_CURRENCIES)) price = models.DecimalField(max_digits=10, decimal_places=2) # The payments provider for this tier. provider = models.IntegerField(choices=PROVIDER_CHOICES, blank=True, null=True) # The payment methods allowed for this tier. method = models.IntegerField(choices=PAYMENT_METHOD_CHOICES, default=PAYMENT_METHOD_ALL) # These are the regions as defined in mkt/constants/regions. region = models.IntegerField(default=1) # Default to worldwide. tier = models.ForeignKey(Price) class Meta: db_table = 'price_currency' verbose_name = 'Price currencies' unique_together = ('tier', 'currency', 'carrier', 'region', 'provider') def __unicode__(self): return u'%s, %s: %s' % (self.tier, self.currency, self.price)
class AppFormDetails(AddonFormBase): LOCALES = [(translation.to_locale(k).replace('_', '-'), v) for k, v in do_dictsort(settings.LANGUAGES)] default_locale = forms.TypedChoiceField(required=False, choices=LOCALES) homepage = TransField.adapt(forms.URLField)(required=False) privacy_policy = TransField( widget=TransTextarea(), required=True, label=_lazy(u"Please specify your app's Privacy Policy")) class Meta: model = Webapp fields = ('default_locale', 'homepage', 'privacy_policy') def clean(self): # Make sure we have the required translations in the new locale. required = ['name', 'description'] data = self.cleaned_data if not self.errors and 'default_locale' in self.changed_data: fields = dict((k, getattr(self.instance, k + '_id')) for k in required) locale = data['default_locale'] ids = filter(None, fields.values()) qs = (Translation.objects.filter(locale=locale, id__in=ids, localized_string__isnull=False) .values_list('id', flat=True)) missing = [k for k, v in fields.items() if v not in qs] if missing: raise forms.ValidationError( _('Before changing your default locale you must have a ' 'name and description in that locale. ' 'You are missing %s.') % ', '.join(map(repr, missing))) return data
class Refund(ModelBase): # This refers to the original object with `type=mkt.CONTRIB_PURCHASE`. contribution = models.OneToOneField(Contribution) # Pending => 0 # Approved => 1 # Instantly Approved => 2 # Declined => 3 # Failed => 4 status = models.PositiveIntegerField(default=mkt.REFUND_PENDING, choices=do_dictsort( mkt.REFUND_STATUSES), db_index=True) refund_reason = models.TextField(default='', blank=True) rejection_reason = models.TextField(default='', blank=True) # Date `created` should always be date `requested` for pending refunds, # but let's just stay on the safe side. We might change our minds. requested = models.DateTimeField(null=True, db_index=True) approved = models.DateTimeField(null=True, db_index=True) declined = models.DateTimeField(null=True, db_index=True) user = models.ForeignKey('users.UserProfile') objects = RefundManager() class Meta: db_table = 'refunds' def __unicode__(self): return u'%s (%s)' % (self.contribution, self.get_status_display())
class PriceCurrency(amo.models.ModelBase): currency = models.CharField(max_length=10, choices=do_dictsort(amo.OTHER_CURRENCIES)) price = models.DecimalField(max_digits=5, decimal_places=2) tier = models.ForeignKey(Price) class Meta: db_table = 'price_currency' verbose_name = 'Price currencies' def __unicode__(self): return u'%s, %s: %s' % (self.tier, self.currency, self.price)
class AddonPurchase(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon') user = models.ForeignKey(UserProfile) type = models.PositiveIntegerField(default=amo.CONTRIB_PURCHASE, choices=do_dictsort(amo.CONTRIB_TYPES), db_index=True) class Meta: db_table = 'addon_purchase' unique_together = ('addon', 'user') def __unicode__(self): return u'%s: %s' % (self.addon, self.user)
class AddonPurchase(ModelBase): addon = models.ForeignKey('webapps.Webapp') type = models.PositiveIntegerField(default=mkt.CONTRIB_PURCHASE, choices=do_dictsort(mkt.CONTRIB_TYPES), db_index=True) user = models.ForeignKey(UserProfile) uuid = models.CharField(max_length=255, db_index=True, unique=True) class Meta: db_table = 'addon_purchase' unique_together = ('addon', 'user') def __unicode__(self): return u'%s: %s' % (self.addon, self.user)
class Category(ModelBase): name = TranslatedField() slug = models.SlugField(max_length=50, help_text='Used in Category URLs.') type = models.PositiveIntegerField(db_column='addontype_id', choices=do_dictsort(base.ADDON_TYPE)) application = models.ForeignKey('applications.Application', null=True, blank=True) count = models.IntegerField('Addon count', default=0) weight = models.IntegerField(default=0, help_text='Category weight used in sort ordering') misc = models.BooleanField(default=False) addons = models.ManyToManyField(AddonBase, through='AddonCategoryBase') class Meta: db_table = 'categories' verbose_name_plural = 'Categories' app_label = 'addons' def __unicode__(self): return unicode(self.name) def flush_urls(self): urls = ['*%s' % self.get_url_path(), ] return urls def get_url_path(self): try: type = base.ADDON_SLUGS[self.type] except KeyError: type = base.ADDON_SLUGS[base.ADDON_EXTENSION] if settings.MARKETPLACE and self.type == base.ADDON_PERSONA: #TODO: (davor) this is a temp stub. Return category URL when done. return urlresolvers.reverse('themes.browse', args=[self.slug]) return urlresolvers.reverse('browse.%s' % type, args=[self.slug]) @staticmethod def transformer(addons): qs = (Category.uncached.filter(addons__in=addons) .extra(select={'addon_id': 'addons_categories.addon_id'})) cats = dict((addon_id, list(cs)) for addon_id, cs in sorted_groupby(qs, 'addon_id')) for addon in addons: addon.all_categories = cats.get(addon.id, [])
class PriceCurrency(amo.models.ModelBase): currency = models.CharField(max_length=10, choices=do_dictsort(amo.OTHER_CURRENCIES)) price = models.DecimalField(max_digits=10, decimal_places=2) tier = models.ForeignKey(Price) class Meta: db_table = 'price_currency' verbose_name = 'Price currencies' unique_together = ('tier', 'currency') def get_price_locale(self): """Return the price as a nicely localised string for the locale.""" lang = translation.get_language() locale = get_locale_from_lang(lang) return numbers.format_currency(self.price, self.currency, locale=locale) def __unicode__(self): return u'%s, %s: %s' % (self.tier, self.currency, self.price)
class Contribution(amo.models.ModelBase): # TODO(addon): figure out what to do when we delete the add-on. addon = models.ForeignKey('addons.Addon') amount = models.DecimalField(max_digits=9, decimal_places=2, 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) # This is the external id that you can communicate to the world. uuid = models.CharField(max_length=255, null=True, db_index=True) comment = models.CharField(max_length=255) # This is the internal transaction id between us and a provider, # for example paypal or solitude. transaction_id = models.CharField(max_length=255, null=True, db_index=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(default=False) suggested_amount = models.DecimalField(max_digits=9, decimal_places=2, null=True) 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 _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_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) # 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 subject = _('Thanks for contributing to {addon_name}').format( addon_name=self.addon.name) # Send the email. send_mail_jinja( subject, 'stats/contribution-thankyou-email.ltxt', { 'thankyou_note': bleach.clean(unicode(self.addon.thankyou_note), strip=True), 'addon_name': self.addon.name, 'learn_url': '%s%s?src=emailinfo' % (settings.SITE_URL, '/'.join(url_parts)), 'domain': settings.DOMAIN }, from_email, [to_email], fail_silently=True, perm_setting='dev_thanks') @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 = get_locale_from_lang(lang) return numbers.format_currency(self.amount or 0, self.currency or 'USD', locale=locale)
class Contribution(amo.models.ModelBase): # TODO(addon): figure out what to do when we delete the add-on. addon = models.ForeignKey('addons.Addon') amount = models.DecimalField(max_digits=9, decimal_places=2, 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) source_locale = models.CharField(max_length=10, null=True) # This is the external id that you can communicate to the world. uuid = models.CharField(max_length=255, null=True, db_index=True) comment = models.CharField(max_length=255) # This is the internal transaction id between us and a provider, # for example paypal or solitude. transaction_id = models.CharField(max_length=255, null=True, db_index=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(default=False) suggested_amount = models.DecimalField(max_digits=9, decimal_places=2, null=True) 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 _switch_locale(self): if self.source_locale: lang = self.source_locale else: lang = self.addon.default_locale activate(lang) return Locale(to_locale(lang)) def get_amount_locale(self, locale=None): """Localise the amount paid into the current locale.""" if not locale: lang = get_language() locale = get_locale_from_lang(lang) return numbers.format_currency(self.amount or 0, self.currency or 'USD', locale=locale)
class Contribution(caching.base.CachingMixin, models.Model): # 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) source_locale = models.CharField(max_length=10, null=True) created = models.DateTimeField(auto_now_add=True) uuid = models.CharField(max_length=255, null=True) comment = models.CharField(max_length=255) 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) objects = models.Manager() stats = StatsManager('created') class Meta: db_table = 'stats_contributions' def __unicode__(self): return u'%s: %s' % (self.addon.name, self.amount) def flush_urls(self): return ['*/addon/%d/statistics/contributions*' % self.addon_id, ] @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 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. """ # Setup l10n before loading addon. if self.source_locale: lang = self.source_locale else: lang = self.addon.default_locale tower.activate(lang) # 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] = lang # 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() @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)
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) # This is the external id that you can communicate to the world. uuid = models.CharField(max_length=255, null=True, db_index=True) comment = models.CharField(max_length=255) # This is the internal transaction id between us and a provider, # for example paypal or solitude. transaction_id = models.CharField(max_length=255, null=True, db_index=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(default=False) 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, user): self.enqueue_refund(amo.REFUND_FAILED, user, 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 subject = _('Thanks for contributing to {addon_name}').format( addon_name=self.addon.name) # Send the email. send_mail_jinja( subject, 'stats/contribution-thankyou-email.ltxt', {'thankyou_note': bleach.clean(unicode(self.addon.thankyou_note), strip=True), 'addon_name': self.addon.name, 'learn_url': '%s%s?src=emailinfo' % (settings.SITE_URL, '/'.join(url_parts)), 'domain': settings.DOMAIN}, from_email, [to_email], fail_silently=True, perm_setting='dev_thanks') def enqueue_refund(self, status, user, 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, user=user) 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 = get_locale_from_lang(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, period=settings.PAYPAL_REFUND_INSTANT): if self.type != amo.CONTRIB_PURCHASE: return False limit = datetime.timedelta(seconds=period) return datetime.datetime.now() < (self.created + limit) def get_refund_contribs(self): """Get related set of refund contributions.""" return Contribution.objects.filter( related=self, type=amo.CONTRIB_REFUND).order_by('-modified') 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())
from django.conf import settings from django.utils.translation import trans_real from jinja2.filters import do_dictsort LOCALES = [(trans_real.to_locale(k).replace('_', '-'), v) for k, v in do_dictsort(settings.LANGUAGES)]
class InAppProductSerializer(serializers.ModelSerializer): _locales = [(translation.to_locale(k).replace('_', '-').lower(), v) for k, v in do_dictsort(settings.LANGUAGES)] app = serializers.SlugRelatedField(read_only=True, slug_field='app_slug', source='webapp') guid = serializers.CharField(read_only=True) include_inactive = serializers.BooleanField(read_only=True) logo_url = serializers.CharField( validators=[URLValidator(schemes=['http', 'https'])], required=False) name = NameField() default_locale = serializers.ChoiceField(choices=_locales) price_id = serializers.PrimaryKeyRelatedField(source='price', queryset=Price.objects) class Meta: model = InAppProduct fields = ['active', 'guid', 'app', 'price_id', 'name', 'default_locale', 'logo_url', 'include_inactive'] def validate(self, attrs): default_name = attrs['name'].get(attrs['default_locale'], None) if ((attrs['default_locale'] not in attrs['name']) or not default_name): raise ValidationError( 'no localization for default_locale {d} in "name"' .format(d=repr(attrs['default_locale']))) return attrs def validate_logo_url(self, logo_url): # This message is shown for all image errors even though it may # not be correct. This is to prevent leaking info that could # lead to port scanning, DOS'ing or other vulnerabilities. msg = _('Product logo must be a 64x64 image. ' 'Check that the URL is correct.') tmp_dest = StringIO() try: res = requests.get( logo_url, timeout=3, headers={'User-Agent': settings.MARKETPLACE_USER_AGENT}) res.raise_for_status() payload = 0 read_size = 100000 for chunk in res.iter_content(read_size): payload += len(chunk) if payload > settings.MAX_INAPP_IMAGE_SIZE: log.info('clean_logo_url: payload exceeded allowed ' 'size: {url}: '.format(url=logo_url)) raise ValidationError(msg) tmp_dest.write(chunk) except ValidationError: raise except Exception, exc: log.info('clean_logo_url: exception fetching {url}: ' '{exc.__class__.__name__}: {exc}' .format(url=logo_url, exc=exc)) raise ValidationError(msg) tmp_dest.seek(0) try: img = Image.open(tmp_dest) img.verify() except Exception, exc: log.info('clean_logo_url: Error loading/verifying {url}: ' '{exc.__class__.__name__}: {exc}' .format(url=logo_url, exc=exc)) raise ValidationError(msg)
class Contribution(ModelBase): addon = models.ForeignKey('webapps.Webapp', blank=True, null=True) # For in-app purchases this links to the product. inapp_product = models.ForeignKey('inapp.InAppProduct', blank=True, null=True) amount = models.DecimalField(max_digits=9, decimal_places=2, blank=True, null=True) currency = models.CharField(max_length=3, choices=do_dictsort(mkt.PAYPAL_CURRENCIES), default=mkt.CURRENCY_DEFAULT) source = models.CharField(max_length=255, null=True) source_locale = models.CharField(max_length=10, null=True) # This is the external id that you can communicate to the world. uuid = models.CharField(max_length=255, null=True, db_index=True) comment = models.CharField(max_length=255) # This is the internal transaction id between us and a provider, # for example paypal or solitude. transaction_id = models.CharField(max_length=255, null=True, db_index=True) paykey = models.CharField(max_length=255, 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=mkt.CONTRIB_TYPE_DEFAULT, choices=do_dictsort(mkt.CONTRIB_TYPES), db_index=True) price_tier = models.ForeignKey('prices.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'<{cls} {pk}; app: {app}; in-app: {inapp}; amount: {amount}>' .format(app=self.addon, amount=self.amount, pk=self.pk, inapp=self.inapp_product, cls=self.__class__.__name__)) @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 def _switch_locale(self): if self.source_locale: lang = self.source_locale else: lang = self.addon.default_locale translation.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 is_inapp_simulation(self): """True if this purchase is for a simulated in-app product.""" return self.inapp_product and self.inapp_product.simulate def enqueue_refund(self, status, user, refund_reason=None, rejection_reason=None): """Keep track of a contribution's refund status.""" from mkt.prices.models import Refund refund, c = Refund.objects.safer_get_or_create(contribution=self, user=user) refund.status = status # Determine which timestamps to update. timestamps = [] if status in (mkt.REFUND_PENDING, mkt.REFUND_APPROVED_INSTANT, mkt.REFUND_FAILED): timestamps.append('requested') if status in (mkt.REFUND_APPROVED, mkt.REFUND_APPROVED_INSTANT): timestamps.append('approved') elif status == mkt.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 def get_amount_locale(self, locale=None): """Localise the amount paid into the current locale.""" if not locale: lang = translation.get_language() locale = get_locale_from_lang(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 get_refund_contribs(self): """Get related set of refund contributions.""" return Contribution.objects.filter( related=self, type=mkt.CONTRIB_REFUND).order_by('-modified') 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=[mkt.CONTRIB_REFUND, mkt.CONTRIB_CHARGEBACK]) .exists())
class AddonBase(OnChangeMixin, ModelBase): STATUS_CHOICES = base.STATUS_CHOICES.items() LOCALES = [(translation.to_locale(k).replace('_', '-'), v) for k, v in do_dictsort(settings.LANGUAGES)] guid = models.CharField(max_length=255, unique=True, null=True) slug = models.CharField(max_length=30, unique=True, null=True) # This column is only used for webapps, so they can have a slug namespace # separate from addons and personas. app_slug = models.CharField(max_length=30, unique=True, null=True) name = TranslatedField() default_locale = models.CharField(max_length=10, default=settings.LANGUAGE_CODE, db_column='defaultlocale') type = models.PositiveIntegerField(db_column='addontype_id') status = models.PositiveIntegerField( choices=STATUS_CHOICES, db_index=True, default=0) highest_status = models.PositiveIntegerField( choices=STATUS_CHOICES, default=0, help_text="An upper limit for what an author can change.", db_column='higheststatus') icon_type = models.CharField(max_length=25, blank=True, db_column='icontype') homepage = TranslatedField() support_email = TranslatedField(db_column='supportemail') support_url = TranslatedField(db_column='supporturl') description = PurifiedField(short=False) summary = LinkifiedField() developer_comments = PurifiedField(db_column='developercomments') eula = PurifiedField() privacy_policy = PurifiedField(db_column='privacypolicy') the_reason = PurifiedField() the_future = PurifiedField() average_rating = models.FloatField(max_length=255, default=0, null=True, db_column='averagerating') bayesian_rating = models.FloatField(default=0, db_index=True, db_column='bayesianrating') total_reviews = models.PositiveIntegerField(default=0, db_column='totalreviews') weekly_downloads = models.PositiveIntegerField( default=0, db_column='weeklydownloads', db_index=True) total_downloads = models.PositiveIntegerField( default=0, db_column='totaldownloads') hotness = models.FloatField(default=0, db_index=True) average_daily_downloads = models.PositiveIntegerField(default=0) average_daily_users = models.PositiveIntegerField(default=0) share_count = models.PositiveIntegerField(default=0, db_index=True, db_column='sharecount') last_updated = models.DateTimeField(db_index=True, null=True, help_text='Last time this add-on had a file/version update') ts_slowness = models.FloatField(db_index=True, null=True, help_text='How much slower this add-on makes browser ts tests. ' 'Read as {addon.ts_slowness}% slower.') disabled_by_user = models.BooleanField(default=False, db_index=True, db_column='inactive') trusted = models.BooleanField(default=False) view_source = models.BooleanField(default=True, db_column='viewsource') public_stats = models.BooleanField(default=False, db_column='publicstats') prerelease = models.BooleanField(default=False) admin_review = models.BooleanField(default=False, db_column='adminreview') admin_review_type = models.PositiveIntegerField( choices=base.ADMIN_REVIEW_TYPES.items(), default=base.ADMIN_REVIEW_FULL) site_specific = models.BooleanField(default=False, db_column='sitespecific') external_software = models.BooleanField(default=False, db_column='externalsoftware') dev_agreement = models.BooleanField(default=False, help_text="Has the dev agreement been signed?") auto_repackage = models.BooleanField(default=True, help_text='Automatically upgrade jetpack add-on to a new sdk version?') outstanding = models.BooleanField(default=False) nomination_message = models.TextField(null=True, db_column='nominationmessage') target_locale = models.CharField( max_length=255, db_index=True, blank=True, null=True, help_text="For dictionaries and language packs") locale_disambiguation = models.CharField( max_length=255, blank=True, null=True, help_text="For dictionaries and language packs") wants_contributions = models.BooleanField(default=False) paypal_id = models.CharField(max_length=255, blank=True) charity = models.ForeignKey('Charity', null=True) # TODO(jbalogh): remove nullify_invalid once remora dies. suggested_amount = DecimalCharField( max_digits=8, decimal_places=2, nullify_invalid=True, blank=True, null=True, help_text=_(u'Users have the option of contributing more ' 'or less than this amount.')) total_contributions = DecimalCharField(max_digits=8, decimal_places=2, nullify_invalid=True, blank=True, null=True) annoying = models.PositiveIntegerField( choices=base.CONTRIB_CHOICES, default=0, help_text=_(u"Users will always be asked in the Add-ons" " Manager (Firefox 4 and above)")) enable_thankyou = models.BooleanField(default=False, help_text="Should the thankyou note be sent to contributors?") thankyou_note = TranslatedField() get_satisfaction_company = models.CharField(max_length=255, blank=True, null=True) get_satisfaction_product = models.CharField(max_length=255, blank=True, null=True) authors = models.ManyToManyField(UserProfileBase, through='AddonUser', related_name='addons') categories = models.ManyToManyField('Category', through='AddonCategoryBase') dependencies = models.ManyToManyField('self', symmetrical=False, through='AddonDependency', related_name='addons') premium_type = models.PositiveIntegerField( choices=base.ADDON_PREMIUM_TYPES.items(), default=base.ADDON_FREE) manifest_url = models.URLField(max_length=255, blank=True, null=True, verify_exists=False) app_domain = models.CharField(max_length=255, blank=True, null=True, db_index=True) _current_version = models.ForeignKey(VersionBase, related_name='___ignore', db_column='current_version', null=True, on_delete=models.SET_NULL) # This is for Firefox only. _backup_version = models.ForeignKey(VersionBase, related_name='___backup', db_column='backup_version', null=True, on_delete=models.SET_NULL) _latest_version = None make_public = models.DateTimeField(null=True) mozilla_contact = models.EmailField() # Whether the app is packaged or not (aka hosted). is_packaged = models.BooleanField(default=False, db_index=True) # This gets overwritten in the transformer. share_counts = collections.defaultdict(int) class Meta: db_table = 'addons' app_label = 'addons' @staticmethod def __new__(cls, *args, **kw): # # Return a Webapp instead of an Addon if the `type` column says this is # # really a webapp. # try: # type_idx = AddonBase._meta._type_idx # except AttributeError: # type_idx = (idx for idx, f in enumerate(AddonBase._meta.fields) # if f.attname == 'type').next() # AddonBase._meta._type_idx = type_idx # if ((len(args) == len(AddonBase._meta.fields) # and args[type_idx] == base.ADDON_WEBAPP) # or kw and kw.get('type') == base.ADDON_WEBAPP): # from gelato.models.webapp import Webapp # cls = Webapp return super(AddonBase, cls).__new__(cls, *args, **kw) def __unicode__(self): return u'%s: %s' % (self.id, self.name) def __init__(self, *args, **kw): super(AddonBase, self).__init__(*args, **kw) self._first_category = {} @property def premium(self): """ Returns the premium object which will be gotten by the transformer, if its not there, try and get it. Will return None if there's nothing there. """ if not hasattr(self, '_premium'): try: self._premium = self.addonpremium except AddonPremium.DoesNotExist: self._premium = None return self._premium
class Refund(amo.models.ModelBase): # This refers to the original object with `type=amo.CONTRIB_PURCHASE`. contribution = models.OneToOneField('stats.Contribution') # Pending => 0 # Approved => 1 # Instantly Approved => 2 # Declined => 3 # Failed => 4 status = models.PositiveIntegerField(default=amo.REFUND_PENDING, choices=do_dictsort(amo.REFUND_STATUSES), db_index=True) refund_reason = models.TextField(default='', blank=True) rejection_reason = models.TextField(default='', blank=True) # Date `created` should always be date `requested` for pending refunds, # but let's just stay on the safe side. We might change our minds. requested = models.DateTimeField(null=True, db_index=True) approved = models.DateTimeField(null=True, db_index=True) declined = models.DateTimeField(null=True, db_index=True) user = models.ForeignKey('users.UserProfile') objects = RefundManager() class Meta: db_table = 'refunds' def __unicode__(self): return u'%s (%s)' % (self.contribution, self.get_status_display()) @staticmethod def post_save(sender, instance, **kwargs): from amo.tasks import find_refund_escalations find_refund_escalations(instance.contribution.addon_id) @classmethod def recent_refund_ratio(cls, addon_id, since): """ Returns the ratio of purchases to refunds since the given datetime. """ cursor = connection.cursor() purchases = AddonPurchase.objects.filter( addon=addon_id, type=amo.CONTRIB_PURCHASE).count() if purchases == 0: return 0.0 params = [addon_id, since] # Hardcoded statuses for simplicity, but they are: # amo.REFUND_PENDING, amo.REFUND_APPROVED, amo.REFUND_APPROVED_INSTANT sql = ''' SELECT COUNT(DISTINCT sc.user_id) AS num FROM refunds LEFT JOIN stats_contributions AS sc ON refunds.contribution_id = sc.id WHERE sc.addon_id = %s AND refunds.status IN (0,1,2) AND refunds.created > %s ''' cursor.execute(sql, params) row = cursor.fetchone() if row: return row[0] / float(purchases) return 0.0
class Contribution(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon') # For in-app purchases this links to the product. inapp_product = models.ForeignKey('inapp.InAppProduct', blank=True, null=True) 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) source_locale = models.CharField(max_length=10, null=True) # This is the external id that you can communicate to the world. uuid = models.CharField(max_length=255, null=True, db_index=True) comment = models.CharField(max_length=255) # This is the internal transaction id between us and a provider, # for example paypal or solitude. transaction_id = models.CharField(max_length=255, null=True, db_index=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(default=False) 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('prices.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 _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 record_failed_refund(self, e, user): self.enqueue_refund(amo.REFUND_FAILED, user, 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', 'purchase/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 enqueue_refund(self, status, user, refund_reason=None, rejection_reason=None): """Keep track of a contribution's refund status.""" from mkt.prices.models import Refund refund, c = Refund.objects.safer_get_or_create(contribution=self, user=user) 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 def get_amount_locale(self, locale=None): """Localise the amount paid into the current locale.""" if not locale: lang = translation.get_language() locale = get_locale_from_lang(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 get_refund_contribs(self): """Get related set of refund contributions.""" return Contribution.objects.filter( related=self, type=amo.CONTRIB_REFUND).order_by('-modified') 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())
### Here be dragons. # Django decided to require that ForeignKeys be unique. That's generally # reasonable, but Translations break that in their quest for all things unholy. # Here we monkeypatch the error collector Django uses in validation to skip any # messages generated by Translations. (Django #13284) import re from django.conf import settings from django.core.management import validation from django.utils.translation import trans_real from jinja2.filters import do_dictsort Parent = validation.ModelErrorCollection class ModelErrorCollection(Parent): skip = ("Field 'id' under model '\w*Translation' must " "have a unique=True constraint.") def add(self, context, error): if not re.match(self.skip, error): Parent.add(self, context, error) validation.ModelErrorCollection = ModelErrorCollection LOCALES = [(trans_real.to_locale(k).replace('_', '-'), v) for k, v in do_dictsort(settings.LANGUAGES)]