Access-Control-Allow-Origin: * in all API requests
[mygpo.git] / mygpo / api / simple.py
blobf7c56e5f06141c3aa57e82e01f104e71a8789e59
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.core.models import Podcast
37 from mygpo.users.models import Suggestions
38 from mygpo.api.opml import Exporter, Importer
39 from mygpo.api.httpresponse import JsonResponse
40 from mygpo.directory.toplist import PodcastToplist
41 from mygpo.directory.models import ExamplePodcasts
42 from mygpo.api.advanced.directory import podcast_data
43 from mygpo.directory.search import search_podcasts
44 from mygpo.decorators import allowed_methods, cors_origin
45 from mygpo.utils import parse_range, normalize_feed_url
46 from mygpo.core.json import json, JSONDecodeError
47 from mygpo.db.couchdb import BulkException
48 from mygpo.db.couchdb.podcast import podcasts_by_id
49 from mygpo.db.couchdb.user import suggestions_for_user
51 import logging
52 logger = logging.getLogger(__name__)
55 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
57 def check_format(fn):
58 @wraps(fn)
59 def tmp(request, format, *args, **kwargs):
60 if not format in ALLOWED_FORMATS:
61 return HttpResponseBadRequest('Invalid format')
63 return fn(request, *args, format=format, **kwargs)
64 return tmp
67 @csrf_exempt
68 @require_valid_user
69 @check_username
70 @check_format
71 @never_cache
72 @allowed_methods(['GET', 'PUT', 'POST'])
73 @cors_origin()
74 def subscriptions(request, username, device_uid, format):
76 user_agent = request.META.get('HTTP_USER_AGENT', '')
78 if request.method == 'GET':
79 title = _('%(username)s\'s Subscription List') % {'username': username}
80 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
81 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
83 elif request.method in ('PUT', 'POST'):
84 try:
85 subscriptions = parse_subscription(request.body, format)
87 except JSONDecodeError as e:
88 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e))
90 return set_subscriptions(subscriptions, request.user, device_uid,
91 user_agent)
94 @csrf_exempt
95 @require_valid_user
96 @check_username
97 @check_format
98 @never_cache
99 @allowed_methods(['GET'])
100 @cors_origin()
101 def all_subscriptions(request, username, format):
103 try:
104 scale = int(request.GET.get('scale_logo', 64))
105 except (TypeError, ValueError):
106 return HttpResponseBadRequest('scale_logo has to be a numeric value')
108 if scale not in range(1, 257):
109 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
112 subscriptions = request.user.get_subscribed_podcasts()
113 title = _('%(username)s\'s Subscription List') % {'username': username}
114 domain = RequestSite(request).domain
115 p_data = lambda p: podcast_data(p, domain, scale)
116 return format_podcast_list(subscriptions, format, title,
117 json_map=p_data, xml_template='podcasts.xml', request=request)
120 def format_podcast_list(obj_list, format, title, get_podcast=None,
121 json_map=lambda x: x.url, jsonp_padding=None,
122 xml_template=None, request=None, template_args={}):
124 Formats a list of podcasts for use in a API response
126 obj_list is a list of podcasts or objects that contain podcasts
127 format is one if txt, opml or json
128 title is a label of the list
129 if obj_list is a list of objects containing podcasts, get_podcast is the
130 function used to get the podcast out of the each of these objects
131 json_map is a function returning the contents of an object (from obj_list)
132 that should be contained in the result (only used for format='json')
135 def default_get_podcast(p):
136 return p.get_podcast()
138 get_podcast = get_podcast or default_get_podcast
140 if format == 'txt':
141 podcasts = map(get_podcast, obj_list)
142 s = '\n'.join([p.url for p in podcasts] + [''])
143 return HttpResponse(s, mimetype='text/plain')
145 elif format == 'opml':
146 podcasts = map(get_podcast, obj_list)
147 exporter = Exporter(title)
148 opml = exporter.generate(podcasts)
149 return HttpResponse(opml, mimetype='text/xml')
151 elif format == 'json':
152 objs = map(json_map, obj_list)
153 return JsonResponse(objs)
155 elif format == 'jsonp':
156 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
158 if not jsonp_padding:
159 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
161 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
162 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
164 objs = map(json_map, obj_list)
165 return JsonResponse(objs, jsonp_padding=jsonp_padding)
167 elif format == 'xml':
168 if None in (xml_template, request):
169 return HttpResponseBadRequest('XML is not a valid format for this request')
171 podcasts = map(json_map, obj_list)
172 template_args.update({'podcasts': podcasts})
174 return render(request, xml_template, template_args,
175 content_type='application/xml')
177 else:
178 return None
181 def get_subscriptions(user, device_uid, user_agent=None):
182 device = get_device(user, device_uid, user_agent)
183 return device.get_subscribed_podcasts()
186 def parse_subscription(raw_post_data, format):
187 if format == 'txt':
188 urls = raw_post_data.split('\n')
190 elif format == 'opml':
191 begin = raw_post_data.find('<?xml')
192 end = raw_post_data.find('</opml>') + 7
193 i = Importer(content=raw_post_data[begin:end])
194 urls = [p['url'] for p in i.items]
196 elif format == 'json':
197 begin = raw_post_data.find('[')
198 end = raw_post_data.find(']') + 1
199 urls = json.loads(raw_post_data[begin:end])
201 else:
202 return []
205 urls = map(normalize_feed_url, urls)
206 urls = filter(None, urls)
207 urls = set(urls)
208 return urls
211 def set_subscriptions(urls, user, device_uid, user_agent):
213 device = get_device(user, device_uid, user_agent, undelete=True)
215 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
216 new = [p for p in urls if p not in subscriptions.keys()]
217 rem = [p for p in subscriptions.keys() if p not in urls]
219 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
221 for r in rem:
222 subscriber.add_action(r, 'unsubscribe')
224 for n in new:
225 subscriber.add_action(n, 'subscribe')
227 try:
228 errors = subscriber.execute()
229 except BulkException as be:
230 for err in be.errors:
231 logger.warn('Simple API: %(username)s: Updating subscription for '
232 '%(podcast_url)s on %(device_uid)s failed: '
233 '%(error)s (%(reason)s)'.format(username=user.username,
234 podcast_url=err.doc, device_uid=device.uid,
235 error=err.error, reason=err.reason)
238 # Only an empty response is a successful response
239 return HttpResponse('', mimetype='text/plain')
242 @check_format
243 @allowed_methods(['GET'])
244 @cache_page(60 * 60)
245 @cors_origin()
246 def toplist(request, count, format):
247 count = parse_range(count, 1, 100, 100)
249 toplist = PodcastToplist()
250 entries = toplist[:count]
251 domain = RequestSite(request).domain
253 try:
254 scale = int(request.GET.get('scale_logo', 64))
255 except (TypeError, ValueError):
256 return HttpResponseBadRequest('scale_logo has to be a numeric value')
258 if scale not in range(1, 257):
259 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
262 def get_podcast(t):
263 old_pos, podcast = t
264 return podcast.get_podcast()
266 def json_map(t):
267 old_pos, podcast = t
268 podcast.old_pos = old_pos
270 p = podcast_data(podcast, domain, scale)
271 p.update(dict(
272 subscribers = podcast.subscriber_count(),
273 subscribers_last_week = podcast.prev_subscriber_count(),
274 position_last_week = podcast.old_pos,
276 return p
278 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
279 return format_podcast_list(entries,
280 format,
281 title,
282 get_podcast=get_podcast,
283 json_map=json_map,
284 jsonp_padding=request.GET.get('jsonp', ''),
285 xml_template='podcasts.xml',
286 request=request,
290 @check_format
291 @cache_page(60 * 60)
292 @allowed_methods(['GET'])
293 @cors_origin()
294 def search(request, format):
296 NUM_RESULTS = 20
298 query = request.GET.get('q', '').encode('utf-8')
300 try:
301 scale = int(request.GET.get('scale_logo', 64))
302 except (TypeError, ValueError):
303 return HttpResponseBadRequest('scale_logo has to be a numeric value')
305 if scale not in range(1, 257):
306 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
308 if not query:
309 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
311 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
313 title = _('gpodder.net - Search')
314 domain = RequestSite(request).domain
315 p_data = lambda p: podcast_data(p, domain, scale)
316 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
319 @require_valid_user
320 @check_format
321 @never_cache
322 @allowed_methods(['GET'])
323 @cors_origin()
324 def suggestions(request, count, format):
325 count = parse_range(count, 1, 100, 100)
327 suggestion_obj = suggestions_for_user(request.user)
328 suggestions = suggestion_obj.get_podcasts(count)
329 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
330 domain = RequestSite(request).domain
331 p_data = lambda p: podcast_data(p, domain)
332 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
335 @check_format
336 @allowed_methods(['GET'])
337 @cache_page(60 * 60)
338 @cors_origin()
339 def example_podcasts(request, format):
341 podcasts = cache.get('example-podcasts', None)
343 try:
344 scale = int(request.GET.get('scale_logo', 64))
345 except (TypeError, ValueError):
346 return HttpResponseBadRequest('scale_logo has to be a numeric value')
348 if scale not in range(1, 257):
349 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
352 if not podcasts:
354 try:
355 examples = ExamplePodcasts.get('example_podcasts')
356 ids = examples.podcast_ids
357 podcasts = podcasts_by_id(ids)
358 cache.set('example-podcasts', podcasts)
360 except ResourceNotFound:
361 podcasts = []
363 title = 'gPodder Podcast Directory'
364 domain = RequestSite(request).domain
365 p_data = lambda p: podcast_data(p, domain, scale)
366 return format_podcast_list(
367 podcasts,
368 format,
369 title,
370 json_map=p_data,
371 xml_template='podcasts.xml',
372 request=request,