diff --git a/apps/amo/search.py b/apps/amo/search.py
index aae4db9eae..3ea6a2bf39 100644
--- a/apps/amo/search.py
+++ b/apps/amo/search.py
@@ -16,6 +16,7 @@ class ES(object):
self.in_ = {}
self.or_ = {}
self.queries = {}
+ self.prefixes = {}
self.fields = ['id']
self.ordering = []
self.start = 0
@@ -28,6 +29,7 @@ class ES(object):
new.in_ = dict(self.in_)
new.or_ = list(self.or_)
new.queries = dict(self.queries)
+ new.prefixes = dict(self.prefixes)
new.fields = list(self.fields)
new.ordering = list(self.ordering)
new.start = self.start
@@ -50,7 +52,11 @@ class ES(object):
def query(self, **kw):
new = self._clone()
- new.queries.update(kw)
+ for key, value in kw.items():
+ if key.endswith('__startswith'):
+ new.prefixes[key.rstrip('__startswith')] = value
+ else:
+ new.queries[key] = value
return new
def filter(self, **kw):
@@ -89,6 +95,8 @@ class ES(object):
qs = {}
if self.queries:
qs['query'] = {'term': self.queries}
+ if self.prefixes:
+ qs.setdefault('query', {}).update({'prefix': self.prefixes})
if len(self.filters) + len(self.in_) + len(self.or_) > 1:
qs['filter'] = {'and': []}
diff --git a/apps/bandwagon/models.py b/apps/bandwagon/models.py
index de26dcaa3d..9bbb81cfb4 100644
--- a/apps/bandwagon/models.py
+++ b/apps/bandwagon/models.py
@@ -545,3 +545,16 @@ class RecommendedCollection(Collection):
d[addon] += score
addons = sorted(d.items(), key=lambda x: x[1], reverse=True)
return [addon for addon, score in addons if addon not in addon_ids]
+
+
+class FeaturedCollection(amo.models.ModelBase):
+ application = models.ForeignKey(Application)
+ collection = models.ForeignKey(Collection)
+ locale = models.CharField(max_length=10, default='', blank=True, null=True)
+
+ class Meta:
+ db_table = 'featured_collections'
+
+ def __unicode__(self):
+ return u'%s (%s: %s)' % (self.collection, self.application,
+ self.locale)
diff --git a/apps/bandwagon/search.py b/apps/bandwagon/search.py
index aaf6bc728e..b9e5066154 100644
--- a/apps/bandwagon/search.py
+++ b/apps/bandwagon/search.py
@@ -4,7 +4,7 @@ import pyes.exceptions as pyes
def extract(collection):
- attrs = ('id', 'name', 'slug', 'type', 'application_id')
+ attrs = ('id', 'name', 'slug', 'author_username', 'type', 'application_id')
d = dict(zip(attrs, attrgetter(*attrs)(collection)))
d['app'] = d.pop('application_id')
d['name'] = unicode(d['name']) # Coerce to unicode.
diff --git a/apps/zadmin/forms.py b/apps/zadmin/forms.py
index cbe65f2cb6..2e4f724357 100644
--- a/apps/zadmin/forms.py
+++ b/apps/zadmin/forms.py
@@ -3,14 +3,18 @@ import re
from django import forms
from django.conf import settings
+from django.forms.models import modelformset_factory
from django.template import Context, Template, TemplateSyntaxError
import happyforms
from tower import ugettext_lazy as _lazy
+from quieter_formset.formset import BaseModelFormSet
import amo
+import product_details
from amo.urlresolvers import reverse
from applications.models import Application, AppVersion
+from bandwagon.models import Collection, FeaturedCollection
from zadmin.models import ValidationJob
@@ -107,3 +111,50 @@ class NotifyForm(happyforms.Form):
def clean_subject(self):
return self.check_template(self.cleaned_data['subject'])
+
+
+class FeaturedCollectionForm(happyforms.ModelForm):
+ LOCALES = (('', u'(Default Locale)'),) + tuple(
+ (i, product_details.languages[i]['native'])
+ for i in settings.AMO_LANGUAGES)
+
+ application = forms.ModelChoiceField(Application.objects.all())
+ collection = forms.CharField(widget=forms.HiddenInput)
+ locale = forms.ChoiceField(choices=LOCALES, required=False)
+
+ class Meta:
+ model = FeaturedCollection
+ fields = ('application', 'locale')
+
+ def clean_collection(self):
+ application = self.cleaned_data.get('application', None)
+ collection = self.cleaned_data.get('collection', None)
+ if not Collection.objects.filter(id=collection,
+ application=application).exists():
+ raise forms.ValidationError(
+ u'Invalid collection for this application.')
+ return collection
+
+ def save(self, commit=False):
+ collection = self.cleaned_data['collection']
+ f = super(FeaturedCollectionForm, self).save(commit=commit)
+ f.collection = Collection.objects.get(id=collection)
+ f.save()
+ return f
+
+
+class BaseFeaturedCollectionFormSet(BaseModelFormSet):
+
+ def __init__(self, *args, **kw):
+ super(BaseFeaturedCollectionFormSet, self).__init__(*args, **kw)
+ for form in self.initial_forms:
+ try:
+ form.initial['collection'] = (FeaturedCollection.objects
+ .get(id=form.instance.id).collection.id)
+ except (FeaturedCollection.DoesNotExist, Collection.DoesNotExist):
+ form.initial['collection'] = None
+
+
+FeaturedCollectionFormSet = modelformset_factory(FeaturedCollection,
+ form=FeaturedCollectionForm, formset=BaseFeaturedCollectionFormSet,
+ can_delete=True, extra=0)
diff --git a/apps/zadmin/templates/zadmin/featured_collection.html b/apps/zadmin/templates/zadmin/featured_collection.html
new file mode 100644
index 0000000000..693b8b4cea
--- /dev/null
+++ b/apps/zadmin/templates/zadmin/featured_collection.html
@@ -0,0 +1,4 @@
+
+ {{ collection.name }}
+Replace with another collection
diff --git a/apps/zadmin/templates/zadmin/features.html b/apps/zadmin/templates/zadmin/features.html
new file mode 100644
index 0000000000..af12201721
--- /dev/null
+++ b/apps/zadmin/templates/zadmin/features.html
@@ -0,0 +1,79 @@
+{% extends "admin/base.html" %}
+
+{% set title = 'Feature Manager' %}
+{% block title %}{{ page_title(title) }}{% endblock %}
+
+{% block bodyattrs %}
+data-collections-url="{{ url('zadmin.collections_json') }}"
+data-featured-collection-url="{{ url('zadmin.featured_collection') }}"
+{% endblock %}
+
+{% block extrahead %}
+
+{% endblock %}
+
+{% block js %}
+{{ super() }}
+
+{% endblock %}
+
+{% macro fc(form) %}
+ {% set app_id = form.application.value()|int %}
+ {% set app = amo.APPS_ALL.get(app_id, '') %}
+ {% set collection_id = form.collection.value() %}
+ {% set collection_disabled = 'disabled' if not (app_id or collection_id) and
+ not form.collection.errors %}
+
+
+ {{ form.id }}
+ {{ form.application }}
+ {{ form.application.errors }}
+ |
+
+ {{ form.locale }}
+ {{ form.locale.errors }}
+ |
+
+
+
+ {{ form.collection }}
+ {{ form.collection.errors }}
+ |
+
+ {{ form.DELETE }}{{ form.DELETE.label_tag() }}
+ ×
+ |
+
+{% endmacro %}
+
+{% block content %}
+{{ title }}
+
+{% endblock %}
diff --git a/apps/zadmin/templates/zadmin/index.html b/apps/zadmin/templates/zadmin/index.html
index c2b7a1ce37..860870dd42 100644
--- a/apps/zadmin/templates/zadmin/index.html
+++ b/apps/zadmin/templates/zadmin/index.html
@@ -8,6 +8,7 @@
('Celery', url('zadmin.celery'), 'See celery stats'),
('Elasticseach', url('zadmin.jetpack'), 'Manage elasticsearch'),
('Env', url('amo.env'), 'See the request environment'),
+ ('Features', url('zadmin.features'), 'Manage featured add-ons'),
('Flagged', url('zadmin.flagged'), 'See flagged reviews'),
('Hera', url('zadmin.hera'), 'Purge pages from zeus'),
('Jetpack', url('zadmin.jetpack'), 'Upgrade jetpack add-ons'),
diff --git a/apps/zadmin/tests/test_views.py b/apps/zadmin/tests/test_views.py
index 0c3c9c863c..cfec67b490 100644
--- a/apps/zadmin/tests/test_views.py
+++ b/apps/zadmin/tests/test_views.py
@@ -15,16 +15,18 @@ from pyquery import PyQuery as pq
import test_utils
import amo
-from amo.tests import close_to_now, assert_no_validation_errors
+from amo.tests import (formset, initial, close_to_now,
+ assert_no_validation_errors)
from amo.urlresolvers import reverse
from addons.models import Addon
from applications.models import AppVersion
+from bandwagon.models import Collection, FeaturedCollection
from devhub.models import ActivityLog
from files.models import Approval, File
from users.models import UserProfile
from users.utils import get_task_user
from versions.models import ApplicationsVersions, Version
-from zadmin.forms import NotifyForm
+from zadmin.forms import NotifyForm, FeaturedCollectionForm
from zadmin.models import ValidationJob, ValidationResult, EmailPreviewTopic
from zadmin.views import completed_versions_dirty, find_files
from zadmin import tasks
@@ -923,3 +925,122 @@ class TestEmailPreview(test_utils.TestCase):
eq_(rdr.next(), ['from_email', 'recipient_list', 'subject', 'body'])
eq_(rdr.next(), ['admin@mozilla.org', 'funnyguy@mozilla.org',
'the subject', 'Hello Ivan Krsti\xc4\x87'])
+
+
+class TestFeatures(test_utils.TestCase):
+ fixtures = ['base/apps', 'base/users', 'base/collections']
+
+ def setUp(self):
+ assert self.client.login(username='admin@mozilla.com',
+ password='password')
+ self.url = reverse('zadmin.features')
+ FeaturedCollection.objects.create(application_id=amo.FIREFOX.id,
+ locale='zh-CN', collection_id=80)
+ self.f = self.client.get(self.url).context['form'].initial_forms[0]
+ self.initial = self.f.initial
+
+ def test_form_initial(self):
+ eq_(self.initial['application'], amo.FIREFOX.id)
+ eq_(self.initial['locale'], 'zh-CN')
+ eq_(self.initial['collection'], 80)
+
+ def test_form_attrs(self):
+ r = self.client.get(self.url)
+ eq_(r.status_code, 200)
+ doc = pq(r.content)
+ eq_(doc('#features tr').attr('data-app'), str(amo.FIREFOX.id))
+ assert doc('#features td.app').hasClass(amo.FIREFOX.short)
+ eq_(doc('#features td.collection.loading').attr('data-collection'),
+ '80')
+ assert doc('#features .collection-ac.js-hidden')
+ assert not doc('#features .collection-ac[disabled]')
+
+ def test_no_app_disabled_autocomplete(self):
+ """If no application, autocomplete field should be disabled."""
+ data = formset(self.initial, {}, initial_count=1)
+ r = self.client.post(self.url, data)
+ doc = pq(r.content)
+ assert doc('#features .collection-ac[disabled]')
+
+ def test_disabled_autocomplete_errors(self):
+ """If any collection errors, autocomplete field should be enabled."""
+ d = dict(application=amo.FIREFOX.id, collection=999)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ doc = pq(r.content)
+ assert not doc('#features .collection-ac[disabled]')
+
+ def test_required_app(self):
+ d = dict(locale='zh-CN', collection=80)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.status_code, 200)
+ eq_(r.context['form'].errors[0]['application'],
+ ['This field is required.'])
+ eq_(r.context['form'].errors[0]['collection'],
+ ['Invalid collection for this application.'])
+
+ def test_bad_app(self):
+ d = dict(application=999, collection=80)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors[0]['application'],
+ ['Select a valid choice. That choice is not one of the available '
+ 'choices.'])
+
+ def test_bad_collection_for_app(self):
+ d = dict(application=amo.THUNDERBIRD.id, collection=80)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors[0]['collection'],
+ ['Invalid collection for this application.'])
+
+ def test_optional_locale(self):
+ d = dict(application=amo.FIREFOX.id, collection=80)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors, [{}])
+
+ def test_bad_locale(self):
+ d = dict(application=amo.FIREFOX.id, locale='klingon', collection=80)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors[0]['locale'],
+ ['Select a valid choice. klingon is not one of the available '
+ 'choices.'])
+
+ def test_required_collection(self):
+ d = dict(application=amo.FIREFOX.id)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors[0]['collection'],
+ ['This field is required.'])
+
+ def test_bad_collection(self):
+ d = dict(application=amo.FIREFOX.id, collection=999)
+ data = formset(self.initial, d, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(r.context['form'].errors[0]['collection'],
+ ['Invalid collection for this application.'])
+
+ def test_success_insert(self):
+ dupe = initial(self.f)
+ del dupe['id']
+ dupe.update(locale='fr')
+ data = formset(initial(self.f), dupe, initial_count=1)
+ r = self.client.post(self.url, data)
+ eq_(FeaturedCollection.objects.count(), 2)
+ eq_(FeaturedCollection.objects.all()[1].locale, 'fr')
+
+ def test_success_update(self):
+ d = initial(self.f)
+ d.update(locale='fr')
+ r = self.client.post(self.url, formset(d, initial_count=1))
+ eq_(r.status_code, 302)
+ eq_(FeaturedCollection.objects.all()[0].locale, 'fr')
+
+ def test_success_delete(self):
+ d = initial(self.f)
+ d.update(DELETE=True)
+ r = self.client.post(self.url, formset(d, initial_count=1))
+ eq_(FeaturedCollection.objects.count(), 0)
diff --git a/apps/zadmin/urls.py b/apps/zadmin/urls.py
index f4bb761996..a93faf2dc3 100644
--- a/apps/zadmin/urls.py
+++ b/apps/zadmin/urls.py
@@ -32,6 +32,13 @@ urlpatterns = patterns('',
url(r'^email_preview/(?P.*)\.csv$',
views.email_preview_csv, name='zadmin.email_preview_csv'),
url(r'^jetpack$', views.jetpack, name='zadmin.jetpack'),
+
+ url('^features$', views.features, name='zadmin.features'),
+ url('^features/collections\.json$', views.es_collections_json,
+ name='zadmin.collections_json'),
+ url('^features/featured-collection$', views.featured_collection,
+ name='zadmin.featured_collection'),
+
url('^elastic$', views.elastic, name='zadmin.elastic'),
url('^mail$', views.mail, name='zadmin.mail'),
url('^celery$', views.celery, name='zadmin.celery'),
diff --git a/apps/zadmin/views.py b/apps/zadmin/views.py
index e4c3c06ff5..41cec6156c 100644
--- a/apps/zadmin/views.py
+++ b/apps/zadmin/views.py
@@ -33,11 +33,12 @@ from amo.urlresolvers import reverse
from amo.utils import chunked, sorted_groupby
from addons.models import Addon
from addons.utils import ReverseNameLookup
+from bandwagon.models import Collection
from files.models import Approval, File
from versions.models import Version
from . import tasks
-from .forms import BulkValidationForm, NotifyForm
+from .forms import BulkValidationForm, NotifyForm, FeaturedCollectionFormSet
from .models import ValidationJob, EmailPreviewTopic, Config
log = commonware.log.getLogger('z.zadmin')
@@ -315,6 +316,51 @@ def jetpack(request):
upgrader=upgrader, by_version=by_version))
+@login_required
+@json_view
+def es_collections_json(request):
+ app = request.GET.get('app', '')
+ q = request.GET.get('q', '')
+ qs = Collection.search()
+ try:
+ qs = qs.query(id__startswith=int(q))
+ except ValueError:
+ qs = qs.query(name__startswith=q)
+ try:
+ qs = qs.filter(app=int(app))
+ except ValueError:
+ pass
+ data = []
+ for c in qs[:7]:
+ data.append({'id': c.id,
+ 'name': unicode(c.name),
+ 'all_personas': c.all_personas,
+ 'url': c.get_url_path()})
+ return data
+
+
+@post_required
+@admin.site.admin_view
+def featured_collection(request):
+ try:
+ pk = int(request.POST.get('collection', 0))
+ except ValueError:
+ pk = 0
+ c = get_object_or_404(Collection, pk=pk)
+ return jingo.render(request, 'zadmin/featured_collection.html',
+ dict(collection=c))
+
+
+@admin.site.admin_view
+def features(request):
+ form = FeaturedCollectionFormSet(request.POST or None)
+ if request.method == 'POST' and form.is_valid():
+ form.save(commit=False)
+ messages.success(request, 'Changes successfully saved.')
+ return redirect('zadmin.features')
+ return jingo.render(request, 'zadmin/features.html', dict(form=form))
+
+
@admin.site.admin_view
def elastic(request):
INDEX = site_settings.ES_INDEX
diff --git a/media/css/zamboni/admin_features.css b/media/css/zamboni/admin_features.css
new file mode 100644
index 0000000000..3e8ffc0e61
--- /dev/null
+++ b/media/css/zamboni/admin_features.css
@@ -0,0 +1,97 @@
+#add {
+ font-weight: bold;
+}
+
+table {
+ width: 100%;
+}
+
+th {
+ color: #888;
+}
+
+#features {
+ border: 1px solid #666;
+ border-width: 1px 0;
+}
+
+#features tr:not(:last-child) {
+ border-bottom: 1px dotted #aaa;
+}
+
+#features td:last-child {
+ vertical-align: middle;
+}
+
+#features td.app {
+ background-position: 0 5px;
+ background-size: auto 24px;
+ font-weight: bold;
+ line-height: 24px;
+ min-height: 24px;
+ padding-left: 35px;
+ vertical-align: top;
+}
+
+#features td.collection {
+ width: 40%;
+}
+
+#features .replace {
+ display: block;
+ font-size: 0.8em;
+ padding-left: 22px;
+}
+
+#features .replace:link,
+#features .replace:visited {
+ color: #666;
+}
+
+#features input.collection-ac {
+ width: 16em;
+}
+
+#features .loading {
+ background: url(../../img/zamboni/loading-white.gif) 50% 50% no-repeat;
+}
+
+#features .collectionitem {
+ background: url(../../img/icons/icons.png) 0 -300px no-repeat;
+ display: inline-block;
+ padding-left: 22px;
+}
+
+#features .collectionitem.personas-collection {
+ background: url(../../img/illustrations/themes.gif) no-repeat;
+ background-size: 18px;
+}
+
+.ui-autocomplete a b {
+ color: #999;
+ display: block;
+ font-size: 10px;
+}
+
+#features a.remove {
+ border-radius: 20px;
+ background-color: #ddd;
+ color: #fff;
+ display: inline-block;
+ float: right;
+ font-size: 14px;
+ font-weight: bold;
+ height: 18px;
+ line-height: 16px;
+ text-align: center;
+ text-decoration: none;
+ width: 18px;
+}
+
+#features tr:hover a.remove {
+ background-color: #ccc;
+}
+
+#features tr:hover a.remove:hover {
+ background-color: #2a4364;
+}
diff --git a/media/js/zamboni/admin_features.js b/media/js/zamboni/admin_features.js
new file mode 100644
index 0000000000..56c668a59f
--- /dev/null
+++ b/media/js/zamboni/admin_features.js
@@ -0,0 +1,101 @@
+$(document).ready(function(){
+ function incTotalForms() {
+ var $totalForms = $('#id_form-TOTAL_FORMS'),
+ num = parseInt($totalForms.val()) + 1;
+ $totalForms.val(num);
+ return num;
+ }
+
+ // Populate cells with current collections.
+ $('#features td.collection').each(function() {
+ var $td = $(this),
+ cid = $td.attr('data-collection'),
+ $input = $td.find('.collection-ac');
+ if (!cid) {
+ $td.removeClass('loading');
+ $input.show();
+ return;
+ }
+ $.post(document.body.getAttribute('data-featured-collection-url'),
+ {'collection': cid}, function(data) {
+ $td.removeClass('loading');
+ $input.hide();
+ $td.find('.current-collection').html(data).show();
+ });
+ });
+
+ $('#features').delegate('.app select', 'change', function() {
+ // Update application id and toggle disabled attr on autocomplete field.
+ var $this = $(this),
+ $tr = $this.closest('tr'),
+ val = $this.val();
+ $tr.attr('data-app', val);
+ $tr.find('.collection-ac').attr('disabled', !val);
+ });
+ $('#features').delegate('.remove', 'click', _pd(function() {
+ $(this).closest('tr').hide();
+ $(this).closest('td').find('input').attr('checked', true);
+ }));
+ $('#features').delegate('.replace', 'click', _pd(function() {
+ var $td = $(this).closest('td');
+ $td.find('.collection-ac').show();
+ $td.find('input[type=hidden]').val('');
+ $(this).parent().html('');
+ })).delegate('.collection-ac', 'collectionAdd', function() {
+ // Autocomplete for collection add form.
+ var $input = $(this),
+ $tr = $input.closest('tr'),
+ $td = $input.closest('td'),
+ $select = $tr.find('.collection-select');
+ function selectCollection() {
+ var item = JSON.parse($input.attr('data-item'));
+ if (item) {
+ $td.find('.errorlist').remove();
+ var current = template(
+ '{name}' +
+ 'Replace with another collection'
+ );
+ $td.find('.current-collection').show().html(current({
+ url: item.url,
+ is_personas: item.all_personas ? 'personas-collection' : '',
+ name: item.name
+ }));
+ $td.find('input[type=hidden]').val(item.id);
+ $td.attr('data-collection', item.id);
+ }
+ $input.val('');
+ $input.hide();
+ }
+ $input.autocomplete({
+ minLength: 3,
+ width: 300,
+ source: function(request, response) {
+ $.getJSON(document.body.getAttribute('data-collections-url'),
+ {'app': $input.closest('tr').attr('data-app'),
+ 'q': request.term}, response);
+ },
+ focus: function(event, ui) {
+ $input.val(ui.item.name);
+ return false;
+ },
+ select: function(event, ui) {
+ $input.val(ui.item.name).attr('data-item', JSON.stringify(ui.item));
+ selectCollection();
+ return false;
+ }
+ }).data('autocomplete')._renderItem = function(ul, item) {
+ var html = format('{0}ID: {1}', [item.name, item.id]);
+ return $('').data('item.autocomplete', item).append(html).appendTo(ul);
+ };
+ });
+
+ $('#features .collection-ac').trigger('collectionAdd');
+
+ $('#add').click(_pd(function() {
+ var formId = incTotalForms() - 1,
+ emptyForm = $('tfoot').html().replace(/__prefix__/g, formId);
+ $('tbody').append(emptyForm);
+ $('tbody tr:last-child .collection-ac').trigger('collectionAdd');
+ }));
+});
diff --git a/migrations/202-featured-collections.sql b/migrations/202-featured-collections.sql
new file mode 100644
index 0000000000..c8070de003
--- /dev/null
+++ b/migrations/202-featured-collections.sql
@@ -0,0 +1,19 @@
+DROP TABLE IF EXISTS `featured_collections`;
+CREATE TABLE `featured_collections` (
+ `id` int(11) unsigned AUTO_INCREMENT NOT NULL PRIMARY KEY,
+ `created` datetime NOT NULL,
+ `modified` datetime NOT NULL,
+ `application_id` int(11) unsigned NOT NULL,
+ `collection_id` int(11) unsigned NOT NULL,
+ `locale` varchar(10)
+);
+
+ALTER TABLE `featured_collections`
+ ADD CONSTRAINT FOREIGN KEY (`application_id`)
+ REFERENCES `applications` (`id`);
+ALTER TABLE `featured_collections`
+ ADD CONSTRAINT FOREIGN KEY (`collection_id`)
+ REFERENCES `collections` (`id`);
+
+CREATE INDEX `application_id_idx` ON `featured_collections` (`application_id`);
+CREATE INDEX `collection_id_idx` ON `featured_collections` (`collection_id`);