add in api for searching (bug 755801)
This commit is contained in:
Родитель
7c3d9685ac
Коммит
cf72feb0a8
|
@ -137,7 +137,7 @@ Create
|
||||||
This API requires authentication and a successfully validated manifest. To
|
This API requires authentication and a successfully validated manifest. To
|
||||||
create an app with your validated manifest::
|
create an app with your validated manifest::
|
||||||
|
|
||||||
POST /en-US/api/apps/
|
POST /en-US/api/apps/app/
|
||||||
|
|
||||||
Body data should contain the manifest id from the validate call and other data
|
Body data should contain the manifest id from the validate call and other data
|
||||||
in JSON::
|
in JSON::
|
||||||
|
@ -174,7 +174,7 @@ Update
|
||||||
|
|
||||||
This API requires authentication and a successfully created app::
|
This API requires authentication and a successfully created app::
|
||||||
|
|
||||||
PUT /en-US/apps/app/<app id>/
|
PUT /en-US/api/apps/app/<app id>/
|
||||||
|
|
||||||
The body contains JSON for the data to be posted.
|
The body contains JSON for the data to be posted.
|
||||||
|
|
||||||
|
@ -216,7 +216,7 @@ This API requires authentication and a successfully created app.
|
||||||
|
|
||||||
To view details of an app, including its review status::
|
To view details of an app, including its review status::
|
||||||
|
|
||||||
GET /api/apps/<slug>
|
GET /en-US/api/apps/app/<app id>/
|
||||||
|
|
||||||
Returns the status of the app::
|
Returns the status of the app::
|
||||||
|
|
||||||
|
@ -231,7 +231,7 @@ This API requires authentication and a successfully created app.
|
||||||
|
|
||||||
Deletes an app::
|
Deletes an app::
|
||||||
|
|
||||||
DELETE /api/apps/<slug>
|
DELETE /en-US/api/apps/app/<app id>/
|
||||||
|
|
||||||
The app will only be hard deleted if it is incomplete. Otherwise it will be
|
The app will only be hard deleted if it is incomplete. Otherwise it will be
|
||||||
soft deleted. A soft deleted app will not appear publicly in any listings
|
soft deleted. A soft deleted app will not appear publicly in any listings
|
||||||
|
@ -296,7 +296,7 @@ No authentication required.
|
||||||
|
|
||||||
To find a list of categories available on the marketplace::
|
To find a list of categories available on the marketplace::
|
||||||
|
|
||||||
GET /en-US/api/categories/
|
GET /en-US/api/apps/categories/
|
||||||
|
|
||||||
Returns the list of categories::
|
Returns the list of categories::
|
||||||
|
|
||||||
|
@ -310,6 +310,37 @@ Returns the list of categories::
|
||||||
|
|
||||||
Use the `id` of the category in your app updating.
|
Use the `id` of the category in your app updating.
|
||||||
|
|
||||||
|
Search
|
||||||
|
======
|
||||||
|
|
||||||
|
No authentication required.
|
||||||
|
|
||||||
|
To find a list of apps in a category on the marketplace::
|
||||||
|
|
||||||
|
GET /en-US/api/apps/search/
|
||||||
|
|
||||||
|
Returns a list of the apps sorted by relevance::
|
||||||
|
|
||||||
|
{"meta": {},
|
||||||
|
"objects":
|
||||||
|
[{"absolute_url": "http://../en-US/app/marble-run-1/",
|
||||||
|
"premium_type": 3, "slug": "marble-run-1", id="26",
|
||||||
|
"icon_url": "http://../addon_icons/0/26-32.png",
|
||||||
|
"resource_uri": null
|
||||||
|
}
|
||||||
|
...
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
* `cat` (optional): use the category API to find the ids of the categories
|
||||||
|
* `sort` (optional): one of 'downloads', 'rating', 'price', 'created'
|
||||||
|
|
||||||
|
Example, to specify a category sorted by rating::
|
||||||
|
|
||||||
|
GET /en-US/api/apps/search/?cat=1&sort=rating
|
||||||
|
|
||||||
|
Sorting options:
|
||||||
|
|
||||||
.. _`MDN`: https://developer.mozilla.org
|
.. _`MDN`: https://developer.mozilla.org
|
||||||
.. _`marketplace team`: marketplace-team@mozilla.org
|
.. _`marketplace team`: marketplace-team@mozilla.org
|
||||||
.. _`django-tastypie`: https://github.com/toastdriven/django-tastypie
|
.. _`django-tastypie`: https://github.com/toastdriven/django-tastypie
|
||||||
|
|
|
@ -33,6 +33,7 @@ from nose.tools import eq_
|
||||||
from piston.models import Consumer
|
from piston.models import Consumer
|
||||||
|
|
||||||
from amo.tests import TestCase
|
from amo.tests import TestCase
|
||||||
|
from amo.helpers import urlparams
|
||||||
from amo.urlresolvers import reverse
|
from amo.urlresolvers import reverse
|
||||||
from files.models import FileUpload
|
from files.models import FileUpload
|
||||||
|
|
||||||
|
@ -51,7 +52,10 @@ def get_absolute_url(url):
|
||||||
# TODO (andym): make this more standard.
|
# TODO (andym): make this more standard.
|
||||||
url[1]['api_name'] = 'apps'
|
url[1]['api_name'] = 'apps'
|
||||||
rev = reverse(url[0], kwargs=url[1])
|
rev = reverse(url[0], kwargs=url[1])
|
||||||
return 'http://%s%s' % ('api', rev)
|
res = 'http://%s%s' % ('api', rev)
|
||||||
|
if len(url) > 2:
|
||||||
|
res = urlparams(res, **url[2])
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def data_keys(d):
|
def data_keys(d):
|
||||||
|
@ -148,7 +152,8 @@ class BaseOAuth(TestCase):
|
||||||
if verb in allowed:
|
if verb in allowed:
|
||||||
continue
|
continue
|
||||||
res = getattr(self.client, verb)(url)
|
res = getattr(self.client, verb)(url)
|
||||||
eq_(res.status_code, 405)
|
assert (res.status_code in (401, 405),
|
||||||
|
'%s: %s not 405' % (verb.upper(), res.status_code))
|
||||||
|
|
||||||
|
|
||||||
@patch.object(settings, 'SITE_URL', 'http://api/')
|
@patch.object(settings, 'SITE_URL', 'http://api/')
|
||||||
|
|
|
@ -2,12 +2,13 @@ from django.conf.urls.defaults import include, patterns, url
|
||||||
|
|
||||||
from tastypie.api import Api
|
from tastypie.api import Api
|
||||||
from mkt.api.resources import AppResource, CategoryResource, ValidationResource
|
from mkt.api.resources import AppResource, CategoryResource, ValidationResource
|
||||||
|
from mkt.search.api import SearchResource
|
||||||
|
|
||||||
api = Api(api_name='apps')
|
api = Api(api_name='apps')
|
||||||
api.register(ValidationResource())
|
api.register(ValidationResource())
|
||||||
api.register(AppResource())
|
api.register(AppResource())
|
||||||
api.register(CategoryResource())
|
api.register(CategoryResource())
|
||||||
|
api.register(SearchResource())
|
||||||
|
|
||||||
urlpatterns = patterns('',
|
urlpatterns = patterns('',
|
||||||
url(r'^', include(api.urls)),
|
url(r'^', include(api.urls)),
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
from tastypie.serializers import Serializer
|
||||||
|
|
||||||
|
from django.forms import ValidationError
|
||||||
|
|
||||||
|
import amo
|
||||||
|
from amo.helpers import absolutify
|
||||||
|
from mkt.api.base import MarketplaceResource
|
||||||
|
from mkt.search.views import _get_query, _filter_search
|
||||||
|
from mkt.search.forms import ApiSearchForm
|
||||||
|
from mkt.webapps.models import Webapp
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResource(MarketplaceResource):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
available_methods = []
|
||||||
|
list_available_methods = ['get', 'post']
|
||||||
|
fields = ['id', 'name', 'description', 'premium_type', 'slug',
|
||||||
|
'summary']
|
||||||
|
object_class = Webapp
|
||||||
|
resource_name = 'search'
|
||||||
|
serializer = Serializer(formats=['json'])
|
||||||
|
|
||||||
|
def get_resource_uri(self, bundle):
|
||||||
|
# At this time we don't have an API to the Webapp details.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_list(self, request=None, **kwargs):
|
||||||
|
form = ApiSearchForm(request.GET)
|
||||||
|
if not form.is_valid():
|
||||||
|
raise ValidationError(self.form_errors(form))
|
||||||
|
|
||||||
|
# Search specific processing of the results.
|
||||||
|
qs = _get_query(request, form, form.cleaned_data)
|
||||||
|
qs = _filter_search(qs, form.cleaned_data)
|
||||||
|
res = amo.utils.paginate(request, qs)
|
||||||
|
|
||||||
|
# Rehydrate the results as per tastypie.
|
||||||
|
bundles = [self.build_bundle(obj=obj, request=request)
|
||||||
|
for obj in res.object_list]
|
||||||
|
objs = [self.full_dehydrate(bundle) for bundle in bundles]
|
||||||
|
# This isn't as quite a full as a full TastyPie meta object,
|
||||||
|
# but at least it's namespaced that way and ready to expand.
|
||||||
|
return self.create_response(request, {'objects': objs, 'meta': {}})
|
||||||
|
|
||||||
|
def dehydrate_slug(self, bundle):
|
||||||
|
return bundle.obj.app_slug
|
||||||
|
|
||||||
|
def dehydrate(self, bundle):
|
||||||
|
bundle.data['icon_url'] = bundle.obj.get_icon_url(32)
|
||||||
|
bundle.data['absolute_url'] = absolutify(bundle.obj.get_detail_url())
|
||||||
|
return bundle
|
|
@ -3,6 +3,9 @@ from django.forms.util import ErrorDict
|
||||||
|
|
||||||
from tower import ugettext_lazy as _lazy
|
from tower import ugettext_lazy as _lazy
|
||||||
|
|
||||||
|
from addons.models import Category
|
||||||
|
import amo
|
||||||
|
|
||||||
|
|
||||||
SORT_CHOICES = [
|
SORT_CHOICES = [
|
||||||
(None, _lazy(u'Relevance')),
|
(None, _lazy(u'Relevance')),
|
||||||
|
@ -89,3 +92,17 @@ class AppSearchForm(forms.Form):
|
||||||
|
|
||||||
class AppListForm(AppSearchForm):
|
class AppListForm(AppSearchForm):
|
||||||
sort = forms.ChoiceField(required=False, choices=LISTING_SORT_CHOICES)
|
sort = forms.ChoiceField(required=False, choices=LISTING_SORT_CHOICES)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiSearchForm(forms.Form):
|
||||||
|
# Like App search form, but just filtering on categories for now
|
||||||
|
# and bit more strict about the filtering.
|
||||||
|
sort = forms.ChoiceField(required=False, choices=LISTING_SORT_CHOICES)
|
||||||
|
cat = forms.TypedChoiceField(required=False, coerce=int, empty_value=None,
|
||||||
|
choices=[])
|
||||||
|
|
||||||
|
def __init__(self, *args, **kw):
|
||||||
|
super(ApiSearchForm, self).__init__(*args, **kw)
|
||||||
|
CATS = (Category.objects.filter(type=amo.ADDON_WEBAPP)
|
||||||
|
.values_list('id', flat=True))
|
||||||
|
self.fields['cat'].choices = [(pk, pk) for pk in CATS]
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import mock
|
||||||
|
from nose.tools import eq_
|
||||||
|
|
||||||
|
from addons.models import AddonCategory, Category
|
||||||
|
import amo
|
||||||
|
from amo.tests import ESTestCase
|
||||||
|
from mkt.api.tests.test_oauth import BaseOAuth, OAuthClient
|
||||||
|
from mkt.webapps.models import Webapp
|
||||||
|
|
||||||
|
|
||||||
|
class TestApi(BaseOAuth, ESTestCase):
|
||||||
|
fixtures = fixtures = ['webapps/337141-steamcube']
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = OAuthClient(None)
|
||||||
|
self.list_url = ('api_dispatch_list', {'resource_name': 'search'})
|
||||||
|
self.webapp = Webapp.objects.get(pk=337141)
|
||||||
|
self.webapp.update(status=amo.STATUS_PUBLIC)
|
||||||
|
self.webapp.save()
|
||||||
|
self.category = Category.objects.create(name='test',
|
||||||
|
type=amo.ADDON_WEBAPP)
|
||||||
|
self.webapp.save()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def test_verbs(self):
|
||||||
|
self._allowed_verbs(self.list_url, ['get'])
|
||||||
|
|
||||||
|
def test_meta(self):
|
||||||
|
res = self.client.get(self.list_url)
|
||||||
|
eq_(res.status_code, 200)
|
||||||
|
eq_(set(json.loads(res.content).keys()), set(['objects', 'meta']))
|
||||||
|
|
||||||
|
def test_wrong_category(self):
|
||||||
|
res = self.client.get(self.list_url + ({'cat': self.category.pk + 1},))
|
||||||
|
eq_(res.status_code, 400)
|
||||||
|
|
||||||
|
def test_wrong_sort(self):
|
||||||
|
res = self.client.get(self.list_url + ({'sort': 'awesomeness'},))
|
||||||
|
eq_(res.status_code, 400)
|
||||||
|
|
||||||
|
def test_right_category(self):
|
||||||
|
res = self.client.get(self.list_url + ({'cat': self.category.pk},))
|
||||||
|
eq_(res.status_code, 200)
|
||||||
|
eq_(json.loads(res.content)['objects'], [])
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
AddonCategory.objects.create(addon=self.webapp, category=self.category)
|
||||||
|
self.webapp.save()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def test_right_category_present(self):
|
||||||
|
self.create()
|
||||||
|
res = self.client.get(self.list_url + ({'cat': self.category.pk},))
|
||||||
|
eq_(res.status_code, 200)
|
||||||
|
objs = json.loads(res.content)['objects']
|
||||||
|
eq_(len(objs), 1)
|
||||||
|
|
||||||
|
def test_dehdryate(self):
|
||||||
|
self.create()
|
||||||
|
res = self.client.get(self.list_url + ({'cat': self.category.pk},))
|
||||||
|
eq_(res.status_code, 200)
|
||||||
|
obj = json.loads(res.content)['objects'][0]
|
||||||
|
eq_(obj['slug'], self.webapp.app_slug)
|
||||||
|
eq_(obj['icon_url'], self.webapp.get_icon_url(32))
|
||||||
|
eq_(obj['absolute_url'], self.webapp.get_absolute_url())
|
||||||
|
eq_(obj['resource_uri'], None)
|
||||||
|
|
||||||
|
@mock.patch('mkt.search.api._filter_search')
|
||||||
|
def test_others_ignored(self, _filter_search):
|
||||||
|
_filter_search.return_value = []
|
||||||
|
res = self.client.get(self.list_url +
|
||||||
|
({'q': 'foo', 'sort': 'rating'},))
|
||||||
|
eq_(res.status_code, 200)
|
||||||
|
args = _filter_search.call_args[0][1]
|
||||||
|
assert 'sort' in args
|
||||||
|
assert 'q' not in args
|
|
@ -24,10 +24,23 @@ class FacetLink(object):
|
||||||
self.null_urlparams['page'] = None
|
self.null_urlparams['page'] = None
|
||||||
|
|
||||||
|
|
||||||
def _filter_search(qs, query, filters, sorting,
|
DEFAULT_FILTERS = ['cat', 'price', 'device', 'sort']
|
||||||
|
DEFAULT_SORTING = {
|
||||||
|
'downloads': '-weekly_downloads',
|
||||||
|
'rating': '-bayesian_rating',
|
||||||
|
'created': '-created',
|
||||||
|
'name': 'name_sort',
|
||||||
|
'hotness': '-hotness',
|
||||||
|
'price': 'price'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_search(qs, query, filters=None, sorting=None,
|
||||||
sorting_default='-weekly_downloads'):
|
sorting_default='-weekly_downloads'):
|
||||||
"""Filter an ES queryset based on a list of filters."""
|
"""Filter an ES queryset based on a list of filters."""
|
||||||
# Intersection of the form fields present and the filters we want to apply.
|
# Intersection of the form fields present and the filters we want to apply.
|
||||||
|
filters = filters or DEFAULT_FILTERS
|
||||||
|
sorting = sorting or DEFAULT_SORTING
|
||||||
show = [f for f in filters if query.get(f)]
|
show = [f for f in filters if query.get(f)]
|
||||||
|
|
||||||
if query.get('q'):
|
if query.get('q'):
|
||||||
|
@ -87,6 +100,13 @@ def sort_sidebar(query, form):
|
||||||
for key, text in form.fields['sort'].choices]
|
for key, text in form.fields['sort'].choices]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_query(request, form, query):
|
||||||
|
return (Webapp.search()
|
||||||
|
.filter(type=amo.ADDON_WEBAPP, status=amo.STATUS_PUBLIC,
|
||||||
|
is_disabled=False)
|
||||||
|
.facet(categories={'terms': {'field': 'category', 'size': 200}}))
|
||||||
|
|
||||||
|
|
||||||
def _app_search(request, category=None, browse=None):
|
def _app_search(request, category=None, browse=None):
|
||||||
form = forms.AppSearchForm(request.GET)
|
form = forms.AppSearchForm(request.GET)
|
||||||
form.is_valid() # Let the form try to clean data.
|
form.is_valid() # Let the form try to clean data.
|
||||||
|
@ -97,19 +117,8 @@ def _app_search(request, category=None, browse=None):
|
||||||
return {'redirect': amo.utils.urlparams(request.get_full_path(),
|
return {'redirect': amo.utils.urlparams(request.get_full_path(),
|
||||||
sort=None)}
|
sort=None)}
|
||||||
|
|
||||||
qs = (Webapp.search()
|
qs = _get_query(request, form, query)
|
||||||
.filter(type=amo.ADDON_WEBAPP, status=amo.STATUS_PUBLIC,
|
qs = _filter_search(qs, query)
|
||||||
is_disabled=False)
|
|
||||||
.facet(categories={'terms': {'field': 'category', 'size': 200}}))
|
|
||||||
|
|
||||||
filters = ['cat', 'price', 'device', 'sort']
|
|
||||||
sorting = {'downloads': '-weekly_downloads',
|
|
||||||
'rating': '-bayesian_rating',
|
|
||||||
'created': '-created',
|
|
||||||
'name': 'name_sort',
|
|
||||||
'hotness': '-hotness',
|
|
||||||
'price': 'price'}
|
|
||||||
qs = _filter_search(qs, query, filters, sorting)
|
|
||||||
|
|
||||||
pager = amo.utils.paginate(request, qs)
|
pager = amo.utils.paginate(request, qs)
|
||||||
facets = pager.object_list.facets
|
facets = pager.object_list.facets
|
||||||
|
|
Загрузка…
Ссылка в новой задаче