[Search] get search results from Elasticsearch
[mygpo.git] / mygpo / api / simple.py
blob94c392e01ee4b1124d7454423c870fc10e65bc27
2 # This file is part of my.gpodder.org.
4 # my.gpodder.org is free software: you can redistribute it and/or modify it
5 # under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or (at your
7 # option) any later version.
9 # my.gpodder.org is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
12 # License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with my.gpodder.org. If not, see <http://www.gnu.org/licenses/>.
18 import string
19 from itertools import islice
20 from functools import wraps
22 from couchdbkit.exceptions import ResourceNotFound
24 from django.shortcuts import render
25 from django.core.cache import cache
26 from django.http import HttpResponse, HttpResponseBadRequest
27 from django.views.decorators.cache import cache_page
28 from django.views.decorators.csrf import csrf_exempt
29 from django.views.decorators.cache import never_cache
30 from django.contrib.sites.models import RequestSite
31 from django.utils.translation import ugettext as _
33 from mygpo.api.basic_auth import require_valid_user, check_username
34 from mygpo.api.backend import get_device, BulkSubscribe
35 from mygpo.core import models
36 from mygpo.podcasts.models import Podcast
37 from mygpo.api.opml import Exporter, Importer
38 from mygpo.api.httpresponse import JsonResponse
39 from mygpo.directory.models import ExamplePodcasts
40 from mygpo.api.advanced.directory import podcast_data
41 from mygpo.search.index import search_podcasts
42 from mygpo.decorators import allowed_methods, cors_origin
43 from mygpo.utils import parse_range, normalize_feed_url
44 from mygpo.core.json import json, JSONDecodeError
45 from mygpo.db.couchdb import BulkException
46 from mygpo.db.couchdb.user import suggestions_for_user
48 import logging
49 logger = logging.getLogger(__name__)
52 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
54 def check_format(fn):
55 @wraps(fn)
56 def tmp(request, format, *args, **kwargs):
57 if not format in ALLOWED_FORMATS:
58 return HttpResponseBadRequest('Invalid format')
60 return fn(request, *args, format=format, **kwargs)
61 return tmp
64 @csrf_exempt
65 @require_valid_user
66 @check_username
67 @check_format
68 @never_cache
69 @allowed_methods(['GET', 'PUT', 'POST'])
70 @cors_origin()
71 def subscriptions(request, username, device_uid, format):
73 user_agent = request.META.get('HTTP_USER_AGENT', '')
75 if request.method == 'GET':
76 title = _('%(username)s\'s Subscription List') % {'username': username}
77 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
78 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
80 elif request.method in ('PUT', 'POST'):
81 try:
82 subscriptions = parse_subscription(request.body, format)
84 except JSONDecodeError as e:
85 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e))
87 return set_subscriptions(subscriptions, request.user, device_uid,
88 user_agent)
91 @csrf_exempt
92 @require_valid_user
93 @check_username
94 @check_format
95 @never_cache
96 @allowed_methods(['GET'])
97 @cors_origin()
98 def all_subscriptions(request, username, format):
100 try:
101 scale = int(request.GET.get('scale_logo', 64))
102 except (TypeError, ValueError):
103 return HttpResponseBadRequest('scale_logo has to be a numeric value')
105 if scale not in range(1, 257):
106 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
109 subscriptions = request.user.get_subscribed_podcasts()
110 title = _('%(username)s\'s Subscription List') % {'username': username}
111 domain = RequestSite(request).domain
112 p_data = lambda p: podcast_data(p, domain, scale)
113 return format_podcast_list(subscriptions, format, title,
114 json_map=p_data, xml_template='podcasts.xml', request=request)
117 def format_podcast_list(obj_list, format, title, get_podcast=None,
118 json_map=lambda x: x.url, jsonp_padding=None,
119 xml_template=None, request=None, template_args={}):
121 Formats a list of podcasts for use in a API response
123 obj_list is a list of podcasts or objects that contain podcasts
124 format is one if txt, opml or json
125 title is a label of the list
126 if obj_list is a list of objects containing podcasts, get_podcast is the
127 function used to get the podcast out of the each of these objects
128 json_map is a function returning the contents of an object (from obj_list)
129 that should be contained in the result (only used for format='json')
132 def default_get_podcast(p):
133 return p
135 get_podcast = get_podcast or default_get_podcast
137 if format == 'txt':
138 podcasts = map(get_podcast, obj_list)
139 s = '\n'.join([p.url for p in podcasts] + [''])
140 return HttpResponse(s, mimetype='text/plain')
142 elif format == 'opml':
143 podcasts = map(get_podcast, obj_list)
144 exporter = Exporter(title)
145 opml = exporter.generate(podcasts)
146 return HttpResponse(opml, mimetype='text/xml')
148 elif format == 'json':
149 objs = map(json_map, obj_list)
150 return JsonResponse(objs)
152 elif format == 'jsonp':
153 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
155 if not jsonp_padding:
156 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
158 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
159 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
161 objs = map(json_map, obj_list)
162 return JsonResponse(objs, jsonp_padding=jsonp_padding)
164 elif format == 'xml':
165 if None in (xml_template, request):
166 return HttpResponseBadRequest('XML is not a valid format for this request')
168 podcasts = map(json_map, obj_list)
169 template_args.update({'podcasts': podcasts})
171 return render(request, xml_template, template_args,
172 content_type='application/xml')
174 else:
175 return None
178 def get_subscriptions(user, device_uid, user_agent=None):
179 device = get_device(user, device_uid, user_agent)
180 return device.get_subscribed_podcasts()
183 def parse_subscription(raw_post_data, format):
184 if format == 'txt':
185 urls = raw_post_data.split('\n')
187 elif format == 'opml':
188 begin = raw_post_data.find('<?xml')
189 end = raw_post_data.find('</opml>') + 7
190 i = Importer(content=raw_post_data[begin:end])
191 urls = [p['url'] for p in i.items]
193 elif format == 'json':
194 begin = raw_post_data.find('[')
195 end = raw_post_data.find(']') + 1
196 urls = json.loads(raw_post_data[begin:end])
198 else:
199 return []
202 urls = map(normalize_feed_url, urls)
203 urls = filter(None, urls)
204 urls = set(urls)
205 return urls
208 def set_subscriptions(urls, user, device_uid, user_agent):
210 device = get_device(user, device_uid, user_agent, undelete=True)
212 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
213 new = [p for p in urls if p not in subscriptions.keys()]
214 rem = [p for p in subscriptions.keys() if p not in urls]
216 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
218 for r in rem:
219 subscriber.add_action(r, 'unsubscribe')
221 for n in new:
222 subscriber.add_action(n, 'subscribe')
224 try:
225 errors = subscriber.execute()
226 except BulkException as be:
227 for err in be.errors:
228 logger.warn('Simple API: %(username)s: Updating subscription for '
229 '%(podcast_url)s on %(device_uid)s failed: '
230 '%(error)s (%(reason)s)'.format(username=user.username,
231 podcast_url=err.doc, device_uid=device.uid,
232 error=err.error, reason=err.reason)
235 # Only an empty response is a successful response
236 return HttpResponse('', mimetype='text/plain')
239 @check_format
240 @allowed_methods(['GET'])
241 @cache_page(60 * 60)
242 @cors_origin()
243 def toplist(request, count, format):
244 count = parse_range(count, 1, 100, 100)
246 entries = Podcast.objects.all().toplist()[:count]
247 domain = RequestSite(request).domain
249 try:
250 scale = int(request.GET.get('scale_logo', 64))
251 except (TypeError, ValueError):
252 return HttpResponseBadRequest('scale_logo has to be a numeric value')
254 if scale not in range(1, 257):
255 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
258 def get_podcast(t):
259 old_pos, podcast = t
260 return podcast.get_podcast()
262 def json_map(t):
263 old_pos, podcast = t
264 podcast.old_pos = old_pos
266 p = podcast_data(podcast, domain, scale)
267 p.update(dict(
268 subscribers = podcast.subscriber_count(),
269 subscribers_last_week = podcast.prev_subscriber_count(),
270 position_last_week = podcast.old_pos,
272 return p
274 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
275 return format_podcast_list(entries,
276 format,
277 title,
278 get_podcast=get_podcast,
279 json_map=json_map,
280 jsonp_padding=request.GET.get('jsonp', ''),
281 xml_template='podcasts.xml',
282 request=request,
286 @check_format
287 @cache_page(60 * 60)
288 @allowed_methods(['GET'])
289 @cors_origin()
290 def search(request, format):
292 NUM_RESULTS = 20
294 query = request.GET.get('q', '').encode('utf-8')
296 try:
297 scale = int(request.GET.get('scale_logo', 64))
298 except (TypeError, ValueError):
299 return HttpResponseBadRequest('scale_logo has to be a numeric value')
301 if scale not in range(1, 257):
302 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
304 if not query:
305 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
307 results = search_podcasts(query)[:NUM_RESULTS]
309 title = _('gpodder.net - Search')
310 domain = RequestSite(request).domain
311 p_data = lambda p: podcast_data(p, domain, scale)
312 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
315 @require_valid_user
316 @check_format
317 @never_cache
318 @allowed_methods(['GET'])
319 @cors_origin()
320 def suggestions(request, count, format):
321 count = parse_range(count, 1, 100, 100)
323 suggestion_obj = suggestions_for_user(request.user)
324 suggestions = suggestion_obj.get_podcasts(count)
325 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
326 domain = RequestSite(request).domain
327 p_data = lambda p: podcast_data(p, domain)
328 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
331 @check_format
332 @allowed_methods(['GET'])
333 @cache_page(60 * 60)
334 @cors_origin()
335 def example_podcasts(request, format):
337 podcasts = cache.get('example-podcasts', None)
339 try:
340 scale = int(request.GET.get('scale_logo', 64))
341 except (TypeError, ValueError):
342 return HttpResponseBadRequest('scale_logo has to be a numeric value')
344 if scale not in range(1, 257):
345 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
348 if not podcasts:
350 try:
351 examples = ExamplePodcasts.get('example_podcasts')
352 ids = examples.podcast_ids
353 podcasts = Podcasts.objects.filter(id__in=ids)
354 cache.set('example-podcasts', podcasts)
356 except ResourceNotFound:
357 podcasts = []
359 title = 'gPodder Podcast Directory'
360 domain = RequestSite(request).domain
361 p_data = lambda p: podcast_data(p, domain, scale)
362 return format_podcast_list(
363 podcasts,
364 format,
365 title,
366 json_map=p_data,
367 xml_template='podcasts.xml',
368 request=request,