class FeedApp(amo.models.ModelBase): """ Thin wrapper around the Webapp class that allows single apps to be featured on the feed. """ app = models.ForeignKey(Webapp) description = PurifiedField() # Optionally linked to a Preview (screenshot or video). preview = models.ForeignKey(Preview, null=True, blank=True) # Optionally linked to a pull quote. pullquote_rating = models.PositiveSmallIntegerField(null=True, blank=True, validators=[validate_rating]) pullquote_text = PurifiedField(null=True) pullquote_attribution = PurifiedField(null=True) class Meta: db_table = 'mkt_feed_app' def clean(self): """ Require `pullquote_text` if `pullquote_rating` or `pullquote_attribution` are set. """ if not self.pullquote_text and (self.pullquote_rating or self.pullquote_attribution): raise ValidationError('Pullquote text required if rating or ' 'attribution is defined.') super(FeedApp, self).clean()
class L10nSettings(amo.models.ModelBase): """Per-locale L10n Dashboard settings""" locale = models.CharField(max_length=30, default='', unique=True) motd = PurifiedField() team_homepage = models.CharField(max_length=255, default='', null=True) class Meta: db_table = 'l10n_settings'
class FeedApp(amo.models.ModelBase): """ Model for "Custom Featured Apps", a feed item highlighting a single app and some additional metadata (e.g. a review or a screenshot). """ app = models.ForeignKey(Webapp) feedapp_type = models.CharField(choices=FEEDAPP_TYPES, max_length=30) description = PurifiedField() slug = SlugField(max_length=30, unique=True) background_color = ColorField(null=True) # Optionally linked to a Preview (screenshot or video). preview = models.ForeignKey(Preview, null=True, blank=True) # Optionally linked to a pull quote. pullquote_attribution = models.CharField(max_length=50, null=True, blank=True) pullquote_rating = models.PositiveSmallIntegerField(null=True, blank=True, validators=[validate_rating]) pullquote_text = PurifiedField(null=True) image_hash = models.CharField(default=None, max_length=8, null=True, blank=True) class Meta: db_table = 'mkt_feed_app' def clean(self): """ Require `pullquote_text` if `pullquote_rating` or `pullquote_attribution` are set. """ if not self.pullquote_text and (self.pullquote_rating or self.pullquote_attribution): raise ValidationError('Pullquote text required if rating or ' 'attribution is defined.') super(FeedApp, self).clean() def image_path(self): return os.path.join(settings.FEATURED_APP_BG_PATH, str(self.pk / 1000), 'featured_app_%s.png' % (self.pk,)) @property def has_image(self): return bool(self.image_hash)
class FeedApp(amo.models.ModelBase): """ Thin wrapper around the Webapp class that allows single apps to be featured on the feed. """ app = models.ForeignKey(Webapp) feedapp_type = models.CharField(choices=FEEDAPP_TYPES, max_length=30) description = PurifiedField() slug = SlugField(max_length=30) background_color = ColorField(null=True) has_image = models.BooleanField(default=False) # Optionally linked to a Preview (screenshot or video). preview = models.ForeignKey(Preview, null=True, blank=True) # Optionally linked to a pull quote. pullquote_rating = models.PositiveSmallIntegerField( null=True, blank=True, validators=[validate_rating]) pullquote_text = PurifiedField(null=True) pullquote_attribution = PurifiedField(null=True) class Meta: db_table = 'mkt_feed_app' def clean(self): """ Require `pullquote_text` if `pullquote_rating` or `pullquote_attribution` are set. """ if not self.pullquote_text and (self.pullquote_rating or self.pullquote_attribution): raise ValidationError('Pullquote text required if rating or ' 'attribution is defined.') super(FeedApp, self).clean() def image_path(self): return os.path.join(settings.FEATURED_APP_BG_PATH, str(self.pk / 1000), 'featured_app_%s.png' % (self.pk, ))
class Version(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon', related_name='versions') license = models.ForeignKey('License', null=True) releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') version_int = models.BigIntegerField(null=True, editable=False) nomination = models.DateTimeField(null=True) reviewed = models.DateTimeField(null=True) has_info_request = models.BooleanField(default=False) has_editor_comment = models.BooleanField(default=False) deleted = models.BooleanField(default=False) supported_locales = models.CharField(max_length=255) objects = VersionManager() with_deleted = VersionManager(include_deleted=True) class Meta(amo.models.ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) self.__dict__.update(version_dict(self.version or '')) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): if not self.version_int and self.version: v_int = version_int(self.version) # Magic number warning, this is the maximum size # of a big int in MySQL to prevent version_int overflow, for # people who have rather crazy version numbers. # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html if v_int < 9223372036854775807: self.version_int = v_int else: log.error('No version_int written for version %s, %s' % (self.pk, self.version)) creating = not self.id super(Version, self).save(*args, **kw) if creating: from mkt.webapps.models import AppFeatures if self.addon.type == amo.ADDON_WEBAPP: AppFeatures.objects.create(version=self) return self @classmethod def from_upload(cls, upload, addon, platforms, send_signal=True): data = utils.parse_addon(upload, addon) try: license = addon.versions.latest().license_id except Version.DoesNotExist: license = None v = cls.objects.create(addon=addon, version=data['version'], license_id=license) log.info('New version: %r (%s) from %r' % (v, v.id, upload)) # appversions AV = ApplicationsVersions for app in data.get('apps', []): AV(version=v, min=app.min, max=app.max, application_id=app.id).save() if addon.type in [amo.ADDON_SEARCH, amo.ADDON_WEBAPP]: # Search extensions and webapps are always for all platforms. platforms = [Platform.objects.get(id=amo.PLATFORM_ALL.id)] else: platforms = cls._make_safe_platform_files(platforms) for platform in platforms: File.from_upload(upload, v, platform, parse_data=data) if addon.type == amo.ADDON_WEBAPP: update_supported_locales_single.apply_async( args=[addon.id], kwargs={'latest': True}, eta=datetime.datetime.now() + datetime.timedelta(seconds=settings.NFS_LAG_DELAY)) v.disable_old_files() # After the upload has been copied to all platforms, remove the upload. storage.delete(upload.path) if send_signal: version_uploaded.send(sender=v) # If packaged app and app is blocked, put in escalation queue. if (addon.is_webapp() and addon.is_packaged and addon.status == amo.STATUS_BLOCKED): # To avoid circular import. from editors.models import EscalationQueue EscalationQueue.objects.create(addon=addon) return v @classmethod def _make_safe_platform_files(cls, platforms): """Make file platform translations until all download pages support desktop ALL + mobile ALL. See bug 646268. """ pl_set = set([p.id for p in platforms]) if pl_set == set([amo.PLATFORM_ALL_MOBILE.id, amo.PLATFORM_ALL.id]): # Make it really ALL: return [Platform.objects.get(id=amo.PLATFORM_ALL.id)] has_mobile = any(p in amo.MOBILE_PLATFORMS for p in pl_set) has_desktop = any(p in amo.DESKTOP_PLATFORMS for p in pl_set) has_all = any(p in (amo.PLATFORM_ALL_MOBILE.id, amo.PLATFORM_ALL.id) for p in pl_set) is_mixed = has_mobile and has_desktop if (is_mixed and has_all) or has_mobile: # Mixing desktop and mobile w/ ALL is not safe; # we have to split the files into exact platforms. # Additionally, it is not safe to use all-mobile. new_plats = [] for p in platforms: if p.id == amo.PLATFORM_ALL_MOBILE.id: new_plats.extend( list( Platform.objects.filter( id__in=amo.MOBILE_PLATFORMS).exclude( id=amo.PLATFORM_ALL_MOBILE.id))) elif p.id == amo.PLATFORM_ALL.id: new_plats.extend( list( Platform.objects.filter( id__in=amo.DESKTOP_PLATFORMS).exclude( id=amo.PLATFORM_ALL.id))) else: new_plats.append(p) return new_plats # Platforms are safe as is return platforms @property def path_prefix(self): return os.path.join(settings.ADDONS_PATH, str(self.addon_id)) @property def mirror_path_prefix(self): return os.path.join(settings.MIRROR_STAGE_PATH, str(self.addon_id)) def license_url(self, impala=False): return reverse('addons.license', args=[self.addon.slug, self.version]) def flush_urls(self): return self.addon.flush_urls() def get_url_path(self): return reverse('addons.versions', args=[self.addon.slug, self.version]) def delete(self): log.info(u'Version deleted: %r (%s)' % (self, self.id)) amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version)) if settings.MARKETPLACE: self.update(deleted=True) if self.addon.is_packaged: f = self.all_files[0] # Unlink signed packages if packaged app. storage.delete(f.signed_file_path) log.info(u'Unlinked file: %s' % f.signed_file_path) storage.delete(f.signed_reviewer_file_path) log.info(u'Unlinked file: %s' % f.signed_reviewer_file_path) else: super(Version, self).delete() @property def current_queue(self): """Return the current queue, or None if not in a queue.""" from editors.models import (ViewPendingQueue, ViewFullReviewQueue, ViewPreliminaryQueue) if self.addon.status in [ amo.STATUS_NOMINATED, amo.STATUS_LITE_AND_NOMINATED ]: return ViewFullReviewQueue elif self.addon.status == amo.STATUS_PUBLIC: return ViewPendingQueue elif self.addon.status in [amo.STATUS_LITE, amo.STATUS_UNREVIEWED]: return ViewPreliminaryQueue return None @amo.cached_property(writable=True) def all_activity(self): from devhub.models import VersionLog # yucky al = (VersionLog.objects.filter( version=self.id).order_by('created').select_related( depth=1).no_cache()) return al @amo.cached_property(writable=True) def compatible_apps(self): """Get a mapping of {APP: ApplicationVersion}.""" avs = self.apps.select_related(depth=1) return self._compat_map(avs) @amo.cached_property def compatible_apps_ordered(self): apps = self.compatible_apps.items() return sorted(apps, key=lambda v: v[0].short) def compatible_platforms(self): """Returns a dict of compatible file platforms for this version. The result is based on which app(s) the version targets. """ apps = set([a.application.id for a in self.apps.all()]) targets_mobile = amo.MOBILE.id in apps targets_other = any((a != amo.MOBILE.id) for a in apps) all_plats = {} if targets_other: all_plats.update(amo.DESKTOP_PLATFORMS) if targets_mobile: all_plats.update(amo.MOBILE_PLATFORMS) return all_plats @amo.cached_property def is_compatible(self): """Returns tuple of compatibility and reasons why if not. Server side conditions for determining compatibility are: * The add-on is an extension (not a theme, app, etc.) * Has not opted in to strict compatibility. * Does not use binary_components in chrome.manifest. Note: The lowest maxVersion compat check needs to be checked separately. Note: This does not take into account the client conditions. """ compat = True reasons = [] if self.addon.type != amo.ADDON_EXTENSION: compat = False # TODO: We may want this. For now we think it may be confusing. # reasons.append(_('Add-on is not an extension.')) if self.files.filter(binary_components=True).exists(): compat = False reasons.append(_('Add-on uses binary components.')) if self.files.filter(strict_compatibility=True).exists(): compat = False reasons.append( _('Add-on has opted into strict compatibility ' 'checking.')) return (compat, reasons) def is_compatible_app(self, app): """Returns True if the provided app passes compatibility conditions.""" appversion = self.compatible_apps.get(app) if appversion and app.id in amo.D2C_MAX_VERSIONS: return (version_int(appversion.max.version) >= version_int( amo.D2C_MAX_VERSIONS.get(app.id, '*'))) return False def compat_override_app_versions(self): """Returns the incompatible app versions range(s). If not ranges, returns empty list. Otherwise, this will return all the app version ranges that this particular version is incompatible with. """ from addons.models import CompatOverride cos = CompatOverride.objects.filter(addon=self.addon) if not cos: return [] app_versions = [] for co in cos: for range in co.collapsed_ranges(): if (version_int(range.min) <= version_int(self.version) <= version_int(range.max)): app_versions.extend([(a.min, a.max) for a in range.apps]) return app_versions @amo.cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) @amo.cached_property def supported_platforms(self): """Get a list of supported platform names.""" return list(set(amo.PLATFORMS[f.platform_id] for f in self.all_files)) @property def status(self): if settings.MARKETPLACE and self.deleted: return [amo.STATUS_CHOICES[amo.STATUS_DELETED]] else: return [amo.STATUS_CHOICES[f.status] for f in self.all_files] @property def statuses(self): """Unadulterated statuses, good for an API.""" return [(f.id, f.status) for f in self.all_files] def is_allowed_upload(self): """Check that a file can be uploaded based on the files per platform for that type of addon.""" num_files = len(self.all_files) if self.addon.type == amo.ADDON_SEARCH: return num_files == 0 elif num_files == 0: return True elif amo.PLATFORM_ALL in self.supported_platforms: return False elif amo.PLATFORM_ALL_MOBILE in self.supported_platforms: return False else: compatible = (v for k, v in self.compatible_platforms().items() if k not in (amo.PLATFORM_ALL.id, amo.PLATFORM_ALL_MOBILE.id)) return bool(set(compatible) - set(self.supported_platforms)) @property def has_files(self): return bool(self.all_files) @property def is_unreviewed(self): return filter(lambda f: f.status in amo.UNREVIEWED_STATUSES, self.all_files) @property def is_all_unreviewed(self): return not bool([ f for f in self.all_files if f.status not in amo.UNREVIEWED_STATUSES ]) @property def is_beta(self): return filter(lambda f: f.status == amo.STATUS_BETA, self.all_files) @property def is_lite(self): return filter(lambda f: f.status in amo.LITE_STATUSES, self.all_files) @property def is_jetpack(self): return all(f.jetpack_version for f in self.all_files) @classmethod def _compat_map(cls, avs): apps = {} for av in avs: app_id = av.application_id if app_id in amo.APP_IDS: apps[amo.APP_IDS[app_id]] = av return apps @classmethod def transformer(cls, versions): """Attach all the compatible apps and files to the versions.""" ids = set(v.id for v in versions) if not versions: return avs = (ApplicationsVersions.objects.filter( version__in=ids).select_related(depth=1).no_cache()) files = (File.objects.filter( version__in=ids).select_related('version').no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) av_dict, file_dict = rollup(avs), rollup(files) for version in versions: v_id = version.id version.compatible_apps = cls._compat_map(av_dict.get(v_id, [])) version.all_files = file_dict.get(v_id, []) @classmethod def transformer_activity(cls, versions): """Attach all the activity to the versions.""" from devhub.models import VersionLog # yucky ids = set(v.id for v in versions) if not versions: return al = (VersionLog.objects.filter( version__in=ids).order_by('created').select_related( depth=1).no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) al_dict = rollup(al) for version in versions: v_id = version.id version.all_activity = al_dict.get(v_id, []) def disable_old_files(self): if not self.files.filter(status=amo.STATUS_BETA).exists(): qs = File.objects.filter( version__addon=self.addon_id, version__lt=self.id, version__deleted=False, status__in=[amo.STATUS_UNREVIEWED, amo.STATUS_PENDING]) # Use File.update so signals are triggered. for f in qs: f.update(status=amo.STATUS_DISABLED)
class Collection(amo.models.ModelBase): collection_type = models.IntegerField(choices=COLLECTION_TYPES) description = PurifiedField() name = PurifiedField() is_public = models.BooleanField(default=False) # FIXME: add better / composite indexes that matches the query we are # going to make. category = models.ForeignKey(Category, null=True, blank=True) region = models.PositiveIntegerField( default=None, null=True, blank=True, choices=mkt.regions.REGIONS_CHOICES_ID, db_index=True) carrier = models.IntegerField(default=None, null=True, blank=True, choices=mkt.carriers.CARRIER_CHOICES, db_index=True) author = models.CharField(max_length=255, default='', blank=True) slug = SlugField(blank=True, max_length=30, help_text='Used in collection URLs.') default_language = models.CharField( max_length=10, choices=((to_language(lang), desc) for lang, desc in settings.LANGUAGES.items()), default=to_language(settings.LANGUAGE_CODE)) curators = models.ManyToManyField('users.UserProfile') background_color = ColorField(null=True) text_color = ColorField(null=True) has_image = models.BooleanField(default=False) can_be_hero = models.BooleanField( default=False, help_text= ('Indicates whether an operator shelf collection can be displayed with' 'a hero graphic')) _apps = models.ManyToManyField(Webapp, through='CollectionMembership', related_name='app_collections') objects = amo.models.ManagerBase() public = PublicCollectionsManager() class Meta: db_table = 'app_collections' ordering = ('-id', ) # This will change soon since we'll need to be # able to order collections themselves, but this # helps tests for now. def __unicode__(self): return self.name.localized_string_clean def save(self, **kw): self.clean_slug() return super(Collection, self).save(**kw) @use_master def clean_slug(self): clean_slug(self, 'slug') @classmethod def get_fallback(cls): return cls._meta.get_field('default_language') def image_path(self): return os.path.join(settings.COLLECTIONS_ICON_PATH, str(self.pk / 1000), 'app_collection_%s.png' % (self.pk, )) def apps(self): """ Public apps on the collection, ordered by their position in the CollectionMembership model. Use this method everytime you want to display apps for a collection to an user. """ return self._apps.filter( disabled_by_user=False, status=amo.STATUS_PUBLIC).order_by('collectionmembership') def add_app(self, app, order=None): """ Add an app to this collection. If specified, the app will be created with the specified `order`. If not, it will be added to the end of the collection. """ qs = CollectionMembership.objects.filter(collection=self) if order is None: aggregate = qs.aggregate(models.Max('order'))['order__max'] order = aggregate + 1 if aggregate is not None else 0 rval = CollectionMembership.objects.create(collection=self, app=app, order=order) # Help django-cache-machine: it doesn't like many 2 many relations, # the cache is never invalidated properly when adding a new object. CollectionMembership.objects.invalidate(*qs) index_webapps.delay([app.pk]) return rval def remove_app(self, app): """ Remove the passed app from this collection, returning a boolean indicating whether a successful deletion took place. """ try: membership = self.collectionmembership_set.get(app=app) except CollectionMembership.DoesNotExist: return False else: membership.delete() index_webapps.delay([app.pk]) return True def reorder(self, new_order): """ Passed a list of app IDs, e.g. [18, 24, 9] will change the order of each item in the collection to match the passed order. A ValueError will be raised if each app in the collection is not included in the ditionary. """ existing_pks = self.apps().no_cache().values_list('pk', flat=True) if set(existing_pks) != set(new_order): raise ValueError('Not all apps included') for order, pk in enumerate(new_order): CollectionMembership.objects.get(collection=self, app_id=pk).update(order=order) index_webapps.delay(new_order) def has_curator(self, userprofile): """ Returns boolean indicating whether the passed user profile is a curator on this collection. ID comparison used instead of directly checking objects to ensure that RequestUser or UserProfile objects could be passed. """ return userprofile.id in self.curators.values_list('id', flat=True) def add_curator(self, userprofile): ret = self.curators.add(userprofile) Collection.objects.invalidate(*self.curators.all()) return ret def remove_curator(self, userprofile): ret = self.curators.remove(userprofile) Collection.objects.invalidate(*self.curators.all()) return ret
class UserProfile(amo.models.OnChangeMixin, amo.models.ModelBase): username = models.CharField(max_length=255, default='', unique=True) display_name = models.CharField(max_length=255, default='', null=True, blank=True) password = models.CharField(max_length=255, default='') email = models.EmailField(unique=True, null=True) averagerating = models.CharField(max_length=255, blank=True, null=True) bio = PurifiedField(short=False) confirmationcode = models.CharField(max_length=255, default='', blank=True) deleted = models.BooleanField(default=False) display_collections = models.BooleanField(default=False) display_collections_fav = models.BooleanField(default=False) emailhidden = models.BooleanField(default=True) homepage = models.URLField(max_length=255, blank=True, default='', verify_exists=False) location = models.CharField(max_length=255, blank=True, default='') notes = models.TextField(blank=True, null=True) notifycompat = models.BooleanField(default=True) notifyevents = models.BooleanField(default=True) occupation = models.CharField(max_length=255, default='', blank=True) # This is essentially a "has_picture" flag right now picture_type = models.CharField(max_length=75, default='', blank=True) resetcode = models.CharField(max_length=255, default='', blank=True) resetcode_expires = models.DateTimeField(default=datetime.now, null=True, blank=True) read_dev_agreement = models.BooleanField(default=False) last_login_ip = models.CharField(default='', max_length=45, editable=False) last_login_attempt = models.DateTimeField(null=True, editable=False) last_login_attempt_ip = models.CharField(default='', max_length=45, editable=False) failed_login_attempts = models.PositiveIntegerField(default=0, editable=False) user = models.ForeignKey(DjangoUser, null=True, editable=False, blank=True) class Meta: db_table = 'users' def __init__(self, *args, **kw): super(UserProfile, self).__init__(*args, **kw) if self.username: self.username = smart_unicode(self.username) def __unicode__(self): return u'%s: %s' % (self.id, self.display_name or self.username) def is_anonymous(self): return False def get_url_path(self): if settings.MARKETPLACE: return reverse('users.profile', args=[self.username or self.id]) else: # AMO isn't ready for this. return reverse('users.profile', args=[self.id]) def flush_urls(self): urls = ['*/user/%d/' % self.id, self.picture_url, ] return urls @amo.cached_property def addons_listed(self): """Public add-ons this user is listed as author of.""" return self.addons.reviewed().exclude(type=amo.ADDON_WEBAPP).filter( addonuser__user=self, addonuser__listed=True) @amo.cached_property def apps_listed(self): """Public apps this user is listed as author of.""" return self.addons.reviewed().filter(type=amo.ADDON_WEBAPP, addonuser__user=self, addonuser__listed=True) def my_addons(self, n=8): """Returns n addons (anything not a webapp)""" qs = self.addons.exclude(type=amo.ADDON_WEBAPP) qs = order_by_translation(qs, 'name') return qs[:n] def my_apps(self, n=8): """Returns n apps""" qs = self.addons.filter(type=amo.ADDON_WEBAPP) qs = order_by_translation(qs, 'name') return qs[:n] @property def picture_dir(self): split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return os.path.join(settings.USERPICS_PATH, split_id.group(2) or '0', split_id.group(1) or '0') @property def picture_path(self): return os.path.join(self.picture_dir, str(self.id) + '.png') @property def picture_url(self): if not self.picture_type: return settings.MEDIA_URL + '/img/zamboni/anon_user.png' else: split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return settings.USERPICS_URL % ( split_id.group(2) or 0, split_id.group(1) or 0, self.id, int(time.mktime(self.modified.timetuple()))) @amo.cached_property def is_developer(self): return self.addonuser_set.exists() @amo.cached_property def is_app_developer(self): return self.addonuser_set.filter(addon__type=amo.ADDON_WEBAPP).exists() @amo.cached_property def is_artist(self): """Is this user a Personas Artist?""" return self.addonuser_set.filter( addon__type=amo.ADDON_PERSONA).exists() @amo.cached_property def needs_tougher_password(user): from access import acl return (acl.action_allowed_user(user, 'Admin', '%') or acl.action_allowed_user(user, 'Addons', 'Edit') or acl.action_allowed_user(user, 'Addons', 'Review') or acl.action_allowed_user(user, 'Apps', 'Review') or acl.action_allowed_user(user, 'Personas', 'Review') or acl.action_allowed_user(user, 'Users', 'Edit')) @property def name(self): return smart_unicode(self.display_name or self.username) welcome_name = name @property def last_login(self): """Make UserProfile look more like auth.User.""" # Django expects this to be non-null, so fake a login attempt. if not self.last_login_attempt: self.update(last_login_attempt=datetime.now()) return self.last_login_attempt @amo.cached_property def reviews(self): """All reviews that are not dev replies.""" return self._reviews_all.filter(reply_to=None) def anonymize(self): log.info(u"User (%s: <%s>) is being anonymized." % (self, self.email)) self.email = None self.password = "******" self.username = "******" % self.id # Can't be null self.display_name = None self.homepage = "" self.deleted = True self.picture_type = "" self.save() @transaction.commit_on_success def restrict(self): from amo.utils import send_mail log.info(u'User (%s: <%s>) is being restricted and ' 'its user-generated content removed.' % (self, self.email)) g = Group.objects.get(rules='Restricted:UGC') GroupUser.objects.create(user=self, group=g) self.reviews.all().delete() self.collections.all().delete() t = loader.get_template('users/email/restricted.ltxt') send_mail(_('Your account has been restricted'), t.render(Context({})), None, [self.email], use_blacklist=False) def unrestrict(self): log.info(u'User (%s: <%s>) is being unrestricted.' % (self, self.email)) GroupUser.objects.filter(user=self, group__rules='Restricted:UGC').delete() def generate_confirmationcode(self): if not self.confirmationcode: self.confirmationcode = ''.join(random.sample(string.letters + string.digits, 60)) return self.confirmationcode def save(self, force_insert=False, force_update=False, using=None): # we have to fix stupid things that we defined poorly in remora if not self.resetcode_expires: self.resetcode_expires = datetime.now() delete_user = None if self.deleted and self.user: delete_user = self.user self.user = None # Delete user after saving this profile. super(UserProfile, self).save(force_insert, force_update, using) if self.deleted and delete_user: delete_user.delete() def check_password(self, raw_password): if '$' not in self.password: valid = (get_hexdigest('md5', '', raw_password) == self.password) if valid: # Upgrade an old password. self.set_password(raw_password) self.save() return valid algo, salt, hsh = self.password.split('$') return hsh == get_hexdigest(algo, salt, raw_password) def set_password(self, raw_password, algorithm='sha512'): self.password = create_password(algorithm, raw_password) # Can't do CEF logging here because we don't have a request object. def email_confirmation_code(self): from amo.utils import send_mail log.debug("Sending account confirmation code for user (%s)", self) url = "%s%s" % (settings.SITE_URL, reverse('users.confirm', args=[self.id, self.confirmationcode])) domain = settings.DOMAIN t = loader.get_template('users/email/confirm.ltxt') c = {'domain': domain, 'url': url, } send_mail(_("Please confirm your email address"), t.render(Context(c)), None, [self.email], use_blacklist=False) def log_login_attempt(self, successful): """Log a user's login attempt""" self.last_login_attempt = datetime.now() self.last_login_attempt_ip = commonware.log.get_remote_addr() if successful: log.debug(u"User (%s) logged in successfully" % self) self.failed_login_attempts = 0 self.last_login_ip = commonware.log.get_remote_addr() else: log.debug(u"User (%s) failed to log in" % self) if self.failed_login_attempts < 16777216: self.failed_login_attempts += 1 self.save() def create_django_user(self): """Make a django.contrib.auth.User for this UserProfile.""" # Reusing the id will make our life easier, because we can use the # OneToOneField as pk for Profile linked back to the auth.user # in the future. self.user = DjangoUser(id=self.pk) self.user.first_name = '' self.user.last_name = '' self.user.username = self.username self.user.email = self.email self.user.password = self.password self.user.date_joined = self.created if self.groups.filter(rules='*:*').count(): self.user.is_superuser = self.user.is_staff = True self.user.save() self.save() return self.user def mobile_collection(self): return self.special_collection(amo.COLLECTION_MOBILE, defaults={'slug': 'mobile', 'listed': False, 'name': _('My Mobile Add-ons')}) def favorites_collection(self): return self.special_collection(amo.COLLECTION_FAVORITES, defaults={'slug': 'favorites', 'listed': False, 'name': _('My Favorite Add-ons')}) def special_collection(self, type_, defaults): from bandwagon.models import Collection c, new = Collection.objects.get_or_create( author=self, type=type_, defaults=defaults) if new: # Do an extra query to make sure this gets transformed. c = Collection.objects.using('default').get(id=c.id) return c def purchase_ids(self): """ I'm special casing this because we use purchase_ids a lot in the site and we are not caching empty querysets in cache-machine. That means that when the site is first launched we are having a lot of empty queries hit. We can probably do this in smarter fashion by making cache-machine cache empty queries on an as need basis. """ # Circular import from amo.utils import memoize from market.models import AddonPurchase @memoize(prefix='users:purchase-ids') def ids(pk): return (AddonPurchase.objects.filter(user=pk) .values_list('addon_id', flat=True) .filter(type=amo.CONTRIB_PURCHASE) .order_by('pk')) return ids(self.pk) def get_preapproval(self): """ Returns the pre approval object for this user, or None if it does not exist """ try: return self.preapprovaluser except ObjectDoesNotExist: pass def has_preapproval_key(self): """ Returns the pre approval paypal key for this user, or False if the pre_approval doesn't exist or the key is blank. """ return bool(getattr(self.get_preapproval(), 'paypal_key', '')) def can_view_consumer(self): # To view the consumer pages, the user must satisfy either criterion: # * Have submitted an app. # * Is a vouched Mozillian or whitelisted fella. return self.is_app_developer or AccessWhitelist.matches(self.email)
class Version(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon', related_name='versions') license = models.ForeignKey('License', null=True) releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') version_int = models.BigIntegerField(null=True, editable=False) nomination = models.DateTimeField(null=True) reviewed = models.DateTimeField(null=True) has_info_request = models.BooleanField(default=False) has_editor_comment = models.BooleanField(default=False) class Meta(amo.models.ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) self.__dict__.update(compare.version_dict(self.version or '')) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): if not self.version_int and self.version: version_int = compare.version_int(self.version) # Magic number warning, this is the maximum size # of a big int in MySQL to prevent version_int overflow, for # people who have rather crazy version numbers. # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html if version_int < 9223372036854775807: self.version_int = version_int else: log.error('No version_int written for version %s, %s' % (self.pk, self.version)) return super(Version, self).save(*args, **kw) @classmethod def from_upload(cls, upload, addon, platforms, send_signal=True): data = utils.parse_addon(upload, addon) try: license = addon.versions.latest().license_id except Version.DoesNotExist: license = None v = cls.objects.create(addon=addon, version=data['version'], license_id=license) log.info('New version: %r (%s) from %r' % (v, v.id, upload)) # appversions AV = ApplicationsVersions for app in data.get('apps', []): AV(version=v, min=app.min, max=app.max, application_id=app.id).save() if addon.type == amo.ADDON_SEARCH: # Search extensions are always for all platforms. platforms = [Platform.objects.get(id=amo.PLATFORM_ALL.id)] else: platforms = cls._make_safe_platform_files(platforms) for platform in platforms: File.from_upload(upload, v, platform, parse_data=data) v.disable_old_files() # After the upload has been copied to all # platforms, remove the upload. path.path(upload.path).unlink() if send_signal: version_uploaded.send(sender=v) return v @classmethod def _make_safe_platform_files(cls, platforms): """Make file platform translations until all download pages support desktop ALL + mobile ALL. See bug 646268. """ pl_set = set([p.id for p in platforms]) if pl_set == set([amo.PLATFORM_ALL_MOBILE.id, amo.PLATFORM_ALL.id]): # Make it really ALL: return [Platform.objects.get(id=amo.PLATFORM_ALL.id)] has_mobile = any(p in amo.MOBILE_PLATFORMS for p in pl_set) has_desktop = any(p in amo.DESKTOP_PLATFORMS for p in pl_set) has_all = any(p in (amo.PLATFORM_ALL_MOBILE.id, amo.PLATFORM_ALL.id) for p in pl_set) is_mixed = has_mobile and has_desktop if (is_mixed and has_all) or has_mobile: # Mixing desktop and mobile w/ ALL is not safe; # we have to split the files into exact platforms. # Additionally, it is not safe to use all-mobile. new_plats = [] for p in platforms: if p.id == amo.PLATFORM_ALL_MOBILE.id: new_plats.extend( list( Platform.objects.filter( id__in=amo.MOBILE_PLATFORMS).exclude( id=amo.PLATFORM_ALL_MOBILE.id))) elif p.id == amo.PLATFORM_ALL.id: new_plats.extend( list( Platform.objects.filter( id__in=amo.DESKTOP_PLATFORMS).exclude( id=amo.PLATFORM_ALL.id))) else: new_plats.append(p) return new_plats # Platforms are safe as is return platforms @property def path_prefix(self): return os.path.join(settings.ADDONS_PATH, str(self.addon_id)) @property def mirror_path_prefix(self): return os.path.join(settings.MIRROR_STAGE_PATH, str(self.addon_id)) def license_url(self, impala=False): return reverse('addons.license', args=[self.addon.slug, self.version]) def flush_urls(self): return self.addon.flush_urls() def get_url_path(self): return reverse('addons.versions', args=[self.addon.slug, self.version]) def delete(self): amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version)) super(Version, self).delete() @property def current_queue(self): """Return the current queue, or None if not in a queue.""" from editors.models import (ViewPendingQueue, ViewFullReviewQueue, ViewPreliminaryQueue) if self.addon.status in [ amo.STATUS_NOMINATED, amo.STATUS_LITE_AND_NOMINATED ]: return ViewFullReviewQueue elif self.addon.status == amo.STATUS_PUBLIC: return ViewPendingQueue elif self.addon.status in [amo.STATUS_LITE, amo.STATUS_UNREVIEWED]: return ViewPreliminaryQueue return None @amo.cached_property(writable=True) def all_activity(self): from devhub.models import VersionLog # yucky al = (VersionLog.objects.filter( version=self.id).order_by('created').select_related( depth=1).no_cache()) return al @amo.cached_property(writable=True) def compatible_apps(self): """Get a mapping of {APP: ApplicationVersion}.""" avs = self.apps.select_related(depth=1) return self._compat_map(avs) def compatible_platforms(self): """Returns a dict of compatible file platforms for this version. The result is based on which app(s) the version targets. """ apps = set([a.application.id for a in self.apps.all()]) targets_mobile = amo.MOBILE.id in apps targets_other = any((a != amo.MOBILE.id) for a in apps) all_plats = {} if targets_other: all_plats.update(amo.DESKTOP_PLATFORMS) if targets_mobile: all_plats.update(amo.MOBILE_PLATFORMS) return all_plats @amo.cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) @amo.cached_property def supported_platforms(self): """Get a list of supported platform names.""" return list(set(amo.PLATFORMS[f.platform_id] for f in self.all_files)) @property def status(self): status = dict([(f.status, amo.STATUS_CHOICES[f.status]) for f in self.all_files]) return status.values() @property def statuses(self): """Unadulterated statuses, good for an API.""" return [(f.id, f.status) for f in self.all_files] def is_allowed_upload(self): """Check that a file can be uploaded based on the files per platform for that type of addon.""" num_files = len(self.all_files) if self.addon.type == amo.ADDON_SEARCH: return num_files == 0 elif num_files == 0: return True elif amo.PLATFORM_ALL in self.supported_platforms: return False elif amo.PLATFORM_ALL_MOBILE in self.supported_platforms: return False else: compatible = (v for k, v in self.compatible_platforms().items() if k not in (amo.PLATFORM_ALL.id, amo.PLATFORM_ALL_MOBILE.id)) return bool(set(compatible) - set(self.supported_platforms)) @property def has_files(self): return bool(self.all_files) @property def is_unreviewed(self): return filter(lambda f: f.status in amo.UNREVIEWED_STATUSES, self.all_files) @property def is_all_unreviewed(self): return not bool([ f for f in self.all_files if f.status not in amo.UNREVIEWED_STATUSES ]) @property def is_beta(self): return filter(lambda f: f.status == amo.STATUS_BETA, self.all_files) @property def is_lite(self): return filter(lambda f: f.status in amo.LITE_STATUSES, self.all_files) @property def is_jetpack(self): return all(f.jetpack_version for f in self.all_files) @classmethod def _compat_map(cls, avs): apps = {} for av in avs: app_id = av.application_id if app_id in amo.APP_IDS: apps[amo.APP_IDS[app_id]] = av return apps @classmethod def transformer(cls, versions): """Attach all the compatible apps and files to the versions.""" ids = set(v.id for v in versions) if not versions: return avs = (ApplicationsVersions.objects.filter( version__in=ids).select_related(depth=1).no_cache()) files = (File.objects.filter( version__in=ids).select_related('version').no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) av_dict, file_dict = rollup(avs), rollup(files) for version in versions: v_id = version.id version.compatible_apps = cls._compat_map(av_dict.get(v_id, [])) version.all_files = file_dict.get(v_id, []) @classmethod def transformer_activity(cls, versions): """Attach all the activity to the versions.""" from devhub.models import VersionLog # yucky ids = set(v.id for v in versions) if not versions: return al = (VersionLog.objects.filter( version__in=ids).order_by('created').select_related( depth=1).no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) al_dict = rollup(al) for version in versions: v_id = version.id version.all_activity = al_dict.get(v_id, []) def disable_old_files(self): if not self.files.filter(status=amo.STATUS_BETA).exists(): qs = File.objects.filter(version__addon=self.addon_id, version__lt=self, status=amo.STATUS_UNREVIEWED) # Use File.update so signals are triggered. for f in qs: f.update(status=amo.STATUS_DISABLED)
class FancyModel(amo.models.ModelBase): """Mix it up with purified and linkified fields.""" purified = PurifiedField() linkified = LinkifiedField()
class UserProfile(amo.models.ModelBase): # nickname, firstname, & lastname are deprecated. nickname = models.CharField(max_length=255, default='', null=True, blank=True) firstname = models.CharField(max_length=255, default='', blank=True) lastname = models.CharField(max_length=255, default='', blank=True) username = models.CharField(max_length=255, default='', unique=True) display_name = models.CharField(max_length=255, default='', null=True, blank=True) password = models.CharField(max_length=255, default='') email = models.EmailField(unique=True, null=True) averagerating = models.CharField(max_length=255, blank=True, null=True) bio = PurifiedField(short=False) confirmationcode = models.CharField(max_length=255, default='', blank=True) deleted = models.BooleanField(default=False) display_collections = models.BooleanField(default=False) display_collections_fav = models.BooleanField(default=False) emailhidden = models.BooleanField(default=False) homepage = models.URLField(max_length=255, blank=True, default='', verify_exists=False) location = models.CharField(max_length=255, blank=True, default='') notes = models.TextField(blank=True, null=True) notifycompat = models.BooleanField(default=True) notifyevents = models.BooleanField(default=True) occupation = models.CharField(max_length=255, default='', blank=True) # This is essentially a "has_picture" flag right now picture_type = models.CharField(max_length=75, default='', blank=True) resetcode = models.CharField(max_length=255, default='', blank=True) resetcode_expires = models.DateTimeField(default=datetime.now, null=True, blank=True) sandboxshown = models.BooleanField(default=False) last_login_ip = models.CharField(default='', max_length=45, editable=False) last_login_attempt = models.DateTimeField(null=True, editable=False) last_login_attempt_ip = models.CharField(default='', max_length=45, editable=False) failed_login_attempts = models.PositiveIntegerField(default=0, editable=False) user = models.ForeignKey(DjangoUser, null=True, editable=False, blank=True) class Meta: db_table = 'users' def __unicode__(self): return '%s: %s' % (self.id, self.display_name or self.username) def get_url_path(self): return reverse('users.profile', args=[self.id]) def flush_urls(self): urls = [ '*/user/%d/' % self.id, self.picture_url, ] return urls @amo.cached_property def addons_listed(self): """Public add-ons this user is listed as author of.""" return self.addons.valid().filter(addonuser__listed=True).distinct() @property def picture_dir(self): split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return os.path.join(settings.USERPICS_PATH, split_id.group(2) or '0', split_id.group(1) or '0') @property def picture_path(self): return os.path.join(self.picture_dir, str(self.id) + '.png') @property def picture_url(self): if not self.picture_type: return settings.MEDIA_URL + '/img/zamboni/anon_user.png' else: split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return settings.USERPICS_URL % ( split_id.group(2) or 0, split_id.group(1) or 0, self.id, int(time.mktime(self.modified.timetuple()))) @amo.cached_property def is_developer(self): return self.addonuser_set.exists() @property def name(self): return self.display_name or self.username welcome_name = name @property def last_login(self): """Make UserProfile look more like auth.User.""" # Django expects this to be non-null, so fake a login attempt. if not self.last_login_attempt: self.update(last_login_attempt=datetime.now()) return self.last_login_attempt @amo.cached_property def reviews(self): """All reviews that are not dev replies.""" return self._reviews_all.filter(reply_to=None) def anonymize(self): log.info(u"User (%s: <%s>) is being anonymized." % (self, self.email)) self.email = None self.password = "******" self.firstname = "" self.lastname = "" self.nickname = None self.username = "******" % self.id # Can't be null self.display_name = None self.homepage = "" self.deleted = True self.picture_type = "" self.save() def generate_confirmationcode(self): if not self.confirmationcode: self.confirmationcode = ''.join( random.sample(string.letters + string.digits, 60)) return self.confirmationcode def save(self, force_insert=False, force_update=False, using=None): # we have to fix stupid things that we defined poorly in remora if not self.resetcode_expires: self.resetcode_expires = datetime.now() # TODO POSTREMORA (maintain remora's view of user names.) if not self.firstname or self.lastname or self.nickname: self.nickname = self.name delete_user = None if self.deleted and self.user: delete_user = self.user self.user = None # Delete user after saving this profile. super(UserProfile, self).save(force_insert, force_update, using) if self.deleted and delete_user: delete_user.delete() def check_password(self, raw_password): if '$' not in self.password: valid = (get_hexdigest('md5', '', raw_password) == self.password) if valid: # Upgrade an old password. self.set_password(raw_password) self.save() return valid algo, salt, hsh = self.password.split('$') return hsh == get_hexdigest(algo, salt, raw_password) def set_password(self, raw_password, algorithm='sha512'): self.password = create_password(algorithm, raw_password) def email_confirmation_code(self): from amo.utils import send_mail log.debug("Sending account confirmation code for user (%s)", self) url = "%s%s" % (settings.SITE_URL, reverse('users.confirm', args=[self.id, self.confirmationcode])) domain = settings.DOMAIN t = loader.get_template('users/email/confirm.ltxt') c = { 'domain': domain, 'url': url, } send_mail(_("Please confirm your email address"), t.render(Context(c)), None, [self.email], use_blacklist=False) def log_login_attempt(self, request, successful): """Log a user's login attempt""" self.last_login_attempt = datetime.now() self.last_login_attempt_ip = commonware.log.get_remote_addr() if successful: log.debug(u"User (%s) logged in successfully" % self) self.failed_login_attempts = 0 self.last_login_ip = commonware.log.get_remote_addr() else: log.debug(u"User (%s) failed to log in" % self) if self.failed_login_attempts < 16777216: self.failed_login_attempts += 1 self.save() def create_django_user(self): """Make a django.contrib.auth.User for this UserProfile.""" # Reusing the id will make our life easier, because we can use the # OneToOneField as pk for Profile linked back to the auth.user # in the future. self.user = DjangoUser(id=self.pk) self.user.first_name = '' self.user.last_name = '' self.user.username = self.email # f self.user.email = self.email self.user.password = self.password self.user.date_joined = self.created if self.groups.filter(rules='*:*').count(): self.user.is_superuser = self.user.is_staff = True self.user.save() self.save() return self.user def mobile_collection(self): return self.special_collection(amo.COLLECTION_MOBILE, defaults={ 'slug': 'mobile', 'listed': False, 'name': _('My Mobile Add-ons') }) def favorites_collection(self): return self.special_collection(amo.COLLECTION_FAVORITES, defaults={ 'slug': 'favorites', 'listed': False, 'name': _('My Favorite Add-ons') }) def special_collection(self, type_, defaults): from bandwagon.models import Collection c, new = Collection.objects.get_or_create(author=self, type=type_, defaults=defaults) if new: # Do an extra query to make sure this gets transformed. c = Collection.objects.using('default').get(id=c.id) return c
class Version(amo.models.OnChangeMixin, amo.models.ModelBase): addon = models.ForeignKey('addons.Addon', related_name='versions') license = models.ForeignKey('License', null=True) releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') version_int = models.BigIntegerField(null=True, editable=False) nomination = models.DateTimeField(null=True) reviewed = models.DateTimeField(null=True) has_info_request = models.BooleanField(default=False) has_editor_comment = models.BooleanField(default=False) deleted = models.BooleanField(default=False) supported_locales = models.CharField(max_length=255) _developer_name = models.CharField(max_length=255, default='', editable=False) source = models.FileField(upload_to=source_upload_path, null=True, blank=True) # The order of those managers is very important: please read the lengthy # comment above the Addon managers declaration/instanciation. unfiltered = VersionManager(include_deleted=True) objects = VersionManager() class Meta(amo.models.ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) self.__dict__.update(version_dict(self.version or '')) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): if not self.version_int and self.version: v_int = version_int(self.version) # Magic number warning, this is the maximum size # of a big int in MySQL to prevent version_int overflow, for # people who have rather crazy version numbers. # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html if v_int < 9223372036854775807: self.version_int = v_int else: log.error('No version_int written for version %s, %s' % (self.pk, self.version)) super(Version, self).save(*args, **kw) return self @classmethod def from_upload(cls, upload, addon, platforms, send_signal=True, source=None, is_beta=False): data = utils.parse_addon(upload, addon) try: license = addon.versions.latest().license_id except Version.DoesNotExist: license = None max_len = cls._meta.get_field_by_name('_developer_name')[0].max_length developer = data.get('developer_name', '')[:max_len] v = cls.objects.create(addon=addon, version=data['version'], license_id=license, _developer_name=developer, source=source) log.info('New version: %r (%s) from %r' % (v, v.id, upload)) AV = ApplicationsVersions for app in data.get('apps', []): AV(version=v, min=app.min, max=app.max, application=app.id).save() if addon.type == amo.ADDON_SEARCH: # Search extensions are always for all platforms. platforms = [amo.PLATFORM_ALL.id] else: platforms = cls._make_safe_platform_files(platforms) for platform in platforms: File.from_upload(upload, v, platform, parse_data=data, is_beta=is_beta) v.disable_old_files() # After the upload has been copied to all platforms, remove the upload. storage.delete(upload.path) if send_signal: version_uploaded.send(sender=v) # Track the time it took from first upload through validation # (and whatever else) until a version was created. upload_start = utc_millesecs_from_epoch(upload.created) now = datetime.datetime.now() now_ts = utc_millesecs_from_epoch(now) upload_time = now_ts - upload_start log.info('Time for version {version} creation from upload: {delta}; ' 'created={created}; now={now}'.format(delta=upload_time, version=v, created=upload.created, now=now)) statsd.timing('devhub.version_created_from_upload', upload_time) return v @classmethod def _make_safe_platform_files(cls, platforms): """Make file platform translations until all download pages support desktop ALL + mobile ALL. See bug 646268. Returns platforms ids. """ pl_set = set(platforms) if pl_set == set((amo.PLATFORM_ALL.id, )): # Make it really ALL: return [amo.PLATFORM_ALL.id] has_mobile = amo.PLATFORM_ANDROID in pl_set has_desktop = any(p in amo.DESKTOP_PLATFORMS for p in pl_set) has_all = amo.PLATFORM_ALL in pl_set is_mixed = has_mobile and has_desktop if (is_mixed and has_all) or has_mobile: # Mixing desktop and mobile w/ ALL is not safe; # we have to split the files into exact platforms. new_plats = [] for platform in platforms: if platform == amo.PLATFORM_ALL.id: plats = amo.DESKTOP_PLATFORMS.keys() plats.remove(amo.PLATFORM_ALL.id) new_plats.extend(plats) else: new_plats.append(platform) return new_plats # Platforms are safe as is return platforms @property def path_prefix(self): return os.path.join(user_media_path('addons'), str(self.addon_id)) @property def mirror_path_prefix(self): return os.path.join(user_media_path('addons'), str(self.addon_id)) def license_url(self, impala=False): return reverse('addons.license', args=[self.addon.slug, self.version]) def flush_urls(self): return self.addon.flush_urls() def get_url_path(self): if not self.addon.is_listed: # Not listed? Doesn't have a public page. return '' return reverse('addons.versions', args=[self.addon.slug, self.version]) def delete(self): log.info(u'Version deleted: %r (%s)' % (self, self.id)) amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version)) super(Version, self).delete() @property def current_queue(self): """Return the current queue, or None if not in a queue.""" from editors.models import (ViewFullReviewQueue, ViewPendingQueue, ViewPreliminaryQueue, ViewUnlistedFullReviewQueue, ViewUnlistedPendingQueue, ViewUnlistedPreliminaryQueue) if self.addon.status in [ amo.STATUS_NOMINATED, amo.STATUS_LITE_AND_NOMINATED ]: return (ViewFullReviewQueue if self.addon.is_listed else ViewUnlistedFullReviewQueue) elif self.addon.status == amo.STATUS_PUBLIC: return (ViewPendingQueue if self.addon.is_listed else ViewUnlistedPendingQueue) elif self.addon.status in [amo.STATUS_LITE, amo.STATUS_UNREVIEWED]: return (ViewPreliminaryQueue if self.addon.is_listed else ViewUnlistedPreliminaryQueue) return None @amo.cached_property(writable=True) def all_activity(self): from devhub.models import VersionLog # yucky al = (VersionLog.objects.filter( version=self.id).order_by('created').select_related( 'activity_log', 'version').no_cache()) return al @amo.cached_property(writable=True) def compatible_apps(self): """Get a mapping of {APP: ApplicationVersion}.""" avs = self.apps.select_related('versions', 'license') return self._compat_map(avs) @amo.cached_property def compatible_apps_ordered(self): apps = self.compatible_apps.items() return sorted(apps, key=lambda v: v[0].short) def compatible_platforms(self): """Returns a dict of compatible file platforms for this version. The result is based on which app(s) the version targets. """ app_ids = [a.application for a in self.apps.all()] targets_mobile = amo.ANDROID.id in app_ids targets_other = any((id_ != amo.ANDROID.id) for id_ in app_ids) all_plats = {} if targets_other: all_plats.update(amo.DESKTOP_PLATFORMS) if targets_mobile: all_plats.update(amo.MOBILE_PLATFORMS) return all_plats @amo.cached_property def is_compatible(self): """Returns tuple of compatibility and reasons why if not. Server side conditions for determining compatibility are: * The add-on is an extension (not a theme, app, etc.) * Has not opted in to strict compatibility. * Does not use binary_components in chrome.manifest. Note: The lowest maxVersion compat check needs to be checked separately. Note: This does not take into account the client conditions. """ compat = True reasons = [] if self.addon.type != amo.ADDON_EXTENSION: compat = False # TODO: We may want this. For now we think it may be confusing. # reasons.append(_('Add-on is not an extension.')) if self.files.filter(binary_components=True).exists(): compat = False reasons.append(_('Add-on uses binary components.')) if self.files.filter(strict_compatibility=True).exists(): compat = False reasons.append( _('Add-on has opted into strict compatibility ' 'checking.')) return (compat, reasons) def is_compatible_app(self, app): """Returns True if the provided app passes compatibility conditions.""" appversion = self.compatible_apps.get(app) if appversion and app.id in amo.D2C_MAX_VERSIONS: return (version_int(appversion.max.version) >= version_int( amo.D2C_MAX_VERSIONS.get(app.id, '*'))) return False def compat_override_app_versions(self): """Returns the incompatible app versions range(s). If not ranges, returns empty list. Otherwise, this will return all the app version ranges that this particular version is incompatible with. """ from addons.models import CompatOverride cos = CompatOverride.objects.filter(addon=self.addon) if not cos: return [] app_versions = [] for co in cos: for range in co.collapsed_ranges(): if (version_int(range.min) <= version_int(self.version) <= version_int(range.max)): app_versions.extend([(a.min, a.max) for a in range.apps]) return app_versions @amo.cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) @amo.cached_property def supported_platforms(self): """Get a list of supported platform names.""" return list(set(amo.PLATFORMS[f.platform] for f in self.all_files)) @property def status(self): return [f.STATUS_CHOICES[f.status] for f in self.all_files] @property def statuses(self): """Unadulterated statuses, good for an API.""" return [(f.id, f.status) for f in self.all_files] def is_allowed_upload(self): """ Check that a file can be uploaded based on the files per platform for that type of addon. """ num_files = len(self.all_files) if self.addon.type == amo.ADDON_SEARCH: return num_files == 0 elif num_files == 0: return True elif amo.PLATFORM_ALL in self.supported_platforms: return False else: compatible = (v for k, v in self.compatible_platforms().items() if k != amo.PLATFORM_ALL.id) return bool(set(compatible) - set(self.supported_platforms)) def is_public(self): # To be public, a version must not be deleted, must belong to a public # addon, and all its attached files must have public status. try: return (not self.deleted and self.addon.is_public() and all(f.status == amo.STATUS_PUBLIC for f in self.all_files)) except ObjectDoesNotExist: return False @property def has_files(self): return bool(self.all_files) @property def is_unreviewed(self): return filter(lambda f: f.status in amo.UNREVIEWED_STATUSES, self.all_files) @property def is_all_unreviewed(self): return not bool([ f for f in self.all_files if f.status not in amo.UNREVIEWED_STATUSES ]) @property def is_beta(self): return filter(lambda f: f.status == amo.STATUS_BETA, self.all_files) @property def is_lite(self): return filter(lambda f: f.status in amo.LITE_STATUSES, self.all_files) @property def is_jetpack(self): return all(f.jetpack_version for f in self.all_files) @classmethod def _compat_map(cls, avs): apps = {} for av in avs: app_id = av.application if app_id in amo.APP_IDS: apps[amo.APP_IDS[app_id]] = av return apps @classmethod def transformer(cls, versions): """Attach all the compatible apps and files to the versions.""" ids = set(v.id for v in versions) if not versions: return # FIXME: find out why we have no_cache() here and try to remove it. avs = (ApplicationsVersions.objects.filter( version__in=ids).select_related('application', 'apps', 'min_set', 'max_set').no_cache()) files = File.objects.filter(version__in=ids).no_cache() def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) av_dict, file_dict = rollup(avs), rollup(files) for version in versions: v_id = version.id version.compatible_apps = cls._compat_map(av_dict.get(v_id, [])) version.all_files = file_dict.get(v_id, []) for f in version.all_files: f.version = version @classmethod def transformer_activity(cls, versions): """Attach all the activity to the versions.""" from devhub.models import VersionLog # yucky ids = set(v.id for v in versions) if not versions: return al = (VersionLog.objects.filter( version__in=ids).order_by('created').select_related( 'activity_log', 'version').no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) al_dict = rollup(al) for version in versions: v_id = version.id version.all_activity = al_dict.get(v_id, []) def disable_old_files(self): if not self.files.filter(status=amo.STATUS_BETA).exists(): qs = File.objects.filter( version__addon=self.addon_id, version__lt=self.id, version__deleted=False, status__in=[amo.STATUS_UNREVIEWED, amo.STATUS_PENDING]) # Use File.update so signals are triggered. for f in qs: f.update(status=amo.STATUS_DISABLED) @property def developer_name(self): return self._developer_name def reset_nomination_time(self, nomination=None): if not self.nomination or nomination: nomination = nomination or datetime.datetime.now() # We need signal=False not to call update_status (which calls us). self.update(nomination=nomination, _signal=False) # But we need the cache to be flushed. Version.objects.invalidate(self) @property def is_listed(self): return self.addon.is_listed @property def unreviewed_files(self): """A File is unreviewed if: - its status is in amo.UNDER_REVIEW_STATUSES or - its addon status is in amo.UNDER_REVIEW_STATUSES and its status is either in amo.UNDER_REVIEW_STATUSES or amo.STATUS_LITE """ under_review_or_lite = amo.UNDER_REVIEW_STATUSES + (amo.STATUS_LITE, ) return self.files.filter( models.Q(status__in=amo.UNDER_REVIEW_STATUSES) | models.Q(version__addon__status__in=amo.UNDER_REVIEW_STATUSES, status__in=under_review_or_lite))
class Version(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon', related_name='versions') license = models.ForeignKey('License', null=True) releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') version_int = models.BigIntegerField(null=True, editable=False) nomination = models.DateTimeField(null=True) reviewed = models.DateTimeField(null=True) class Meta(amo.models.ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) self.__dict__.update(compare.version_dict(self.version or '')) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): if not self.version_int and self.version: version_int = compare.version_int(self.version) # Magic number warning, this is the maximum size # of a big int in MySQL to prevent version_int overflow, for # people who have rather crazy version numbers. # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html if version_int < 9223372036854775807: self.version_int = version_int else: log.error('No version_int written for version %s, %s' % (self.pk, self.version)) return super(Version, self).save(*args, **kw) @classmethod def from_upload(cls, upload, addon, platforms): data = utils.parse_addon(upload.path, addon) try: license = addon.versions.latest().license_id except Version.DoesNotExist: license = None v = cls.objects.create(addon=addon, version=data['version'], license_id=license) log.debug('New version: %r (%s) from %r' % (v, v.id, upload)) # appversions AV = ApplicationsVersions for app in data.get('apps', []): AV(version=v, min=app.min, max=app.max, application_id=app.id).save() if addon.type == amo.ADDON_SEARCH: # Search extensions are always for all platforms. platforms = [Platform.objects.get(id=amo.PLATFORM_ALL.id)] else: new_plats = [] # Transform PLATFORM_ALL_MOBILE into specific mobile platform # files (e.g. Android, Maemo). # TODO(Kumar) Stop doing this when allmobile is supported # for downloads. See bug 646268. for p in platforms: if p.id == amo.PLATFORM_ALL_MOBILE.id: for mobi_p in (set(amo.MOBILE_PLATFORMS.keys()) - set([amo.PLATFORM_ALL_MOBILE.id])): new_plats.append(Platform.objects.get(id=mobi_p)) else: new_plats.append(p) platforms = new_plats for platform in platforms: File.from_upload(upload, v, platform, parse_data=data) v.disable_old_files() # After the upload has been copied to all # platforms, remove the upload. upload.path.unlink() return v @property def path_prefix(self): return os.path.join(settings.ADDONS_PATH, str(self.addon_id)) @property def mirror_path_prefix(self): return os.path.join(settings.MIRROR_STAGE_PATH, str(self.addon_id)) def license_url(self): return reverse('addons.license', args=[self.addon.slug, self.version]) def flush_urls(self): return self.addon.flush_urls() def get_url_path(self): return reverse('addons.versions', args=[self.addon.slug, self.version]) def delete(self): amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version)) super(Version, self).delete() @amo.cached_property(writable=True) def compatible_apps(self): """Get a mapping of {APP: ApplicationVersion}.""" avs = self.apps.select_related(depth=1) return self._compat_map(avs) def compatible_platforms(self): """Returns a dict of compatible file platforms for this version. The result is based on which app(s) the version targets. """ apps = set([a.application.id for a in self.apps.all()]) targets_mobile = amo.MOBILE.id in apps targets_other = any((a != amo.MOBILE.id) for a in apps) all_plats = {} if targets_other: all_plats.update(amo.DESKTOP_PLATFORMS) if targets_mobile: all_plats.update(amo.MOBILE_PLATFORMS) return all_plats @amo.cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) # TODO(jbalogh): Do we want names or Platforms? @amo.cached_property def supported_platforms(self): """Get a list of supported platform names.""" return list(set(amo.PLATFORMS[f.platform_id] for f in self.all_files)) def is_allowed_upload(self): """Check that a file can be uploaded based on the files per platform for that type of addon.""" num_files = len(self.all_files) if self.addon.type == amo.ADDON_SEARCH: return num_files == 0 elif num_files == 0: return True elif amo.PLATFORM_ALL in self.supported_platforms: return False elif amo.PLATFORM_ALL_MOBILE in self.supported_platforms: return False else: compatible = (v for k, v in self.compatible_platforms().items() if k not in (amo.PLATFORM_ALL.id, amo.PLATFORM_ALL_MOBILE.id)) return bool(set(compatible) - set(self.supported_platforms)) @property def has_files(self): return bool(self.all_files) @property def is_unreviewed(self): return filter(lambda f: f.status in amo.UNREVIEWED_STATUSES, self.all_files) @property def is_beta(self): return filter(lambda f: f.status == amo.STATUS_BETA, self.all_files) @property def is_lite(self): return filter(lambda f: f.status in amo.LITE_STATUSES, self.all_files) @classmethod def _compat_map(cls, avs): apps = {} for av in avs: app_id = av.application_id if app_id in amo.APP_IDS: apps[amo.APP_IDS[app_id]] = av return apps @classmethod def transformer(cls, versions): """Attach all the compatible apps and files to the versions.""" ids = set(v.id for v in versions) if not versions: return avs = (ApplicationsVersions.objects.filter( version__in=ids).select_related(depth=1).no_cache()) files = (File.objects.filter( version__in=ids).select_related('version').no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) av_dict, file_dict = rollup(avs), rollup(files) for version in versions: v_id = version.id version.compatible_apps = cls._compat_map(av_dict.get(v_id, [])) version.all_files = file_dict.get(v_id, []) def disable_old_files(self): if not self.files.filter(status=amo.STATUS_BETA).exists(): qs = File.objects.filter(version__addon=self.addon_id, version__lt=self, status=amo.STATUS_UNREVIEWED) # Use File.update so signals are triggered. for f in qs: f.update(status=amo.STATUS_DISABLED)
class Collection(amo.models.ModelBase): collection_type = models.IntegerField(choices=COLLECTION_TYPES) description = PurifiedField() name = PurifiedField() is_public = models.BooleanField(default=False) # FIXME: add better / composite indexes that matches the query we are # going to make. category = models.ForeignKey(Category, null=True, blank=True) region = models.PositiveIntegerField( default=None, null=True, blank=True, choices=mkt.regions.REGIONS_CHOICES_ID, db_index=True) carrier = models.IntegerField(default=None, null=True, blank=True, choices=mkt.carriers.CARRIER_CHOICES, db_index=True) author = models.CharField(max_length=255, default='', blank=True) slug = models.SlugField(blank=True, max_length=30, help_text='Used in collection URLs.') default_language = models.CharField( max_length=10, choices=((to_language(lang), desc) for lang, desc in settings.LANGUAGES.items()), default=to_language(settings.LANGUAGE_CODE)) background_color = ColorField(null=True) text_color = ColorField(null=True) objects = amo.models.ManagerBase() public = PublicCollectionsManager() class Meta: db_table = 'app_collections' ordering = ('-id', ) # This will change soon since we'll need to be # able to order collections themselves, but this # helps tests for now. def __unicode__(self): return self.name.localized_string_clean def save(self, **kw): self.clean_slug() return super(Collection, self).save(**kw) @use_master def clean_slug(self): clean_slug(self, 'slug') @classmethod def get_fallback(cls): return cls._meta.get_field('default_language') def apps(self): """ Return a list containing all apps in this collection. """ return [a.app for a in self.collectionmembership_set.all()] def add_app(self, app, order=None): """ Add an app to this collection. If specified, the app will be created with the specified `order`. If not, it will be added to the end of the collection. """ if not order: qs = CollectionMembership.objects.filter(collection=self) aggregate = qs.aggregate(models.Max('order'))['order__max'] order = aggregate + 1 if aggregate is not None else 0 return CollectionMembership.objects.create(collection=self, app=app, order=order) def remove_app(self, app): """ Remove the passed app from this collection, returning a boolean indicating whether a successful deletion took place. """ try: membership = self.collectionmembership_set.get(app=app) except CollectionMembership.DoesNotExist: return False else: membership.delete() return True def reorder(self, new_order): """ Passed a list of app IDs, e.g. [18, 24, 9] will change the order of each item in the collection to match the passed order. A ValueError will be raised if each app in the collection is not included in the ditionary. """ if set(a.pk for a in self.apps()) != set(new_order): raise ValueError('Not all apps included') for order, pk in enumerate(new_order): CollectionMembership.objects.get(collection=self, app_id=pk).update(order=order)
class Version(amo.models.ModelBase): addon = models.ForeignKey('addons.Addon', related_name='versions') license = models.ForeignKey('License', null=True) releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') version_int = models.BigIntegerField(null=True, editable=False) nomination = models.DateTimeField(null=True) reviewed = models.DateTimeField(null=True) has_info_request = models.BooleanField(default=False) has_editor_comment = models.BooleanField(default=False) deleted = models.BooleanField(default=False) supported_locales = models.CharField(max_length=255) _developer_name = models.CharField(max_length=255, default='', editable=False) objects = VersionManager() with_deleted = VersionManager(include_deleted=True) class Meta(amo.models.ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) self.__dict__.update(version_dict(self.version or '')) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): if not self.version_int and self.version: v_int = version_int(self.version) # Magic number warning, this is the maximum size # of a big int in MySQL to prevent version_int overflow, for # people who have rather crazy version numbers. # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html if v_int < 9223372036854775807: self.version_int = v_int else: log.error('No version_int written for version %s, %s' % (self.pk, self.version)) creating = not self.id super(Version, self).save(*args, **kw) if creating: # To avoid circular import. from mkt.webapps.models import AppFeatures AppFeatures.objects.create(version=self) return self @classmethod def from_upload(cls, upload, addon, platforms, send_signal=True): data = utils.parse_addon(upload, addon) try: license = addon.versions.latest().license_id except Version.DoesNotExist: license = None max_len = cls._meta.get_field_by_name('_developer_name')[0].max_length developer = data.get('developer_name', '')[:max_len] v = cls.objects.create(addon=addon, version=data['version'], license_id=license, _developer_name=developer) log.info('New version: %r (%s) from %r' % (v, v.id, upload)) platforms = [Platform.objects.get(id=amo.PLATFORM_ALL.id)] # To avoid circular import. from mkt.webapps.models import AppManifest # Note: This must happen before we call `File.from_upload`. manifest = utils.WebAppParser().get_json_data(upload) AppManifest.objects.create( version=v, manifest=json.dumps(manifest)) for platform in platforms: File.from_upload(upload, v, platform, parse_data=data) # Update supported locales from manifest. # Note: This needs to happen after we call `File.from_upload`. update_supported_locales_single.apply_async( args=[addon.id], kwargs={'latest': True}, eta=datetime.datetime.now() + datetime.timedelta(seconds=settings.NFS_LAG_DELAY)) v.disable_old_files() # After the upload has been copied to all platforms, remove the upload. storage.delete(upload.path) if send_signal: version_uploaded.send(sender=v) # If packaged app and app is blocked, put in escalation queue. if addon.is_packaged and addon.status == amo.STATUS_BLOCKED: # To avoid circular import. from editors.models import EscalationQueue EscalationQueue.objects.create(addon=addon) return v @property def path_prefix(self): return os.path.join(settings.ADDONS_PATH, str(self.addon_id)) def delete(self): log.info(u'Version deleted: %r (%s)' % (self, self.id)) amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version)) self.update(deleted=True) # Set file status to disabled. f = self.all_files[0] f.update(status=amo.STATUS_DISABLED, _signal=False) f.hide_disabled_file() if self.addon.is_packaged: # Unlink signed packages if packaged app. storage.delete(f.signed_file_path) log.info(u'Unlinked file: %s' % f.signed_file_path) storage.delete(f.signed_reviewer_file_path) log.info(u'Unlinked file: %s' % f.signed_reviewer_file_path) @amo.cached_property(writable=True) def all_activity(self): from devhub.models import VersionLog # yucky al = (VersionLog.objects.filter(version=self.id).order_by('created') .select_related(depth=1).no_cache()) return al @amo.cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) @amo.cached_property def supported_platforms(self): """Get a list of supported platform names.""" return list(set(amo.PLATFORMS[f.platform_id] for f in self.all_files)) @property def status(self): status_choices = amo.MKT_STATUS_FILE_CHOICES if self.deleted: return [status_choices[amo.STATUS_DELETED]] else: return [status_choices[f.status] for f in self.all_files] @property def statuses(self): """Unadulterated statuses, good for an API.""" return [(f.id, f.status) for f in self.all_files] def is_public(self): # To be public, a version must not be deleted, must belong to a public # addon, and all its attached files must have public status. try: return (not self.deleted and self.addon.is_public() and all(f.status == amo.STATUS_PUBLIC for f in self.all_files)) except ObjectDoesNotExist: return False @property def has_files(self): return bool(self.all_files) @classmethod def transformer(cls, versions): """Attach all the files to the versions.""" ids = set(v.id for v in versions) if not versions: return # FIXME: find out why we have no_cache() here and try to remove it. files = File.objects.filter(version__in=ids).no_cache() def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) file_dict = rollup(files) for version in versions: v_id = version.id version.all_files = file_dict.get(v_id, []) for f in version.all_files: f.version = version @classmethod def transformer_activity(cls, versions): """Attach all the activity to the versions.""" from devhub.models import VersionLog # yucky ids = set(v.id for v in versions) if not versions: return al = (VersionLog.objects.filter(version__in=ids).order_by('created') .select_related(depth=1).no_cache()) def rollup(xs): groups = amo.utils.sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) al_dict = rollup(al) for version in versions: v_id = version.id version.all_activity = al_dict.get(v_id, []) def disable_old_files(self): if not self.files.filter(status=amo.STATUS_BETA).exists(): qs = File.objects.filter(version__addon=self.addon_id, version__lt=self.id, version__deleted=False, status__in=[amo.STATUS_UNREVIEWED, amo.STATUS_PENDING]) # Use File.update so signals are triggered. for f in qs: f.update(status=amo.STATUS_DISABLED) @property def developer_name(self): return self._developer_name @amo.cached_property(writable=True) def is_privileged(self): """ Return whether the corresponding addon is privileged by looking at the manifest file. This is a cached property, to avoid going in the manifest more than once for a given instance. It's also directly writable do allow you to bypass the manifest fetching if you *know* your app is privileged or not already and want to pass the instance to some code that will use that property. """ if not self.addon.is_packaged or not self.all_files: return False data = self.addon.get_manifest_json(file_obj=self.all_files[0]) return data.get('type') == 'privileged' @amo.cached_property def manifest(self): # To avoid circular import. from mkt.webapps.models import AppManifest try: manifest = self.manifest_json.manifest except AppManifest.DoesNotExist: manifest = None return json.loads(manifest) if manifest else {}