503 строки
17 KiB
Python
503 строки
17 KiB
Python
"""
|
|
API views
|
|
"""
|
|
from datetime import date, timedelta
|
|
import hashlib
|
|
import itertools
|
|
import json
|
|
import random
|
|
import urllib
|
|
|
|
from django.core.cache import cache
|
|
from django.http import HttpResponse, HttpResponsePermanentRedirect
|
|
from django.template.context import get_standard_processors
|
|
from django.utils import translation, encoding
|
|
from django.utils.encoding import smart_str
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
from caching.base import cached_with
|
|
import commonware.log
|
|
import jingo
|
|
from piston.utils import rc
|
|
from tower import ugettext as _, ugettext_lazy
|
|
import waffle
|
|
|
|
import amo
|
|
import api
|
|
from amo.decorators import post_required
|
|
from api.authentication import AMOOAuthAuthentication
|
|
from api.forms import PerformanceForm
|
|
from api.utils import addon_to_dict, extract_filters
|
|
from amo.models import manual_order
|
|
from amo.urlresolvers import get_url_prefix
|
|
from amo.utils import JSONEncoder
|
|
from addons.models import Addon, CompatOverride
|
|
from perf.models import (Performance, PerformanceAppVersions,
|
|
PerformanceOSVersion)
|
|
from search.client import (Client as SearchClient, SearchError,
|
|
SEARCHABLE_STATUSES)
|
|
from search.views import name_query
|
|
from search import utils as search_utils
|
|
|
|
|
|
ERROR = 'error'
|
|
OUT_OF_DATE = ugettext_lazy(
|
|
u"The API version, {0:.1f}, you are using is not valid. "
|
|
u"Please upgrade to the current version {1:.1f} API.")
|
|
|
|
xml_env = jingo.env.overlay()
|
|
old_finalize = xml_env.finalize
|
|
xml_env.finalize = lambda x: amo.helpers.strip_controls(old_finalize(x))
|
|
|
|
|
|
# Hard limit of 30. The buffer is to try for locale-specific add-ons.
|
|
MAX_LIMIT, BUFFER = 30, 10
|
|
|
|
# "New" is arbitrarily defined as 10 days old.
|
|
NEW_DAYS = 10
|
|
|
|
log = commonware.log.getLogger('z.api')
|
|
|
|
|
|
def partition(seq, key):
|
|
"""Group a sequence based into buckets by key(x)."""
|
|
groups = itertools.groupby(sorted(seq, key=key), key=key)
|
|
return ((k, list(v)) for k, v in groups)
|
|
|
|
|
|
def render_xml_to_string(request, template, context={}):
|
|
if not jingo._helpers_loaded:
|
|
jingo.load_helpers()
|
|
|
|
for processor in get_standard_processors():
|
|
context.update(processor(request))
|
|
|
|
template = xml_env.get_template(template)
|
|
return template.render(**context)
|
|
|
|
|
|
def render_xml(request, template, context={}, **kwargs):
|
|
"""Safely renders xml, stripping out nasty control characters."""
|
|
rendered = render_xml_to_string(request, template, context)
|
|
|
|
if 'mimetype' not in kwargs:
|
|
kwargs['mimetype'] = 'text/xml'
|
|
|
|
return HttpResponse(rendered, **kwargs)
|
|
|
|
|
|
def handler404(request):
|
|
context = {'error_level': ERROR, 'msg': 'Not Found'}
|
|
return render_xml(request, 'api/message.xml', context, status=404)
|
|
|
|
|
|
def handler500(request):
|
|
context = {'error_level': ERROR, 'msg': 'Server Error'}
|
|
return render_xml(request, 'api/message.xml', context, status=500)
|
|
|
|
|
|
def validate_api_version(version):
|
|
"""
|
|
We want to be able to deprecate old versions of the API, therefore we check
|
|
for a minimum API version before continuing.
|
|
"""
|
|
if float(version) < api.MIN_VERSION:
|
|
return False
|
|
|
|
if float(version) > api.MAX_VERSION:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def addon_filter(addons, addon_type, limit, app, platform, version,
|
|
compat_mode='strict', shuffle=True):
|
|
"""
|
|
Filter addons by type, application, app version, and platform.
|
|
|
|
Add-ons that support the current locale will be sorted to front of list.
|
|
Shuffling will be applied to the add-ons supporting the locale and the
|
|
others separately.
|
|
|
|
Doing this in the database takes too long, so we in code and wrap it in
|
|
generous caching.
|
|
"""
|
|
APP = app
|
|
|
|
if addon_type.upper() != 'ALL':
|
|
try:
|
|
addon_type = int(addon_type)
|
|
if addon_type:
|
|
addons = [a for a in addons if a.type == addon_type]
|
|
except ValueError:
|
|
# `addon_type` is ALL or a type id. Otherwise we ignore it.
|
|
pass
|
|
|
|
# Take out personas since they don't have versions.
|
|
groups = dict(partition(addons,
|
|
lambda x: x.type == amo.ADDON_PERSONA))
|
|
personas, addons = groups.get(True, []), groups.get(False, [])
|
|
|
|
platform = platform.lower()
|
|
if platform != 'all' and platform in amo.PLATFORM_DICT:
|
|
pid = amo.PLATFORM_DICT[platform]
|
|
f = lambda ps: pid in ps or amo.PLATFORM_ALL in ps
|
|
addons = [a for a in addons
|
|
if f(a.current_version.supported_platforms)]
|
|
|
|
if version is not None:
|
|
v = search_utils.convert_version(version)
|
|
f = lambda app: app.min.version_int <= v <= app.max.version_int
|
|
xs = [(a, a.compatible_apps) for a in addons]
|
|
addons = [a for a, apps in xs if apps.get(APP) and f(apps[APP])]
|
|
|
|
# Put personas back in.
|
|
addons.extend(personas)
|
|
|
|
# We prefer add-ons that support the current locale.
|
|
lang = translation.get_language()
|
|
partitioner = lambda x: (x.description and
|
|
(x.description.locale == lang))
|
|
groups = dict(partition(addons, partitioner))
|
|
good, others = groups.get(True, []), groups.get(False, [])
|
|
|
|
if shuffle:
|
|
random.shuffle(good)
|
|
random.shuffle(others)
|
|
|
|
if len(good) < limit:
|
|
good.extend(others[:limit - len(good)])
|
|
return good[:limit]
|
|
|
|
|
|
class APIView(object):
|
|
"""
|
|
Base view class for all API views.
|
|
"""
|
|
|
|
def __call__(self, request, api_version, *args, **kwargs):
|
|
|
|
self.version = float(api_version)
|
|
self.format = request.REQUEST.get('format', 'xml')
|
|
self.mimetype = ('text/xml' if self.format == 'xml'
|
|
else 'application/json')
|
|
self.request = request
|
|
if not validate_api_version(api_version):
|
|
msg = OUT_OF_DATE.format(self.version, api.CURRENT_VERSION)
|
|
return self.render_msg(msg, ERROR, status=403,
|
|
mimetype=self.mimetype)
|
|
|
|
return self.process_request(*args, **kwargs)
|
|
|
|
def render_msg(self, msg, error_level=None, *args, **kwargs):
|
|
"""
|
|
Renders a simple message.
|
|
"""
|
|
|
|
if self.format == 'xml':
|
|
return render_xml(self.request, 'api/message.xml',
|
|
{'error_level': error_level, 'msg': msg}, *args, **kwargs)
|
|
else:
|
|
return HttpResponse(json.dumps({'msg': _(msg)}), *args, **kwargs)
|
|
|
|
def render(self, template, context):
|
|
context['api_version'] = self.version
|
|
context['api'] = api
|
|
|
|
if self.format == 'xml':
|
|
return render_xml(self.request, template, context,
|
|
mimetype=self.mimetype)
|
|
else:
|
|
return HttpResponse(self.render_json(context),
|
|
mimetype=self.mimetype)
|
|
|
|
def render_json(self, context):
|
|
return json.dumps({'msg': _('Not implemented yet.')})
|
|
|
|
|
|
class AddonDetailView(APIView):
|
|
|
|
def process_request(self, addon_id):
|
|
try:
|
|
addon = Addon.objects.id_or_slug(addon_id).get()
|
|
except Addon.DoesNotExist:
|
|
return self.render_msg('Add-on not found!', ERROR, status=404,
|
|
mimetype=self.mimetype)
|
|
|
|
if addon.is_disabled:
|
|
return self.render_msg('Add-on disabled.', ERROR, status=404,
|
|
mimetype=self.mimetype)
|
|
return self.render_addon(addon)
|
|
|
|
def render_addon(self, addon):
|
|
return self.render('api/addon_detail.xml', {'addon': addon})
|
|
|
|
|
|
def guid_search(request, api_version, guids):
|
|
if waffle.switch_is_active('new-guid-search'):
|
|
return _guid_search_caching(request, api_version, guids)
|
|
else:
|
|
return _guid_search_old(request, api_version, guids)
|
|
|
|
|
|
def _guid_search_old(request, api_version, guids):
|
|
guids = [g.strip() for g in guids.split(',')] if guids else []
|
|
results = Addon.objects.filter(guid__in=guids, disabled_by_user=False,
|
|
status__in=SEARCHABLE_STATUSES)
|
|
compat = (CompatOverride.objects.filter(guid__in=guids)
|
|
.transform(CompatOverride.transformer))
|
|
return render_xml(request, 'api/search.xml',
|
|
{'results': results, 'total': len(results),
|
|
'compat': compat,
|
|
'api_version': api_version, 'api': api})
|
|
|
|
|
|
def _guid_search_caching(request, api_version, guids):
|
|
lang = request.LANG
|
|
|
|
def guid_search_cache_key(guid):
|
|
key = 'guid_search:%s:%s:%s' % (api_version, lang, guid)
|
|
return hashlib.md5(smart_str(key)).hexdigest()
|
|
|
|
guids = [g.strip() for g in guids.split(',')] if guids else []
|
|
|
|
addons_xml = cache.get_many([guid_search_cache_key(g) for g in guids])
|
|
dirty_keys = set()
|
|
|
|
for g in guids:
|
|
key = guid_search_cache_key(g)
|
|
if key not in addons_xml:
|
|
dirty_keys.add(key)
|
|
try:
|
|
addon = Addon.objects.get(guid=g, disabled_by_user=False,
|
|
status__in=SEARCHABLE_STATUSES)
|
|
|
|
except Addon.DoesNotExist:
|
|
addons_xml[key] = ''
|
|
|
|
else:
|
|
addon_xml = render_xml_to_string(request,
|
|
'api/includes/addon.xml',
|
|
{'addon': addon,
|
|
'api_version': api_version,
|
|
'api': api})
|
|
addons_xml[key] = addon_xml
|
|
|
|
cache.set_many(dict((k, v) for k, v in addons_xml.iteritems()
|
|
if k in dirty_keys))
|
|
|
|
compat = (CompatOverride.objects.filter(guid__in=guids)
|
|
.transform(CompatOverride.transformer))
|
|
|
|
addons_xml = [v for v in addons_xml.values() if v]
|
|
return render_xml(request, 'api/search.xml',
|
|
{'addons_xml': addons_xml,
|
|
'total': len(addons_xml),
|
|
'compat': compat,
|
|
'api_version': api_version, 'api': api})
|
|
|
|
|
|
class SearchView(APIView):
|
|
|
|
def process_request(self, query, addon_type='ALL', limit=10,
|
|
platform='ALL', version=None, compat_mode='strict'):
|
|
"""
|
|
Query the search backend and serve up the XML.
|
|
"""
|
|
if not waffle.flag_is_active(self.request, 'new-api-search'):
|
|
return self._sphinx_api_search(query, addon_type, limit, platform,
|
|
version, compat_mode)
|
|
|
|
limit = min(MAX_LIMIT, int(limit))
|
|
|
|
filters = {
|
|
'app': self.request.APP.id,
|
|
'status__in': amo.REVIEWED_STATUSES,
|
|
'is_disabled': False,
|
|
'has_version': True,
|
|
}
|
|
|
|
# Opts may get overridden by query string filters.
|
|
opts = {
|
|
'addon_type': addon_type,
|
|
'platform': platform,
|
|
'version': version,
|
|
}
|
|
|
|
if self.version < 1.5:
|
|
# By default we show public addons only for api_version < 1.5.
|
|
filters['status__in'] = [amo.STATUS_PUBLIC]
|
|
|
|
# Fix doubly encoded query strings.
|
|
try:
|
|
query = urllib.unquote(query.encode('ascii'))
|
|
except UnicodeEncodeError:
|
|
# This fails if the string is already UTF-8.
|
|
pass
|
|
|
|
query, qs_filters = extract_filters(query, filters['app'], opts)
|
|
|
|
qs = Addon.search().query(or_=name_query(query))
|
|
filters.update(qs_filters)
|
|
if 'type' not in filters:
|
|
# Filter by ALL types, which is really all types except for apps.
|
|
filters['type__in'] = list(amo.ADDON_SEARCH_TYPES)
|
|
qs = qs.filter(**filters)
|
|
|
|
return self.render('api/search.xml', {
|
|
'results': qs[:limit],
|
|
'total': qs.count(),
|
|
})
|
|
|
|
def _sphinx_api_search(self, query, addon_type='ALL', limit=10,
|
|
platform='ALL', version=None, compat_mode='strict'):
|
|
"""
|
|
This queries sphinx with `query` and serves the results in xml.
|
|
"""
|
|
sc = SearchClient()
|
|
limit = min(MAX_LIMIT, int(limit))
|
|
|
|
opts = {'app': self.request.APP.id}
|
|
|
|
if addon_type.upper() != 'ALL':
|
|
try:
|
|
opts['type'] = int(addon_type)
|
|
except ValueError:
|
|
# `addon_type` is ALL or a type id. Otherwise we ignore it.
|
|
pass
|
|
|
|
if version:
|
|
opts['version'] = version
|
|
|
|
if platform.upper() != 'ALL':
|
|
opts['platform'] = platform.lower()
|
|
|
|
if self.version < 1.5:
|
|
# By default we show public addons only for api_version < 1.5
|
|
opts['status'] = [amo.STATUS_PUBLIC]
|
|
|
|
# Fix doubly encoded query strings
|
|
try:
|
|
query = urllib.unquote(query.encode('ascii'))
|
|
except UnicodeEncodeError:
|
|
# This fails if the string is already UTF-8.
|
|
pass
|
|
try:
|
|
results = sc.query(query, limit=limit, **opts)
|
|
except SearchError:
|
|
return self.render_msg('Could not connect to Sphinx search.',
|
|
ERROR, status=503, mimetype=self.mimetype)
|
|
|
|
return self.render('api/search.xml',
|
|
{'results': results, 'total': sc.total_found})
|
|
|
|
|
|
class ListView(APIView):
|
|
|
|
def process_request(self, list_type='recommended', addon_type='ALL',
|
|
limit=10, platform='ALL', version=None,
|
|
compat_mode='strict'):
|
|
"""
|
|
Find a list of new or featured add-ons. Filtering is done in Python
|
|
for cache-friendliness and to avoid heavy queries.
|
|
"""
|
|
limit = min(MAX_LIMIT, int(limit))
|
|
APP, platform = self.request.APP, platform.lower()
|
|
qs = Addon.objects.listed(APP).exclude(type=amo.ADDON_WEBAPP)
|
|
shuffle = True
|
|
|
|
if list_type in ('by_adu', 'featured'):
|
|
qs = qs.exclude(type=amo.ADDON_PERSONA)
|
|
|
|
if list_type == 'newest':
|
|
new = date.today() - timedelta(days=NEW_DAYS)
|
|
addons = (qs.filter(created__gte=new)
|
|
.order_by('-created'))[:limit + BUFFER]
|
|
elif list_type == 'by_adu':
|
|
addons = qs.order_by('-average_daily_users')[:limit + BUFFER]
|
|
shuffle = False # By_adu is an ordered list.
|
|
elif list_type == 'hotness':
|
|
# Filter to type=1 so we hit visible_idx. Only extensions have a
|
|
# hotness index right now so this is not incorrect.
|
|
addons = (qs.filter(type=amo.ADDON_EXTENSION)
|
|
.order_by('-hotness'))[:limit + BUFFER]
|
|
shuffle = False
|
|
else:
|
|
ids = Addon.featured_random(APP, self.request.LANG)
|
|
addons = manual_order(qs, ids[:limit + BUFFER], 'addons.id')
|
|
shuffle = False
|
|
|
|
args = (addon_type, limit, APP, platform, version, compat_mode, shuffle)
|
|
f = lambda: self._process(addons, *args)
|
|
return cached_with(addons, f, map(encoding.smart_str, args))
|
|
|
|
def _process(self, addons, *args):
|
|
return self.render('api/list.xml',
|
|
{'addons': addon_filter(addons, *args)})
|
|
|
|
def render_json(self, context):
|
|
return json.dumps([addon_to_dict(a) for a in context['addons']],
|
|
cls=JSONEncoder)
|
|
|
|
|
|
class LanguageView(APIView):
|
|
|
|
def process_request(self):
|
|
addons = Addon.objects.filter(status=amo.STATUS_PUBLIC,
|
|
type=amo.ADDON_LPAPP,
|
|
appsupport__app=self.request.APP.id,
|
|
disabled_by_user=False).order_by('pk')
|
|
return self.render('api/list.xml', {'addons': addons,
|
|
'show_localepicker': True})
|
|
|
|
|
|
# pylint: disable-msg=W0613
|
|
def redirect_view(request, url):
|
|
"""
|
|
Redirect all requests that come here to an API call with a view parameter.
|
|
"""
|
|
dest = '/api/%.1f/%s' % (api.CURRENT_VERSION,
|
|
urllib.quote(url.encode('utf-8')))
|
|
dest = get_url_prefix().fix(dest)
|
|
|
|
return HttpResponsePermanentRedirect(dest)
|
|
|
|
|
|
def request_token_ready(request, token):
|
|
error = request.GET.get('error', '')
|
|
ctx = {'error': error, 'token': token}
|
|
return jingo.render(request, 'piston/request_token_ready.html', ctx)
|
|
|
|
|
|
@csrf_exempt
|
|
@post_required
|
|
def performance_add(request):
|
|
"""
|
|
A wrapper around adding in performance data that is easier than
|
|
using the piston API.
|
|
"""
|
|
# Trigger OAuth.
|
|
if not AMOOAuthAuthentication(two_legged=True).is_authenticated(request):
|
|
return rc.FORBIDDEN
|
|
|
|
form = PerformanceForm(request.POST)
|
|
if not form.is_valid():
|
|
return form.show_error()
|
|
|
|
os, created = (PerformanceOSVersion
|
|
.objects.safer_get_or_create(**form.os_version))
|
|
app, created = (PerformanceAppVersions
|
|
.objects.safer_get_or_create(**form.app_version))
|
|
|
|
data = form.performance
|
|
data.update({'osversion': os, 'appversion': app})
|
|
|
|
# Look up on everything except the average time.
|
|
result, created = Performance.objects.safer_get_or_create(**data)
|
|
result.average = form.cleaned_data['average']
|
|
result.save()
|
|
|
|
log.info('Performance created for add-on: %s, %s' %
|
|
(form.cleaned_data['addon_id'], form.cleaned_data['average']))
|
|
return rc.ALL_OK
|