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 }}

+
+ {{ csrf() }} + {% include "messages.html" %} + {{ form.non_form_errors() }} + {{ form.management_form }} + + + + + + + + + {% for form in form.forms %} + {{ fc(form) }} + {% endfor %} + + + {{ fc(form.empty_form) }} + +
ApplicationLocaleCollectionDelete
+

Add a Featured Collection

+

+ or Cancel +

+
+{% 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`);