addons-server/apps/addons/models.py

2040 строки
76 KiB
Python

# -*- coding: utf-8 -*-
import collections
import hashlib
import hmac
import itertools
import json
import os
import re
import time
from datetime import datetime, timedelta
from django.conf import settings
from django.core.cache import cache
from django.db import models, transaction
from django.dispatch import receiver
from django.db.models import Q, Max, signals as dbsignals
from django.utils.translation import trans_real as translation
from jinja2.filters import do_dictsort
import caching.base as caching
import commonware.log
import json_field
from tower import ugettext_lazy as _
import waffle
from addons.utils import ReverseNameLookup, get_featured_ids, get_creatured_ids
import amo.models
from amo.decorators import use_master
from amo.fields import DecimalCharField
from amo.helpers import absolutify, shared_url
from amo.utils import (cache_ns_key, chunked, JSONEncoder, send_mail, slugify,
sorted_groupby, to_language, urlparams)
from amo.urlresolvers import get_outgoing_url, reverse
from compat.models import CompatReport
from files.models import File
from market.models import AddonPremium, Price
from reviews.models import Review
import sharing.utils as sharing
from stats.models import AddonShareCountTotal
from translations.fields import (TranslatedField, PurifiedField,
LinkifiedField, Translation)
from translations.query import order_by_translation
from users.models import UserProfile, UserForeignKey
from users.utils import find_users
from versions.compare import version_int
from versions.models import Version
from . import query, signals
log = commonware.log.getLogger('z.addons')
class AddonManager(amo.models.ManagerBase):
def __init__(self, include_deleted=False):
amo.models.ManagerBase.__init__(self)
self.include_deleted = include_deleted
def get_query_set(self):
qs = super(AddonManager, self).get_query_set()
qs = qs._clone(klass=query.IndexQuerySet)
if not self.include_deleted:
qs = qs.exclude(status=amo.STATUS_DELETED)
return qs.transform(Addon.transformer)
def id_or_slug(self, val):
if isinstance(val, basestring) and not val.isdigit():
return self.filter(slug=val)
return self.filter(id=val)
def enabled(self):
return self.filter(disabled_by_user=False)
def public(self):
"""Get public add-ons only"""
return self.filter(self.valid_q([amo.STATUS_PUBLIC]))
def reviewed(self):
"""Get add-ons with a reviewed status"""
return self.filter(self.valid_q(amo.REVIEWED_STATUSES))
def unreviewed(self):
"""Get only unreviewed add-ons"""
return self.filter(self.valid_q(amo.UNREVIEWED_STATUSES))
def valid(self):
"""Get valid, enabled add-ons only"""
return self.filter(self.valid_q(amo.LISTED_STATUSES))
def valid_and_disabled(self):
"""Get valid, enabled and disabled add-ons."""
statuses = list(amo.LISTED_STATUSES) + [amo.STATUS_DISABLED]
return self.filter(Q(status__in=statuses) | Q(disabled_by_user=True),
_current_version__isnull=False)
def featured(self, app, lang=None, type=None):
"""
Filter for all featured add-ons for an application in all locales.
"""
ids = get_featured_ids(app, lang, type)
return amo.models.manual_order(self.listed(app), ids, 'addons.id')
def listed(self, app, *status):
"""
Listed add-ons have a version with a file matching ``status`` and are
not disabled. Personas and self-hosted add-ons will be returned too.
"""
if len(status) == 0:
status = [amo.STATUS_PUBLIC]
return self.filter(self.valid_q(status), appsupport__app=app.id)
def top_free(self, app, listed=True):
qs = (self.listed(app) if listed else
self.filter(appsupport__app=app.id))
return (qs.exclude(premium_type__in=amo.ADDON_PREMIUMS)
.exclude(addonpremium__price__price__isnull=False)
.order_by('-weekly_downloads')
.with_index(addons='downloads_type_idx'))
def top_paid(self, app, listed=True):
qs = (self.listed(app) if listed else
self.filter(appsupport__app=app.id))
return (qs.filter(premium_type__in=amo.ADDON_PREMIUMS,
addonpremium__price__price__isnull=False)
.order_by('-weekly_downloads')
.with_index(addons='downloads_type_idx'))
def valid_q(self, status=[], prefix=''):
"""
Return a Q object that selects a valid Addon with the given statuses.
An add-on is valid if not disabled and has a current version.
``prefix`` can be used if you're not working with Addon directly and
need to hop across a join, e.g. ``prefix='addon__'`` in
CollectionAddon.
"""
if not status:
status = [amo.STATUS_PUBLIC]
def q(*args, **kw):
if prefix:
kw = dict((prefix + k, v) for k, v in kw.items())
return Q(*args, **kw)
return q(q(_current_version__isnull=False),
disabled_by_user=False, status__in=status)
class Addon(amo.models.OnChangeMixin, amo.models.ModelBase):
STATUS_CHOICES = amo.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=amo.ADMIN_REVIEW_TYPES.items(),
default=amo.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=amo.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('users.UserProfile', through='AddonUser',
related_name='addons')
categories = models.ManyToManyField('Category', through='AddonCategory')
dependencies = models.ManyToManyField('self', symmetrical=False,
through='AddonDependency',
related_name='addons')
premium_type = models.PositiveIntegerField(
choices=amo.ADDON_PREMIUM_TYPES.items(),
default=amo.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(Version, related_name='___ignore',
db_column='current_version', null=True, on_delete=models.SET_NULL)
# This is for Firefox only.
_backup_version = models.ForeignKey(Version, related_name='___backup',
db_column='backup_version', null=True, on_delete=models.SET_NULL)
_latest_version = None
# This gets overwritten in the transformer.
share_counts = collections.defaultdict(int)
objects = AddonManager()
with_deleted = AddonManager(include_deleted=True)
make_public = models.DateTimeField(null=True)
mozilla_contact = models.EmailField()
class Meta:
db_table = '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 = Addon._meta._type_idx
except AttributeError:
type_idx = (idx for idx, f in enumerate(Addon._meta.fields)
if f.attname == 'type').next()
Addon._meta._type_idx = type_idx
if ((len(args) == len(Addon._meta.fields)
and args[type_idx] == amo.ADDON_WEBAPP)
or kw and kw.get('type') == amo.ADDON_WEBAPP):
cls = Webapp
return super(Addon, cls).__new__(cls, *args, **kw)
def __unicode__(self):
return u'%s: %s' % (self.id, self.name)
def __init__(self, *args, **kw):
super(Addon, self).__init__(*args, **kw)
self._first_category = {}
def save(self, **kw):
self.clean_slug()
super(Addon, self).save(**kw)
@use_master
def clean_slug(self, slug_field='slug'):
if self.status == amo.STATUS_DELETED:
return
slug = getattr(self, slug_field, None)
if not slug:
if not self.name:
try:
name = Translation.objects.filter(id=self.name_id)[0]
except IndexError:
name = str(self.id)
else:
name = self.name
slug = slugify(name)[:27]
if BlacklistedSlug.blocked(slug):
slug += '~'
qs = Addon.objects.values_list(slug_field, 'id')
match = qs.filter(**{slug_field: slug})
if match and match[0][1] != self.id:
if self.id:
prefix = '%s-%s' % (slug[:-len(str(self.id))], self.id)
else:
prefix = slug
slugs = dict(qs.filter(
**{'%s__startswith' % slug_field: '%s-' % prefix}))
slugs.update(match)
for idx in range(len(slugs)):
new = ('%s-%s' % (prefix, idx + 1))[:30]
if new not in slugs:
slug = new
break
setattr(self, slug_field, slug)
@transaction.commit_on_success
def delete(self, msg=''):
id = self.id
if self.highest_status or self.status:
if self.guid:
log.debug('Adding guid to blacklist: %s' % self.guid)
BlacklistedGuid(guid=self.guid, comments=msg).save()
log.debug('Deleting add-on: %s' % self.id)
to = [settings.FLIGTAR]
user = amo.get_user()
context = {
'atype': amo.ADDON_TYPE.get(self.type).upper(),
'authors': [u.email for u in self.authors.all()],
'adu': self.average_daily_users,
'guid': self.guid,
'id': self.id,
'msg': msg,
'name': self.name,
'slug': self.slug,
'total_downloads': self.total_downloads,
'url': absolutify(self.get_url_path()),
'user_str': ("%s, %s (%s)" % (user.display_name or
user.username, user.email,
user.id) if user else "Unknown"),
}
email_msg = u"""
The following %(atype)s was deleted.
%(atype)s: %(name)s
URL: %(url)s
DELETED BY: %(user_str)s
ID: %(id)s
GUID: %(guid)s
AUTHORS: %(authors)s
TOTAL DOWNLOADS: %(total_downloads)s
AVERAGE DAILY USERS: %(adu)s
NOTES: %(msg)s
""" % context
log.debug('Sending delete email for %(atype)s %(id)s' % context)
subject = 'Deleting %(atype)s %(slug)s (%(id)d)' % context
if waffle.switch_is_active('soft_delete'):
models.signals.pre_delete.send(sender=Addon, instance=self)
self.status = amo.STATUS_DELETED
self.slug = self.app_slug = self.app_domain = None
self.save()
models.signals.post_delete.send(sender=Addon, instance=self)
else:
super(Addon, self).delete()
send_mail(subject, email_msg, recipient_list=to)
else:
super(Addon, self).delete()
ReverseNameLookup(self.is_webapp()).delete(id)
from . import tasks
tasks.delete_preview_files.delay(id)
tasks.unindex_addons.delay([id])
return True
@classmethod
def from_upload(cls, upload, platforms):
from files.utils import parse_addon
data = parse_addon(upload)
fields = cls._meta.get_all_field_names()
addon = Addon(**dict((k, v) for k, v in data.items() if k in fields))
addon.status = amo.STATUS_NULL
locale_is_set = (addon.default_locale and
addon.default_locale != settings.LANGUAGE_CODE)
if not locale_is_set:
addon.default_locale = to_language(translation.get_language())
if addon.is_webapp():
addon.manifest_url = upload.name
addon.app_domain = addon.domain_from_url(addon.manifest_url)
addon.save()
Version.from_upload(upload, addon, platforms)
amo.log(amo.LOG.CREATE_ADDON, addon)
log.debug('New addon %r from %r' % (addon, upload))
return addon
def flush_urls(self):
urls = ['*/addon/%s/' % self.slug, # Doesn't take care of api
'*/addon/%s/developers/' % self.slug,
'*/addon/%s/eula/*' % self.slug,
'*/addon/%s/privacy/' % self.slug,
'*/addon/%s/versions/*' % self.slug,
'*/api/*/addon/%s' % self.slug,
self.icon_url,
self.thumbnail_url,
]
urls.extend('*/user/%d/' % u.id for u in self.listed_authors)
return urls
def get_url_path(self, more=False, add_prefix=True):
# If more=True you get the link to the ajax'd middle chunk of the
# detail page.
if settings.MARKETPLACE and self.is_persona():
return reverse('themes.detail', args=[self.slug])
view = 'addons.detail_more' if more else 'addons.detail'
return reverse(view, args=[self.slug], add_prefix=add_prefix)
def get_api_url(self):
# Used by Piston in output.
return absolutify(self.get_url_path())
def get_dev_url(self, action='edit', args=None, prefix_only=False):
# Either link to the "new" Marketplace Developer Hub or the old one.
args = args or []
prefix = ('mkt.developers' if getattr(settings, 'MARKETPLACE', False)
else 'devhub')
if self.is_webapp():
view_name = '%s.%s' if prefix_only else '%s.apps.%s'
return reverse(view_name % (prefix, action),
args=[self.app_slug] + args)
else:
view_name = '%s.%s' if prefix_only else '%s.addons.%s'
return reverse(view_name % (prefix, action),
args=[self.slug] + args)
def get_detail_url(self, action='detail', args=[]):
if self.is_webapp():
return reverse('apps.%s' % action, args=[self.app_slug] + args)
else:
return reverse('addons.%s' % action, args=[self.slug] + args)
def meet_the_dev_url(self):
return reverse('addons.meet', args=[self.slug])
@property
def reviews_url(self):
return shared_url('reviews.list', self)
def type_url(self):
"""The url for this add-on's AddonType."""
return AddonType(self.type).get_url_path()
def share_url(self):
return reverse('addons.share', args=[self.slug])
@amo.cached_property(writable=True)
def listed_authors(self):
return UserProfile.objects.filter(addons=self,
addonuser__listed=True).order_by('addonuser__position')
@classmethod
def get_fallback(cls):
return cls._meta.get_field('default_locale')
@property
def reviews(self):
return Review.objects.filter(addon=self, reply_to=None)
def get_category(self, app):
if app in getattr(self, '_first_category', {}):
return self._first_category[app]
categories = list(self.categories.filter(application=app))
return categories[0] if categories else None
def language_ascii(self):
lang = translation.to_language(self.default_locale)
return settings.LANGUAGES.get(lang)
def get_version(self, backup_version=False):
"""
Retrieves the latest version of an addon.
backup_version: if specified the highest file up to but *not* including
this version will be found.
"""
if self.type == amo.ADDON_PERSONA:
return
try:
if self.status == amo.STATUS_PUBLIC:
status = [self.status]
elif self.status in (amo.STATUS_LITE,
amo.STATUS_LITE_AND_NOMINATED):
status = [amo.STATUS_PUBLIC, amo.STATUS_LITE,
amo.STATUS_LITE_AND_NOMINATED]
else:
status = amo.VALID_STATUSES
status_list = ','.join(map(str, status))
fltr = {'files__status__in': status}
if backup_version:
fltr['apps__application__id'] = amo.FIREFOX.id
fltr['apps__min__version_int__lt'] = amo.FIREFOX.backup_version
return self.versions.no_cache().filter(**fltr).extra(
where=["""
NOT EXISTS (
SELECT 1 FROM versions as v2
INNER JOIN files AS f2 ON (f2.version_id = v2.id)
WHERE v2.id = versions.id
AND f2.status NOT IN (%s))
""" % status_list])[0]
except (IndexError, Version.DoesNotExist):
return None
def update_version(self):
"Returns true if we updated the field."
backup = None
current = self.get_version()
if current:
firefox_min = current.compatible_apps.get(amo.FIREFOX)
if (firefox_min and
firefox_min.min.version_int > amo.FIREFOX.backup_version):
backup = self.get_version(backup_version=True)
diff = [self._backup_version, backup, self._current_version, current]
updated = {}
if self._backup_version != backup:
updated.update({'_backup_version': backup})
if self._current_version != current:
updated.update({'_current_version': current})
if updated:
try:
self.update(**updated)
signals.version_changed.send(sender=self)
log.info(u'Version changed from backup: %s to %s, '
'current: %s to %s for addon %s'
% tuple(diff + [self]))
except Exception, e:
log.error(u'Could not save version changes backup: %s to %s, '
'current: %s to %s for addon %s (%s)' %
tuple(diff + [self, e]))
return bool(updated)
@property
def latest_version(self):
"""Returns the absolutely newest non-beta version. """
if self.type == amo.ADDON_PERSONA:
return
if not self._latest_version:
try:
v = (self.versions.exclude(files__status=amo.STATUS_BETA)
.latest())
self._latest_version = v
except Version.DoesNotExist:
self._latest_version = None
return self._latest_version
def compatible_version(self, app_id, app_version=None, platform=None,
compat_mode='strict'):
"""Returns the newest compatible version given the input."""
if not app_id:
return None
if platform:
# We include platform_id=1 always in the SQL so we skip it here.
platform = platform.lower()
if platform != 'all' and platform in amo.PLATFORM_DICT:
platform = amo.PLATFORM_DICT[platform].id
else:
platform = None
log.info(u'Checking compatibility for add-on ID:%s, APP:%s, V:%s, '
'OS:%s, Mode:%s' % (self.id, app_id, app_version, platform,
compat_mode))
valid_file_statuses = ','.join(map(str, amo.REVIEWED_STATUSES))
data = dict(id=self.id, app_id=app_id, platform=platform,
valid_file_statuses=valid_file_statuses)
if app_version:
data.update(version_int=version_int(app_version))
else:
# We can't perform the search queries for strict or normal without
# an app version.
compat_mode = 'ignore'
ns_key = cache_ns_key('d2c-versions:%s' % self.id)
cache_key = '%s:%s:%s:%s:%s' % (ns_key, app_id, app_version, platform,
compat_mode)
version_id = cache.get(cache_key)
if version_id != None:
log.info(u'Found compatible version in cache: %s => %s' % (
cache_key, version_id))
if version_id == 0:
return None
else:
try:
return Version.objects.get(pk=version_id)
except Version.DoesNotExist:
pass
raw_sql = ["""
SELECT versions.*
FROM versions
INNER JOIN addons
ON addons.id = versions.addon_id AND addons.id = %(id)s
INNER JOIN applications_versions
ON applications_versions.version_id = versions.id
INNER JOIN applications
ON applications_versions.application_id = applications.id
AND applications.id = %(app_id)s
INNER JOIN appversions appmin
ON appmin.id = applications_versions.min
INNER JOIN appversions appmax
ON appmax.id = applications_versions.max
INNER JOIN files
ON files.version_id = versions.id AND
(files.platform_id = 1"""]
if platform:
raw_sql.append(' OR files.platform_id = %(platform)s')
raw_sql.append(') WHERE files.status IN (%(valid_file_statuses)s) ')
if app_version:
raw_sql.append('AND appmin.version_int <= %(version_int)s ')
if compat_mode == 'ignore':
pass # No further SQL modification required.
elif compat_mode == 'normal':
raw_sql.append("""AND
CASE WHEN files.strict_compatibility = 1 OR
files.binary_components = 1
THEN appmax.version_int >= %(version_int)s ELSE 1 END
""")
# Filter out versions that don't have the minimum maxVersion
# requirement to qualify for default-to-compatible.
d2c_max = amo.D2C_MAX_VERSIONS.get(app_id)
if d2c_max:
data['d2c_max_version'] = version_int(d2c_max)
raw_sql.append(
"AND appmax.version_int >= %(d2c_max_version)s ")
# Filter out versions found in compat overrides
raw_sql.append("""AND
NOT versions.id IN (
SELECT version_id FROM incompatible_versions
WHERE app_id=%(app_id)s AND
(min_app_version='0' AND
max_app_version_int >= %(version_int)s) OR
(min_app_version_int <= %(version_int)s AND
max_app_version='*') OR
(min_app_version_int <= %(version_int)s AND
max_app_version_int >= %(version_int)s)) """)
else: # Not defined or 'strict'.
raw_sql.append('AND appmax.version_int >= %(version_int)s ')
raw_sql.append('ORDER BY versions.id DESC LIMIT 1;')
version = Version.objects.raw(''.join(raw_sql) % data)
if version:
version = version[0]
version_id = version.id
else:
version = None
version_id = 0
log.info(u'Caching compat version %s => %s' % (cache_key, version_id))
cache.set(cache_key, version_id, 0)
return version
def invalidate_d2c_versions(self):
"""Invalidates the cache of compatible versions.
Call this when there is an event that may change what compatible
versions are returned so they are recalculated.
"""
key = cache_ns_key('d2c-versions:%s' % self.id, increment=True)
log.info('Incrementing d2c-versions namespace for add-on [%s]: %s' % (
self.id, key))
@property
def current_version(self):
"Returns the current_version field or updates it if needed."
if self.type == amo.ADDON_PERSONA:
return
if not self._current_version:
self.update_version()
return self._current_version
@amo.cached_property
def binary(self):
"""Returns if the current version has binary files."""
version = self.current_version
if version:
return version.files.filter(binary=True).exists()
return False
@amo.cached_property
def binary_components(self):
"""Returns if the current version has files with binary_components."""
version = self.current_version
if version:
return version.files.filter(binary_components=True).exists()
return False
@property
def backup_version(self):
"""Returns the backup version."""
if not self._current_version:
return
return self._backup_version
def get_icon_dir(self):
return os.path.join(settings.ADDON_ICONS_PATH,
'%s' % (self.id / 1000))
def get_icon_url(self, size, use_default=True):
"""
Returns either the addon's icon url.
If this is not a theme or persona and there is no
icon for the addon then if:
use_default is True, will return a default icon
use_default is False, will return None
"""
icon_type_split = []
if self.icon_type:
icon_type_split = self.icon_type.split('/')
# Get the closest allowed size without going over
if (size not in amo.ADDON_ICON_SIZES and
size >= amo.ADDON_ICON_SIZES[0]):
size = [s for s in amo.ADDON_ICON_SIZES if s < size][-1]
elif size < amo.ADDON_ICON_SIZES[0]:
size = amo.ADDON_ICON_SIZES[0]
# Figure out what to return for an image URL
if self.type == amo.ADDON_PERSONA:
return self.persona.icon_url
if not self.icon_type:
if self.type == amo.ADDON_THEME:
icon = amo.ADDON_ICONS[amo.ADDON_THEME]
return settings.ADDON_ICON_BASE_URL + icon
else:
if not use_default:
return None
return '%s/%s-%s.png' % (settings.ADDON_ICONS_DEFAULT_URL,
'default', size)
elif icon_type_split[0] == 'icon':
return '%s/%s-%s.png' % (settings.ADDON_ICONS_DEFAULT_URL,
icon_type_split[1], size)
else:
# [1] is the whole ID, [2] is the directory
split_id = re.match(r'((\d*?)\d{1,3})$', str(self.id))
return settings.ADDON_ICON_URL % (
split_id.group(2) or 0, self.id, size,
int(time.mktime(self.modified.timetuple())))
def update_status(self, using=None):
if (self.status in [amo.STATUS_NULL, amo.STATUS_DELETED]
or self.is_disabled
or self.is_webapp() or self.is_persona()):
return
def logit(reason, old=self.status):
log.info('Changing add-on status [%s]: %s => %s (%s).'
% (self.id, old, self.status, reason))
amo.log(amo.LOG.CHANGE_STATUS, self.get_status_display(), self)
versions = self.versions.using(using)
if not versions.exists():
self.update(status=amo.STATUS_NULL)
logit('no versions')
elif not (versions.filter(files__isnull=False).exists()):
self.update(status=amo.STATUS_NULL)
logit('no versions with files')
elif (self.status == amo.STATUS_PUBLIC and
not versions.filter(files__status=amo.STATUS_PUBLIC).exists()):
if versions.filter(files__status=amo.STATUS_LITE).exists():
self.update(status=amo.STATUS_LITE)
logit('only lite files')
else:
self.update(status=amo.STATUS_UNREVIEWED)
logit('no reviewed files')
@staticmethod
def transformer(addons):
if not addons:
return
addon_dict = dict((a.id, a) for a in addons)
non_apps = [a for a in addons if a.type != amo.ADDON_WEBAPP]
personas = [a for a in addons if a.type == amo.ADDON_PERSONA]
addons = [a for a in addons if a.type != amo.ADDON_PERSONA]
version_ids = filter(None, (a._current_version_id for a in addons))
backup_ids = filter(None, (a._backup_version_id for a in addons))
all_ids = set(version_ids) | set(backup_ids)
versions = list(Version.objects.filter(id__in=all_ids).order_by()
.transform(Version.transformer))
for version in versions:
addon = addon_dict[version.addon_id]
if addon._current_version_id == version.id:
addon._current_version = version
elif addon._backup_version_id == version.id:
addon._backup_version = version
version.addon = addon
# Attach listed authors.
q = (UserProfile.objects.no_cache()
.filter(addons__in=addons, addonuser__listed=True)
.extra(select={'addon_id': 'addons_users.addon_id',
'position': 'addons_users.position'}))
q = sorted(q, key=lambda u: (u.addon_id, u.position))
for addon_id, users in itertools.groupby(q, key=lambda u: u.addon_id):
addon_dict[addon_id].listed_authors = list(users)
for persona in Persona.objects.no_cache().filter(addon__in=personas):
addon = addon_dict[persona.addon_id]
addon.persona = persona
addon.weekly_downloads = persona.popularity
# Personas need categories for the JSON dump.
Category.transformer(personas)
# Attach sharing stats.
sharing.attach_share_counts(AddonShareCountTotal, 'addon', addon_dict)
# Attach previews.
qs = Preview.objects.filter(addon__in=addons,
position__gte=0).order_by()
qs = sorted(qs, key=lambda x: (x.addon_id, x.position, x.created))
for addon, previews in itertools.groupby(qs, lambda x: x.addon_id):
addon_dict[addon].all_previews = list(previews)
# Attach _first_category for Firefox.
cats = dict(AddonCategory.objects.values_list('addon', 'category')
.filter(addon__in=addon_dict,
category__application=amo.FIREFOX.id))
qs = Category.objects.filter(id__in=set(cats.values()))
categories = dict((c.id, c) for c in qs)
for addon in addons:
category = categories[cats[addon.id]] if addon.id in cats else None
addon._first_category[amo.FIREFOX.id] = category
# There's a constrained amount of price tiers, may as well load
# them all and let cache machine keep them cached.
prices = dict((p.id, p) for p in Price.objects.all())
# Attach premium addons.
qs = AddonPremium.objects.filter(addon__in=addons)
for addon_p in qs:
if addon_dict[addon_p.addon_id].is_premium():
price = prices.get(addon_p.price_id)
if price:
addon_p.price = price
addon_dict[addon_p.addon_id]._premium = addon_p
# This isn't cheating, right? I don't want to add `compat` to
# market's INSTALLED_APPS.
if not settings.MARKETPLACE:
# Attach counts for add-on compatibility reports.
CompatReport.transformer(non_apps)
return addon_dict
@property
def show_beta(self):
return self.status == amo.STATUS_PUBLIC and self.current_beta_version
def show_adu(self):
return self.type not in (amo.ADDON_SEARCH, amo.ADDON_WEBAPP)
@amo.cached_property
def current_beta_version(self):
"""Retrieves the latest version of an addon, in the beta channel."""
versions = self.versions.filter(files__status=amo.STATUS_BETA)[:1]
if versions:
return versions[0]
@property
def icon_url(self):
return self.get_icon_url(32)
def authors_other_addons(self, app=None):
"""
Return other addons by the author(s) of this addon,
optionally takes an app.
"""
if app:
qs = Addon.objects.listed(app)
else:
qs = Addon.objects.valid()
return (qs.exclude(id=self.id)
.exclude(type=amo.ADDON_WEBAPP)
.filter(addonuser__listed=True,
authors__in=self.listed_authors)
.distinct())
@property
def contribution_url(self, lang=settings.LANGUAGE_CODE,
app=settings.DEFAULT_APP):
return reverse('addons.contribute', args=[self.slug])
@property
def thumbnail_url(self):
"""
Returns the addon's thumbnail url or a default.
"""
try:
preview = self.all_previews[0]
return preview.thumbnail_url
except IndexError:
return settings.MEDIA_URL + '/img/icons/no-preview.png'
def can_request_review(self):
"""Return the statuses an add-on can request."""
if not File.objects.filter(version__addon=self):
return ()
if self.is_disabled or self.status in (amo.STATUS_PUBLIC,
amo.STATUS_LITE_AND_NOMINATED,
amo.STATUS_DELETED):
return ()
elif self.status == amo.STATUS_NOMINATED:
return (amo.STATUS_LITE,)
elif self.status == amo.STATUS_UNREVIEWED:
return (amo.STATUS_PUBLIC,)
elif self.status == amo.STATUS_LITE:
if self.days_until_full_nomination() == 0:
return (amo.STATUS_PUBLIC,)
else:
# Still in preliminary waiting period...
return ()
else:
return (amo.STATUS_LITE, amo.STATUS_PUBLIC)
def days_until_full_nomination(self):
"""Returns number of days until author can request full review.
If wait period is over or this doesn't apply at all, returns 0 days.
An author must wait 10 days after submitting first LITE approval
to request FULL.
"""
if self.status != amo.STATUS_LITE:
return 0
# Calculate wait time from the earliest submitted version:
qs = (File.objects.filter(version__addon=self, status=self.status)
.order_by('created').values_list('datestatuschanged'))[:1]
if qs:
days_ago = datetime.now() - qs[0][0]
if days_ago < timedelta(days=10):
return 10 - days_ago.days
return 0
def is_persona(self):
return self.type == amo.ADDON_PERSONA
def is_webapp(self):
return self.type == amo.ADDON_WEBAPP
@property
def is_disabled(self):
"""True if this Addon is disabled.
It could be disabled by an admin or disabled by the developer
"""
return self.status == amo.STATUS_DISABLED or self.disabled_by_user
@property
def is_deleted(self):
return self.status == amo.STATUS_DELETED
def is_selfhosted(self):
return self.status == amo.STATUS_LISTED
@property
def is_under_review(self):
return self.status in amo.STATUS_UNDER_REVIEW
def is_unreviewed(self):
return self.status in amo.UNREVIEWED_STATUSES
def is_public(self):
return self.status == amo.STATUS_PUBLIC and not self.disabled_by_user
def is_incomplete(self):
return self.status == amo.STATUS_NULL
def can_become_premium(self):
"""
Not all addons can become premium and those that can only at
certain times. Webapps can become premium at any time.
"""
if self.upsell:
return False
if self.type == amo.ADDON_WEBAPP and not self.is_premium():
return True
return (self.status in amo.PREMIUM_STATUSES
and self.highest_status in amo.PREMIUM_STATUSES
and self.type in amo.ADDON_BECOME_PREMIUM)
def is_premium(self):
return self.premium_type in amo.ADDON_PREMIUMS
def is_free(self):
return not (self.is_premium() and self.premium and
self.premium.has_price())
def needs_paypal(self):
return (self.premium_type not in
(amo.ADDON_FREE, amo.ADDON_OTHER_INAPP))
def can_be_purchased(self):
return self.is_premium() and self.status in amo.REVIEWED_STATUSES
def can_be_deleted(self):
"""Only incomplete or free addons can be deleted."""
if waffle.switch_is_active('soft_delete'):
return not self.is_deleted
return self.is_incomplete() or not (
self.is_premium() or self.is_webapp())
@classmethod
def featured_random(cls, app, lang):
return get_featured_ids(app, lang)
def is_no_restart(self):
"""Is this a no-restart add-on?"""
files = self.current_version and self.current_version.all_files
return bool(files and files[0].no_restart)
def is_featured(self, app, lang=None):
"""Is add-on globally featured for this app and language?"""
if app:
return self.id in get_featured_ids(app, lang)
def has_full_profile(self):
"""Is developer profile public (completed)?"""
return self.the_reason and self.the_future
def has_profile(self):
"""Is developer profile (partially or entirely) completed?"""
return self.the_reason or self.the_future
@amo.cached_property
def tags_partitioned_by_developer(self):
"""Returns a tuple of developer tags and user tags for this addon."""
tags = self.tags.not_blacklisted()
if self.is_persona:
return models.query.EmptyQuerySet(), tags
user_tags = tags.exclude(addon_tags__user__in=self.listed_authors)
dev_tags = tags.exclude(id__in=[t.id for t in user_tags])
return dev_tags, user_tags
@amo.cached_property
def compatible_apps(self):
"""Shortcut to get compatible apps for the current version."""
# Search providers and personas don't list their supported apps.
if self.type in amo.NO_COMPAT:
return dict((app, None) for app in
amo.APP_TYPE_SUPPORT[self.type])
if self.current_version:
return self.current_version.compatible_apps
else:
return {}
def accepts_compatible_apps(self):
"""True if this add-on lists compatible apps."""
return self.type not in amo.NO_COMPAT
def incompatible_latest_apps(self):
"""Returns a list of applications with which this add-on is
incompatible (based on the latest version).
"""
return [a for a, v in self.compatible_apps.items() if v and
version_int(v.max.version) < version_int(a.latest_version)]
@caching.cached_method
def has_author(self, user, roles=None):
"""True if ``user`` is an author with any of the specified ``roles``.
``roles`` should be a list of valid roles (see amo.AUTHOR_ROLE_*). If
not specified, has_author will return true if the user has any role.
"""
if user is None or user.is_anonymous():
return False
if roles is None:
roles = dict(amo.AUTHOR_CHOICES).keys()
return AddonUser.objects.filter(addon=self, user=user,
role__in=roles).exists()
@property
def takes_contributions(self):
return (self.status == amo.STATUS_PUBLIC and self.wants_contributions
and (self.paypal_id or self.charity_id))
@property
def has_eula(self):
return self.eula
@classmethod
def _last_updated_queries(cls):
"""
Get the queries used to calculate addon.last_updated.
"""
status_change = Max('versions__files__datestatuschanged')
public = (Addon.uncached.filter(status=amo.STATUS_PUBLIC,
versions__files__status=amo.STATUS_PUBLIC)
.exclude(type__in=(amo.ADDON_PERSONA, amo.ADDON_WEBAPP))
.values('id').annotate(last_updated=status_change))
lite = (Addon.uncached.filter(status__in=amo.LISTED_STATUSES,
versions__files__status=amo.STATUS_LITE)
.exclude(type=amo.ADDON_WEBAPP)
.values('id').annotate(last_updated=status_change))
stati = amo.LISTED_STATUSES + (amo.STATUS_PUBLIC,)
exp = (Addon.uncached.exclude(status__in=stati)
.filter(versions__files__status__in=amo.VALID_STATUSES)
.exclude(type=amo.ADDON_WEBAPP)
.values('id')
.annotate(last_updated=Max('versions__files__created')))
listed = (Addon.uncached.filter(status=amo.STATUS_LISTED)
.values('id')
.annotate(last_updated=Max('versions__created')))
personas = (Addon.uncached.filter(type=amo.ADDON_PERSONA)
.extra(select={'last_updated': 'created'}))
return dict(public=public, exp=exp, listed=listed, personas=personas,
lite=lite)
@amo.cached_property(writable=True)
def all_categories(self):
return list(self.categories.all())
@amo.cached_property(writable=True)
def all_previews(self):
return list(self.get_previews())
def get_previews(self):
"""Exclude promo graphics."""
return self.previews.exclude(position=-1)
@property
def app_categories(self):
categories = sorted_groupby(order_by_translation(self.categories.all(),
'name'),
key=lambda x: x.application_id)
app_cats = []
for app_id, cats in categories:
app = amo.APP_IDS.get(app_id)
if app_id and not app:
# Skip retired applications like Sunbird.
continue
app_cats.append((app, list(cats)))
return app_cats
def remove_locale(self, locale):
"""NULLify strings in this locale for the add-on and versions."""
for o in itertools.chain([self], self.versions.all()):
ids = [getattr(o, f.attname) for f in o._meta.translated_fields]
qs = Translation.objects.filter(id__in=filter(None, ids),
locale=locale)
qs.update(localized_string=None, localized_string_clean=None)
def app_perf_results(self):
"""Generator of (AppVersion, [list of perf results contexts]).
A performance result context is a dict that has these keys:
**baseline**
The baseline of the result. For startup time this is the
time it takes to start up with no addons.
**startup_is_too_slow**
True/False if this result is slower than the threshold.
**result**
Actual result object
"""
res = collections.defaultdict(list)
baselines = {}
for result in (self.performance
.select_related('osversion', 'appversion')
.order_by('-created')[:20]):
k = (result.appversion.id, result.osversion.id, result.test)
if k not in baselines:
baselines[k] = result.get_baseline()
baseline = baselines[k]
appver = result.appversion
slow = result.startup_is_too_slow(baseline=baseline)
res[appver].append({'baseline': baseline,
'startup_is_too_slow': slow,
'result': result})
return res.iteritems()
def get_localepicker(self):
"""For language packs, gets the contents of localepicker."""
if (self.type == amo.ADDON_LPAPP and self.status == amo.STATUS_PUBLIC
and self.current_version):
files = (self.current_version.files
.filter(platform__in=amo.MOBILE_PLATFORMS.keys()))
try:
return unicode(files[0].get_localepicker(), 'utf-8')
except IndexError:
pass
return ''
@amo.cached_property
def upsell(self):
"""Return the upsell or add-on, or None if there isn't one."""
try:
# We set unique_together on the model, so there will only be one.
return self._upsell_from.all()[0]
except IndexError:
pass
@amo.cached_property
def upsold(self):
"""
Return what this is going to upsold from,
or None if there isn't one.
"""
try:
return self._upsell_to.all()[0]
except IndexError:
pass
def get_purchase_type(self, user):
if user and isinstance(user, UserProfile):
try:
return self.addonpurchase_set.get(user=user).type
except models.ObjectDoesNotExist:
pass
def has_purchased(self, user):
return self.get_purchase_type(user) == amo.CONTRIB_PURCHASE
def is_refunded(self, user):
return self.get_purchase_type(user) == amo.CONTRIB_REFUND
def is_chargeback(self, user):
return self.get_purchase_type(user) == amo.CONTRIB_CHARGEBACK
def can_review(self, user):
if user and self.has_author(user):
return False
else:
return (not self.is_premium() or self.has_purchased(user) or
self.is_refunded(user))
@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
@property
def all_dependencies(self):
"""Return all the add-ons this add-on depends on."""
return list(self.dependencies.all()[:3])
def get_watermark_hash(self, user):
"""
Create a hash for the addon using the user and addon. Suitable for
receipts or addon updates.
"""
keys = [user.pk, time.mktime(user.created.timetuple()),
self.pk, time.mktime(self.created.timetuple())]
return hmac.new(settings.WATERMARK_SECRET_KEY,
''.join(map(str, keys)),
hashlib.sha512).hexdigest()
def get_user_from_hash(self, email, hsh):
"""
Will try and match the watermark hash against a series of users,
based on any users who has had the addon. Will return the user
if it's found the person, otherwise None.
"""
for user in find_users(email):
if hsh == self.get_watermark_hash(user):
return user
def has_installed(self, user):
if not user or not isinstance(user, UserProfile):
return False
return self.installed.filter(user=user).exists()
class AddonDeviceType(amo.models.ModelBase):
addon = models.ForeignKey(Addon)
device_type = models.PositiveIntegerField(default=amo.DEVICE_DESKTOP,
choices=do_dictsort(amo.DEVICE_TYPES), db_index=True)
class Meta:
db_table = 'addons_devicetypes'
def __unicode__(self):
return u'%s: %s' % (self.addon.name, self.device.name)
def device(self):
return amo.DEVICE_TYPES[device_type]
@receiver(dbsignals.post_save, sender=Addon,
dispatch_uid='addons.update.name.table')
def update_name_table(sender, **kw):
log.debug('post_save signal called to update name table.')
from . import cron
if not kw.get('raw'):
addon = kw['instance']
if addon.name:
data = {'name_id': addon.name_id, 'id': addon.id,
'type': addon.type}
log.debug('Build reverse name lookup with data: %s' % data)
cron._build_reverse_name_lookup([data], clear=True)
@receiver(signals.version_changed, dispatch_uid='version_changed')
def version_changed(sender, **kw):
from . import tasks
tasks.version_changed.delay(sender.id)
@receiver(dbsignals.post_save, sender=Addon,
dispatch_uid='addons.search.index')
def update_search_index(sender, instance, **kw):
from . import tasks
if not kw.get('raw'):
tasks.index_addons.delay([instance.id])
@Addon.on_change
def watch_status(old_attr={}, new_attr={}, instance=None,
sender=None, **kw):
"""Set nomination date if self.status asks for full review.
The nomination date will only be set when the status of the addon changes.
The nomination date cannot be reset, say, when a developer cancels their
request for full review and re-requests full review.
If a version is rejected after nomination, the developer has to upload a
new version.
"""
new_status = new_attr.get('status')
if not new_status:
return
addon = instance
stati = (amo.STATUS_NOMINATED, amo.STATUS_LITE_AND_NOMINATED)
if new_status in stati and old_attr['status'] != new_status:
try:
latest = addon.versions.latest()
if not latest.nomination:
latest.update(nomination=datetime.now())
except Version.DoesNotExist:
pass
@Addon.on_change
def watch_disabled(old_attr={}, new_attr={}, instance=None, sender=None, **kw):
attrs = dict((k, v) for k, v in old_attr.items()
if k in ('disabled_by_user', 'status'))
if Addon(**attrs).is_disabled and not instance.is_disabled:
for f in File.objects.filter(version__addon=instance.id):
f.unhide_disabled_file()
if instance.is_disabled and not Addon(**attrs).is_disabled:
for f in File.objects.filter(version__addon=instance.id):
f.hide_disabled_file()
class Persona(caching.CachingMixin, models.Model):
"""Personas-specific additions to the add-on model."""
addon = models.OneToOneField(Addon)
persona_id = models.PositiveIntegerField(db_index=True)
# name: deprecated in favor of Addon model's name field
# description: deprecated, ditto
header = models.CharField(max_length=64, null=True)
footer = models.CharField(max_length=64, null=True)
accentcolor = models.CharField(max_length=10, null=True)
textcolor = models.CharField(max_length=10, null=True)
author = models.CharField(max_length=32, null=True)
display_username = models.CharField(max_length=32, null=True)
submit = models.DateTimeField(null=True)
approve = models.DateTimeField(null=True)
movers = models.FloatField(null=True, db_index=True)
popularity = models.IntegerField(null=False, default=0, db_index=True)
license = models.ForeignKey('versions.License', null=True)
objects = caching.CachingManager()
class Meta:
db_table = 'personas'
def __unicode__(self):
return unicode(self.addon.name)
def is_new(self):
return self.persona_id == 0
def flush_urls(self):
urls = ['*/addon/%d/' % self.addon_id,
'*/api/*/addon/%d' % self.addon_id,
self.thumb_url,
self.icon_url,
self.preview_url,
self.header_url,
self.footer_url,
self.update_url]
return urls
def _image_url(self, filename, ssl=True):
if self.is_new():
return settings.NEW_PERSONAS_IMAGE_URL % {'id': self.addon.id,
'file': filename}
else:
# TODO(cvan): Remove when getpersonas.com images go bye-bye.
base_url = (settings.PERSONAS_IMAGE_URL_SSL if ssl else
settings.PERSONAS_IMAGE_URL)
return base_url % {
'units': self.persona_id % 10,
'tens': (self.persona_id // 10) % 10,
'id': self.persona_id,
'file': filename,
}
@amo.cached_property
def thumb_url(self):
"""URL to Persona's thumbnail preview."""
if self.is_new():
return self._image_url('thumb.jpg')
else:
return self._image_url('preview.jpg')
@amo.cached_property
def icon_url(self):
"""URL to personas square preview."""
if self.is_new():
return self._image_url('icon.jpg')
else:
return self._image_url('preview_small.jpg')
@amo.cached_property
def preview_url(self):
"""URL to Persona's big, 680px, preview."""
if self.is_new():
return self._image_url('preview.jpg')
else:
return self._image_url('preview_large.jpg')
@amo.cached_property
def header_url(self):
return self._image_url(self.header, ssl=False)
@amo.cached_property
def footer_url(self):
return self._image_url(self.footer, ssl=False)
@amo.cached_property
def update_url(self):
return settings.PERSONAS_UPDATE_URL % self.persona_id
@amo.cached_property
def json_data(self):
"""Persona JSON Data for Browser/extension preview."""
hexcolor = lambda color: '#%s' % color
addon = self.addon
return json.dumps({
'id': unicode(self.persona_id), # Personas dislikes ints
'name': addon.name,
'accentcolor': hexcolor(self.accentcolor),
'textcolor': hexcolor(self.textcolor),
'category': (addon.all_categories[0].name if
addon.all_categories else ''),
'author': self.author,
'description': addon.description,
'header': self.header_url,
'footer': self.footer_url,
'headerURL': self.header_url,
'footerURL': self.footer_url,
'previewURL': self.thumb_url,
'iconURL': self.icon_url,
'updateURL': self.update_url,
}, separators=(',', ':'), cls=JSONEncoder)
def authors_other_addons(self, app=None):
"""
Return other addons by the author(s) of this addon,
optionally takes an app.
"""
qs = (Addon.objects.valid()
.exclude(id=self.addon.id)
.filter(type=amo.ADDON_PERSONA))
# TODO(andym): delete this once personas are migrated.
if not waffle.switch_is_active('personas-migration-completed'):
return (qs.filter(persona__author=self.author)
.select_related('persona'))
return (qs.filter(addonuser__listed=True,
authors__in=self.addon.listed_authors)
.distinct())
@amo.cached_property(writable=True)
def listed_authors(self):
# TODO(andym): delete this once personas are migrated.
if not waffle.switch_is_active('personas-migration-completed'):
class PersonaAuthor(unicode):
@property
def name(self):
return self
return [PersonaAuthor(self.display_username)]
return self.addon.listed_authors
class AddonCategory(caching.CachingMixin, models.Model):
addon = models.ForeignKey(Addon)
category = models.ForeignKey('Category')
feature = models.BooleanField(default=False)
feature_locales = models.CharField(max_length=255, default='', null=True)
objects = caching.CachingManager()
class Meta:
db_table = 'addons_categories'
unique_together = ('addon', 'category')
def flush_urls(self):
urls = ['*/addon/%d/' % self.addon_id,
'*%s' % self.category.get_url_path(), ]
return urls
@classmethod
def creatured_random(cls, category, lang):
return get_creatured_ids(category, lang)
class AddonRecommendation(models.Model):
"""
Add-on recommendations. For each `addon`, a group of `other_addon`s
is recommended with a score (= correlation coefficient).
"""
addon = models.ForeignKey(Addon, related_name="addon_recommendations")
other_addon = models.ForeignKey(Addon, related_name="recommended_for")
score = models.FloatField()
class Meta:
db_table = 'addon_recommendations'
ordering = ('-score',)
@classmethod
def scores(cls, addon_ids):
"""Get a mapping of {addon: {other_addon: score}} for each add-on."""
d = {}
q = (AddonRecommendation.objects.filter(addon__in=addon_ids)
.values('addon', 'other_addon', 'score'))
for addon, rows in sorted_groupby(q, key=lambda x: x['addon']):
d[addon] = dict((r['other_addon'], r['score']) for r in rows)
return d
class AddonType(amo.models.ModelBase):
name = TranslatedField()
name_plural = TranslatedField()
description = TranslatedField()
class Meta:
db_table = 'addontypes'
def __unicode__(self):
return unicode(self.name)
def get_url_path(self):
try:
type = amo.ADDON_SLUGS[self.id]
except KeyError:
return None
return reverse('browse.%s' % type)
class AddonUser(caching.CachingMixin, models.Model):
addon = models.ForeignKey(Addon)
user = UserForeignKey()
role = models.SmallIntegerField(default=amo.AUTHOR_ROLE_OWNER,
choices=amo.AUTHOR_CHOICES)
listed = models.BooleanField(_(u'Listed'), default=True)
position = models.IntegerField(default=0)
objects = caching.CachingManager()
def __init__(self, *args, **kwargs):
super(AddonUser, self).__init__(*args, **kwargs)
self._original_role = self.role
self._original_user_id = self.user_id
class Meta:
db_table = 'addons_users'
def flush_urls(self):
return self.addon.flush_urls() + self.user.flush_urls()
class AddonDependency(models.Model):
addon = models.ForeignKey(Addon, related_name='addons_dependencies')
dependent_addon = models.ForeignKey(Addon, related_name='dependent_on')
class Meta:
db_table = 'addons_dependencies'
unique_together = ('addon', 'dependent_addon')
class BlacklistedGuid(amo.models.ModelBase):
guid = models.CharField(max_length=255, unique=True)
comments = models.TextField(default='', blank=True)
class Meta:
db_table = 'blacklisted_guids'
def __unicode__(self):
return self.guid
class Category(amo.models.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(amo.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(Addon, through='AddonCategory')
class Meta:
db_table = 'categories'
verbose_name_plural = 'Categories'
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 = amo.ADDON_SLUGS[self.type]
except KeyError:
type = amo.ADDON_SLUGS[amo.ADDON_EXTENSION]
if settings.MARKETPLACE and self.type == amo.ADDON_PERSONA:
#TODO: (davor) this is a temp stub. Return category URL when done.
return reverse('themes.browse', args=[self.slug])
return 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 Feature(amo.models.ModelBase):
addon = models.ForeignKey(Addon)
start = models.DateTimeField()
end = models.DateTimeField()
locale = models.CharField(max_length=10, default='', blank=True, null=True)
application = models.ForeignKey('applications.Application')
class Meta:
db_table = 'features'
def __unicode__(self):
app = amo.APP_IDS[self.application.id].pretty
return '%s (%s: %s)' % (self.addon.name, app, self.locale)
class Preview(amo.models.ModelBase):
addon = models.ForeignKey(Addon, related_name='previews')
filetype = models.CharField(max_length=25)
thumbtype = models.CharField(max_length=25)
caption = TranslatedField()
position = models.IntegerField(default=0)
sizes = json_field.JSONField(max_length=25, default={})
class Meta:
db_table = 'previews'
ordering = ('position', 'created')
def flush_urls(self):
urls = ['*/addon/%d/' % self.addon_id,
self.thumbnail_url,
self.image_url, ]
return urls
def _image_url(self, url_template):
if self.modified is not None:
modified = int(time.mktime(self.modified.timetuple()))
else:
modified = 0
args = [self.id / 1000, self.id, modified]
if '.png' not in url_template:
args.insert(2, self.file_extension)
return url_template % tuple(args)
def _image_path(self, url_template):
args = [self.id / 1000, self.id]
if '.png' not in url_template:
args.append(self.file_extension)
return url_template % tuple(args)
def as_dict(self, src=None):
d = {'full': urlparams(self.image_url, src=src),
'thumbnail': urlparams(self.thumbnail_url, src=src),
'caption': unicode(self.caption)}
return d
@property
def file_extension(self):
# Assume that blank is an image.
if not self.filetype:
return 'png'
return self.filetype.split('/')[1]
@property
def thumbnail_url(self):
return self._image_url(settings.PREVIEW_THUMBNAIL_URL)
@property
def image_url(self):
return self._image_url(settings.PREVIEW_FULL_URL)
@property
def thumbnail_path(self):
return self._image_path(settings.PREVIEW_THUMBNAIL_PATH)
@property
def image_path(self):
return self._image_path(settings.PREVIEW_FULL_PATH)
@property
def thumbnail_size(self):
return self.sizes.get('thumbnail', []) if self.sizes else []
@property
def image_size(self):
return self.sizes.get('image', []) if self.sizes else []
class AppSupport(amo.models.ModelBase):
"""Cache to tell us if an add-on's current version supports an app."""
addon = models.ForeignKey(Addon)
app = models.ForeignKey('applications.Application')
min = models.BigIntegerField("Minimum app version", null=True)
max = models.BigIntegerField("Maximum app version", null=True)
class Meta:
db_table = 'appsupport'
unique_together = ('addon', 'app')
class Charity(amo.models.ModelBase):
name = models.CharField(max_length=255)
url = models.URLField(verify_exists=False)
paypal = models.CharField(max_length=255)
class Meta:
db_table = 'charities'
@property
def outgoing_url(self):
if self.pk == amo.FOUNDATION_ORG:
return self.url
return get_outgoing_url(unicode(self.url))
class BlacklistedSlug(amo.models.ModelBase):
name = models.CharField(max_length=255, unique=True, default='')
class Meta:
db_table = 'addons_blacklistedslug'
def __unicode__(self):
return self.name
@classmethod
def blocked(cls, slug):
return slug.isdigit() or cls.objects.filter(name=slug).exists()
class FrozenAddon(models.Model):
"""Add-ons in this table never get a hotness score."""
addon = models.ForeignKey(Addon)
class Meta:
db_table = 'frozen_addons'
def __unicode__(self):
return 'Frozen: %s' % self.addon_id
@receiver(dbsignals.post_save, sender=FrozenAddon)
def freezer(sender, instance, **kw):
# Adjust the hotness of the FrozenAddon.
if instance.addon_id:
Addon.objects.get(id=instance.addon_id).update(hotness=0)
class AddonUpsell(amo.models.ModelBase):
free = models.ForeignKey(Addon, related_name='_upsell_from')
premium = models.ForeignKey(Addon, related_name='_upsell_to')
text = PurifiedField()
class Meta:
db_table = 'addon_upsell'
unique_together = ('free', 'premium')
def __unicode__(self):
return u'Free: %s to Premium: %s' % (self.free, self.premium)
@amo.cached_property
def premium_addon(self):
"""
Return the premium version, or None if there isn't one.
"""
try:
return self.premium
except Addon.DoesNotExist:
pass
class CompatOverride(amo.models.ModelBase):
"""Helps manage compat info for add-ons not hosted on AMO."""
name = models.CharField(max_length=255, blank=True, null=True)
guid = models.CharField(max_length=255, unique=True)
addon = models.ForeignKey(Addon, blank=True, null=True,
help_text='Fill this out to link an override '
'to a hosted add-on')
class Meta:
db_table = 'compat_override'
def save(self, *args, **kw):
if not self.addon:
qs = Addon.objects.filter(guid=self.guid)
if qs:
self.addon = qs[0]
return super(CompatOverride, self).save(*args, **kw)
def __unicode__(self):
if self.addon:
return unicode(self.addon)
elif self.name:
return '%s (%s)' % (self.name, self.guid)
else:
return self.guid
def is_hosted(self):
"""Am I talking about an add-on on AMO?"""
return bool(self.addon_id)
@staticmethod
def transformer(overrides):
if not overrides:
return
id_map = dict((o.id, o) for o in overrides)
qs = CompatOverrideRange.objects.filter(compat__in=id_map)
for compat_id, ranges in sorted_groupby(qs, 'compat_id'):
id_map[compat_id].compat_ranges = list(ranges)
# May be filled in by a transformer for performance.
@amo.cached_property(writable=True)
def compat_ranges(self):
return list(self._compat_ranges.all())
def collapsed_ranges(self):
"""Collapse identical version ranges into one entity."""
Range = collections.namedtuple('Range', 'type min max apps')
AppRange = collections.namedtuple('AppRange', 'app min max')
rv = []
sort_key = lambda x: (x.min_version, x.max_version, x.type)
for key, compats in sorted_groupby(self.compat_ranges, key=sort_key):
compats = list(compats)
first = compats[0]
item = Range(first.override_type(), first.min_version,
first.max_version, [])
for compat in compats:
app = AppRange(amo.APPS_ALL[compat.app_id],
compat.min_app_version, compat.max_app_version)
item.apps.append(app)
rv.append(item)
return rv
OVERRIDE_TYPES = (
(0, 'Compatible (not supported)'),
(1, 'Incompatible'),
)
class CompatOverrideRange(amo.models.ModelBase):
"""App compatibility for a certain version range of a RemoteAddon."""
compat = models.ForeignKey(CompatOverride, related_name='_compat_ranges')
type = models.SmallIntegerField(choices=OVERRIDE_TYPES, default=1)
min_version = models.CharField(max_length=255, default='0',
help_text=u'If not "0", version is required to exist for the override'
' to take effect.')
max_version = models.CharField(max_length=255, default='*',
help_text=u'If not "*", version is required to exist for the override'
' to take effect.')
app = models.ForeignKey('applications.Application')
min_app_version = models.CharField(max_length=255, default='0')
max_app_version = models.CharField(max_length=255, default='*')
class Meta:
db_table = 'compat_override_range'
def override_type(self):
"""This is what Firefox wants to see in the XML output."""
return {0: 'compatible', 1: 'incompatible'}[self.type]
class IncompatibleVersions(amo.models.ModelBase):
"""
Denormalized table to join against for fast compat override filtering.
This was created to be able to join against a specific version record since
the CompatOverrideRange can be wildcarded (e.g. 0 to *, or 1.0 to 1.*), and
addon versioning isn't as consistent as Firefox versioning to trust
`version_int` in all cases. So extra logic needed to be provided for when
a particular version falls within the range of a compatibility override.
"""
version = models.ForeignKey(Version, related_name='+')
app = models.ForeignKey('applications.Application', related_name='+')
min_app_version = models.CharField(max_length=255, blank=True, default='0')
max_app_version = models.CharField(max_length=255, blank=True, default='*')
min_app_version_int = models.BigIntegerField(blank=True, null=True,
editable=False, db_index=True)
max_app_version_int = models.BigIntegerField(blank=True, null=True,
editable=False, db_index=True)
class Meta:
db_table = 'incompatible_versions'
def __unicode__(self):
return u'<IncompatibleVersion V:%s A:%s %s-%s>' % (
self.version.id, self.app.id, self.min_app_version,
self.max_app_version)
def save(self, *args, **kw):
self.min_app_version_int = version_int(self.min_app_version)
self.max_app_version_int = version_int(self.max_app_version)
return super(IncompatibleVersions, self).save(*args, **kw)
def update_incompatible_versions(sender, instance, **kw):
if not instance.compat.addon_id:
return
if not instance.compat.addon.type == amo.ADDON_EXTENSION:
return
from . import tasks
versions = instance.compat.addon.versions.values_list('id', flat=True)
for chunk in chunked(versions, 50):
tasks.update_incompatible_appversions.delay(chunk)
models.signals.post_save.connect(update_incompatible_versions,
sender=CompatOverrideRange,
dispatch_uid='cor_update_incompatible')
models.signals.post_delete.connect(update_incompatible_versions,
sender=CompatOverrideRange,
dispatch_uid='cor_update_incompatible')
# webapps.models imports addons.models to get Addon, so we need to keep the
# Webapp import down here.
from mkt.webapps.models import Webapp