featured collections w/ admin tool (bug 635331)

This commit is contained in:
Chris Van 2011-06-13 11:55:36 -07:00
Родитель 8c2ea69c6a
Коммит 9ca694d42c
13 изменённых файлов: 552 добавлений и 5 удалений

Просмотреть файл

@ -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': []}

Просмотреть файл

@ -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)

Просмотреть файл

@ -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.

Просмотреть файл

@ -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)

Просмотреть файл

@ -0,0 +1,4 @@
<a href="{{ collection.get_url_path() }}" target="_blank"
class="collectionitem {% if collection.all_personas %}personas-collection{% endif %}">
{{ collection.name }}</a>
<a href="#" class="replace">Replace with another collection</a>

Просмотреть файл

@ -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 %}
<link rel="stylesheet" href="{{ media('css/zamboni/admin_features.css') }}">
{% endblock %}
{% block js %}
{{ super() }}
<script src="{{ media('js/zamboni/admin_features.js') }}"></script>
{% 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 %}
<tr data-app="{{ app.id }}">
<td class="app {{ app.short }}">
{{ form.id }}
{{ form.application }}
{{ form.application.errors }}
</td>
<td>
{{ form.locale }}
{{ form.locale.errors }}
</td>
<td class="collection{% if collection_id %} loading{% endif %}"
data-collection="{{ collection_id }}">
<div class="current-collection js-hidden"></div>
<input type="text" class="placeholder collection-ac{% if collection_id %} js-hidden{% endif %}"
{{ collection_disabled }} placeholder="Enter collection ID or name">
{{ form.collection }}
{{ form.collection.errors }}
</td>
<td>
<span class="js-hidden delete">{{ form.DELETE }}{{ form.DELETE.label_tag() }}</span>
<a href="#" class="remove">&times;</a>
</td>
</tr>
{% endmacro %}
{% block content %}
<h2>{{ title }}</h2>
<form action="" method="post">
{{ csrf() }}
{% include "messages.html" %}
{{ form.non_form_errors() }}
{{ form.management_form }}
<table>
<thead>
<th>Application</th>
<th>Locale</th>
<th>Collection</th>
<th class="js-hidden">Delete</th>
</thead>
<tbody id="features">
{% for form in form.forms %}
{{ fc(form) }}
{% endfor %}
</tbody>
<tfoot class="hidden">
{{ fc(form.empty_form) }}
</tfoot>
</table>
<p><a href="#" id="add">Add a Featured Collection</a></p>
<p>
<button type="submit">Save Changes</button> or <a href="">Cancel</a>
</p>
</form>
{% endblock %}

Просмотреть файл

@ -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'),

Просмотреть файл

@ -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)

Просмотреть файл

@ -32,6 +32,13 @@ urlpatterns = patterns('',
url(r'^email_preview/(?P<topic>.*)\.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'),

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@ -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(
'<a href="{url}" target="_blank" ' +
'class="collectionitem {is_personas}">{name}</a>' +
'<a href="#" class="replace">Replace with another collection</a>'
);
$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('<a>{0}<b>ID: {1}</b></a>', [item.name, item.id]);
return $('<li>').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');
}));
});

Просмотреть файл

@ -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`);