remove gevent monkey-patch from feed-downloader
[mygpo.git] / mygpo / api / simple.py
blobf47ce7a0948d81106e0e271549e7be6c54401732
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.api.sanitizing import sanitize_urls
41 from mygpo.directory.toplist import PodcastToplist
42 from mygpo.directory.models import ExamplePodcasts
43 from mygpo.api.advanced.directory import podcast_data
44 from mygpo.directory.search import search_podcasts
45 from mygpo.log import log
46 from mygpo.decorators import allowed_methods
47 from mygpo.utils import parse_range
48 from mygpo.core.json import json, JSONDecodeError
49 from mygpo.db.couchdb import BulkException
50 from mygpo.db.couchdb.podcast import podcasts_by_id
51 from mygpo.db.couchdb.user import suggestions_for_user
53 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
55 def check_format(fn):
56 @wraps(fn)
57 def tmp(request, format, *args, **kwargs):
58 if not format in ALLOWED_FORMATS:
59 return HttpResponseBadRequest('Invalid format')
61 return fn(request, *args, format=format, **kwargs)
62 return tmp
65 @csrf_exempt
66 @require_valid_user
67 @check_username
68 @check_format
69 @never_cache
70 @allowed_methods(['GET', 'PUT', 'POST'])
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 def all_subscriptions(request, username, format):
99 try:
100 scale = int(request.GET.get('scale_logo', 64))
101 except (TypeError, ValueError):
102 return HttpResponseBadRequest('scale_logo has to be a numeric value')
104 if scale not in range(1, 257):
105 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
108 subscriptions = request.user.get_subscribed_podcasts()
109 title = _('%(username)s\'s Subscription List') % {'username': username}
110 domain = RequestSite(request).domain
111 p_data = lambda p: podcast_data(p, domain, scale)
112 return format_podcast_list(subscriptions, format, title,
113 json_map=p_data, xml_template='podcasts.xml', request=request)
116 def format_podcast_list(obj_list, format, title, get_podcast=None,
117 json_map=lambda x: x.url, jsonp_padding=None,
118 xml_template=None, request=None, template_args={}):
120 Formats a list of podcasts for use in a API response
122 obj_list is a list of podcasts or objects that contain podcasts
123 format is one if txt, opml or json
124 title is a label of the list
125 if obj_list is a list of objects containing podcasts, get_podcast is the
126 function used to get the podcast out of the each of these objects
127 json_map is a function returning the contents of an object (from obj_list)
128 that should be contained in the result (only used for format='json')
131 def default_get_podcast(p):
132 return p.get_podcast()
134 get_podcast = get_podcast or default_get_podcast
136 if format == 'txt':
137 podcasts = map(get_podcast, obj_list)
138 s = '\n'.join([p.url for p in podcasts] + [''])
139 return HttpResponse(s, mimetype='text/plain')
141 elif format == 'opml':
142 podcasts = map(get_podcast, obj_list)
143 exporter = Exporter(title)
144 opml = exporter.generate(podcasts)
145 return HttpResponse(opml, mimetype='text/xml')
147 elif format == 'json':
148 objs = map(json_map, obj_list)
149 return JsonResponse(objs)
151 elif format == 'jsonp':
152 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
154 if not jsonp_padding:
155 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
157 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
158 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
160 objs = map(json_map, obj_list)
161 return JsonResponse(objs, jsonp_padding=jsonp_padding)
163 elif format == 'xml':
164 if None in (xml_template, request):
165 return HttpResponseBadRequest('XML is not a valid format for this request')
167 podcasts = map(json_map, obj_list)
168 template_args.update({'podcasts': podcasts})
170 return render(request, xml_template, template_args,
171 content_type='application/xml')
173 else:
174 return None
177 def get_subscriptions(user, device_uid, user_agent=None):
178 device = get_device(user, device_uid, user_agent)
179 return device.get_subscribed_podcasts()
182 def parse_subscription(raw_post_data, format):
183 if format == 'txt':
184 urls = raw_post_data.split('\n')
186 elif format == 'opml':
187 begin = raw_post_data.find('<?xml')
188 end = raw_post_data.find('</opml>') + 7
189 i = Importer(content=raw_post_data[begin:end])
190 urls = [p['url'] for p in i.items]
192 elif format == 'json':
193 begin = raw_post_data.find('[')
194 end = raw_post_data.find(']') + 1
195 urls = json.loads(raw_post_data[begin:end])
197 else:
198 return []
201 urls = sanitize_urls(urls)
202 urls = filter(None, urls)
203 urls = set(urls)
204 return urls
207 def set_subscriptions(urls, user, device_uid, user_agent):
209 device = get_device(user, device_uid, user_agent, undelete=True)
211 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
212 new = [p for p in urls if p not in subscriptions.keys()]
213 rem = [p for p in subscriptions.keys() if p not in urls]
215 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
217 for r in rem:
218 subscriber.add_action(r, 'unsubscribe')
220 for n in new:
221 subscriber.add_action(n, 'subscribe')
223 try:
224 errors = subscriber.execute()
225 except BulkException as be:
226 for err in be.errors:
227 log('Simple API: %(username)s: Updating subscription for '
228 '%(podcast_url)s on %(device_uid)s failed: '
229 '%(error)s (%(reason)s)'.format(username=user.username,
230 podcast_url=err.doc, device_uid=device.uid,
231 error=err.error, reason=err.reason)
234 # Only an empty response is a successful response
235 return HttpResponse('', mimetype='text/plain')
238 @check_format
239 @allowed_methods(['GET'])
240 @cache_page(60 * 60)
241 def toplist(request, count, format):
242 count = parse_range(count, 1, 100, 100)
244 toplist = PodcastToplist()
245 entries = toplist[:count]
246 domain = RequestSite(request).domain
248 try:
249 scale = int(request.GET.get('scale_logo', 64))
250 except (TypeError, ValueError):
251 return HttpResponseBadRequest('scale_logo has to be a numeric value')
253 if scale not in range(1, 257):
254 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
257 def get_podcast(t):
258 old_pos, podcast = t
259 return podcast.get_podcast()
261 def json_map(t):
262 old_pos, podcast = t
263 podcast.old_pos = old_pos
265 p = podcast_data(podcast, domain, scale)
266 p.update(dict(
267 subscribers = podcast.subscriber_count(),
268 subscribers_last_week = podcast.prev_subscriber_count(),
269 position_last_week = podcast.old_pos,
271 return p
273 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
274 return format_podcast_list(entries,
275 format,
276 title,
277 get_podcast=get_podcast,
278 json_map=json_map,
279 jsonp_padding=request.GET.get('jsonp', ''),
280 xml_template='podcasts.xml',
281 request=request,
285 @check_format
286 @cache_page(60 * 60)
287 @allowed_methods(['GET'])
288 def search(request, format):
290 NUM_RESULTS = 20
292 query = request.GET.get('q', '').encode('utf-8')
294 try:
295 scale = int(request.GET.get('scale_logo', 64))
296 except (TypeError, ValueError):
297 return HttpResponseBadRequest('scale_logo has to be a numeric value')
299 if scale not in range(1, 257):
300 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
302 if not query:
303 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
305 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
307 title = _('gpodder.net - Search')
308 domain = RequestSite(request).domain
309 p_data = lambda p: podcast_data(p, domain, scale)
310 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
313 @require_valid_user
314 @check_format
315 @never_cache
316 @allowed_methods(['GET'])
317 def suggestions(request, count, format):
318 count = parse_range(count, 1, 100, 100)
320 suggestion_obj = suggestions_for_user(request.user)
321 suggestions = suggestion_obj.get_podcasts(count)
322 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
323 domain = RequestSite(request).domain
324 p_data = lambda p: podcast_data(p, domain)
325 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
328 @check_format
329 @allowed_methods(['GET'])
330 @cache_page(60 * 60)
331 def example_podcasts(request, format):
333 podcasts = cache.get('example-podcasts', None)
335 try:
336 scale = int(request.GET.get('scale_logo', 64))
337 except (TypeError, ValueError):
338 return HttpResponseBadRequest('scale_logo has to be a numeric value')
340 if scale not in range(1, 257):
341 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
344 if not podcasts:
346 try:
347 examples = ExamplePodcasts.get('example_podcasts')
348 ids = examples.podcast_ids
349 podcasts = podcasts_by_id(ids)
350 cache.set('example-podcasts', podcasts)
352 except ResourceNotFound:
353 podcasts = []
355 title = 'gPodder Podcast Directory'
356 domain = RequestSite(request).domain
357 p_data = lambda p: podcast_data(p, domain, scale)
358 return format_podcast_list(
359 podcasts,
360 format,
361 title,
362 json_map=p_data,
363 xml_template='podcasts.xml',
364 request=request,