class FeedCollection(GroupedAppsMixin, BaseFeedCollection, BaseFeedImage): """ Model for "Collections", a type of curated collection that allows more complex grouping of apps than an Editorial Brand. """ _apps = models.ManyToManyField(Webapp, through=FeedCollectionMembership, related_name='app_feed_collections') background_color = models.CharField(max_length=7, null=True, blank=True) name = PurifiedField() description = PurifiedField(blank=True, null=True) type = models.CharField(choices=COLLECTION_TYPE_CHOICES, max_length=30, null=True) membership_class = FeedCollectionMembership membership_relation = 'feedcollectionmembership' class Meta(BaseFeedCollection.Meta): abstract = False db_table = 'mkt_feed_collection' @classmethod def get_indexer(self): return indexers.FeedCollectionIndexer def image_path(self, suffix=''): return os.path.join( settings.FEED_COLLECTION_BG_PATH, str(self.pk / 1000), 'feed_collection{suffix}_{pk}.png'.format(suffix=suffix, pk=self.pk))
class FeedApp(BaseFeedImage, 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) description = PurifiedField() slug = models.CharField(max_length=30, unique=True) color = models.CharField(max_length=20, null=True, blank=True) type = models.CharField(choices=FEEDAPP_TYPE_CHOICES, max_length=30) # 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) # Deprecated. background_color = ColorField(null=True) class Meta: db_table = 'mkt_feed_app' @classmethod def get_indexer(self): return indexers.FeedAppIndexer 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, suffix=''): return os.path.join(settings.FEATURED_APP_BG_PATH, str(self.pk / 1000), 'featured_app{suffix}_{pk}.png'.format( suffix=suffix, pk=self.pk))
class FeedCollectionMembership(BaseFeedCollectionMembership): """ An app's membership to a `FeedBrand` class, used as the through model for `FeedBrand._apps`. """ obj = models.ForeignKey('FeedCollection') group = PurifiedField(blank=True, null=True) class Meta(BaseFeedCollectionMembership.Meta): abstract = False db_table = 'mkt_feed_collection_membership'
class FeedShelf(BaseFeedCollection, BaseFeedImage): """ Model for "Operator Shelves", a special type of collection that gives operators a place to centralize content they wish to feature. """ _apps = models.ManyToManyField(Webapp, through=FeedShelfMembership, related_name='app_shelves') carrier = models.IntegerField(choices=mkt.carriers.CARRIER_CHOICES) description = PurifiedField(null=True) name = PurifiedField() region = models.PositiveIntegerField( choices=mkt.regions.REGIONS_CHOICES_ID) # Shelf landing image. image_landing_hash = models.CharField(default=None, max_length=8, null=True, blank=True) membership_class = FeedShelfMembership membership_relation = 'feedshelfmembership' class Meta(BaseFeedCollection.Meta): abstract = False db_table = 'mkt_feed_shelf' @classmethod def get_indexer(self): return indexers.FeedShelfIndexer def image_path(self, suffix=''): return os.path.join( settings.FEED_SHELF_BG_PATH, str(self.pk / 1000), 'feed_shelf{suffix}_{pk}.png'.format(suffix=suffix, pk=self.pk)) @property def is_published(self): return self.feeditem_set.exists()
class Version(ModelBase): addon = models.ForeignKey('webapps.Webapp', related_name='versions') releasenotes = PurifiedField() approvalnotes = models.TextField(default='', null=True) version = models.CharField(max_length=255, default='0.1') 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(ModelBase.Meta): db_table = 'versions' ordering = ['-created', '-modified'] def __init__(self, *args, **kwargs): super(Version, self).__init__(*args, **kwargs) def __unicode__(self): return jinja2.escape(self.version) def save(self, *args, **kw): 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, send_signal=True): data = utils.parse_addon(upload, addon) 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'], _developer_name=developer) log.info('New version: %r (%s) from %r' % (v, v.id, upload)) # 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)) File.from_upload(upload, v, 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, 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 == mkt.STATUS_BLOCKED: # To avoid circular import. from mkt.reviewers.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)) mkt.log(mkt.LOG.DELETE_VERSION, self.addon, str(self.version)) models.signals.pre_delete.send(sender=Version, instance=self) was_current = False if self == self.addon.current_version: was_current = True self.update(deleted=True) # Set file status to disabled. f = self.all_files[0] f.update(status=mkt.STATUS_DISABLED, _signal=False) f.hide_disabled_file() # If version deleted was the current version and there now exists # another current_version, we need to call some extra methods to update # various bits for packaged apps. if was_current and self.addon.current_version: self.addon.update_name_from_package_manifest() self.addon.update_supported_locales() 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) models.signals.post_delete.send(sender=Version, instance=self) @cached_property(writable=True) def all_activity(self): from mkt.developers.models import VersionLog al = (VersionLog.objects.filter( version=self.id).order_by('created').select_related( 'activity_log', 'version')) return al @cached_property(writable=True) def all_files(self): """Shortcut for list(self.files.all()). Heavily cached.""" return list(self.files.all()) @property def status(self): status_choices = mkt.MKT_STATUS_FILE_CHOICES if self.deleted: return [status_choices[mkt.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 == mkt.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 def rollup(xs): groups = sorted_groupby(xs, 'version_id') return dict((k, list(vs)) for k, vs in groups) file_dict = rollup(File.objects.filter(version__in=ids)) 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 mkt.developers.models import VersionLog 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')) def rollup(xs): groups = 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): qs = File.objects.filter(version__addon=self.addon_id, version__lt=self.id, version__deleted=False, status__in=[mkt.STATUS_PENDING]) # Use File.update so signals are triggered. for f in qs: f.update(status=mkt.STATUS_DISABLED) @property def developer_name(self): return self._developer_name @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' @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 {}
class FeedCollection(BaseFeedCollection, BaseFeedImage): """ Model for "Collections", a type of curated collection that allows more complex grouping of apps than an Editorial Brand. """ _apps = models.ManyToManyField(Webapp, through=FeedCollectionMembership, related_name='app_feed_collections') background_color = models.CharField(max_length=7, null=True, blank=True) name = PurifiedField() description = PurifiedField(blank=True, null=True) type = models.CharField(choices=COLLECTION_TYPE_CHOICES, max_length=30, null=True) membership_class = FeedCollectionMembership membership_relation = 'feedcollectionmembership' class Meta(BaseFeedCollection.Meta): abstract = False db_table = 'mkt_feed_collection' @classmethod def get_indexer(self): return indexers.FeedCollectionIndexer def image_path(self, suffix=''): return os.path.join( settings.FEED_COLLECTION_BG_PATH, str(self.pk / 1000), 'feed_collection{suffix}_{pk}.png'.format(suffix=suffix, pk=self.pk)) def add_app_grouped(self, app, group, order=None): """ Add an app to this collection, as a member of the passed `group`. If specified, the app will be created with the specified `order`. If not, it will be added to the end of the collection. """ qs = self.membership_class.objects.filter(obj=self) if order is None: aggregate = qs.aggregate(models.Max('order'))['order__max'] order = aggregate + 1 if aggregate is not None else 0 rval = self.membership_class.objects.create(obj_id=self.id, app_id=app, group=group, 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. self.membership_class.objects.invalidate(*qs) index_webapps.delay([app]) return rval def set_apps_grouped(self, new_apps): for app in self.apps().no_cache().values_list('pk', flat=True): self.remove_app(Webapp.objects.get(pk=app)) for group in new_apps: for app in group['apps']: self.add_app_grouped(app, group['name'])
class Collection(ModelBase): # `collection_type` for rocketfuel, not transonic. collection_type = models.IntegerField(choices=COLLECTION_TYPES) description = PurifiedField() name = PurifiedField() is_public = models.BooleanField(default=False) category = models.CharField(default=None, null=True, blank=True, max_length=30, choices=CATEGORY_CHOICES) 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.CharField(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) image_hash = models.CharField(default=None, max_length=8, null=True) 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 = ManagerBase() public = PublicCollectionsManager() class Meta: db_table = 'app_collections' # This ordering will change soon since we'll need to be able to order # collections themselves, but this helps tests for now. ordering = ('-id', ) 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, suffix=''): # The argument `suffix` isn't used here but is in the feed. 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 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 FancyModel(ModelBase): """Mix it up with purified and linkified fields.""" purified = PurifiedField() linkified = LinkifiedField() objects = ManagerWithTranslations()
class FancyModel(ModelBase): """Mix it up with purified and linkified fields.""" purified = PurifiedField() linkified = LinkifiedField()