8f15a2855c9544d48400b17ad09069d6a7bf5430
[mygpo.git] / mygpo / api / simple.py
blob8f15a2855c9544d48400b17ad09069d6a7bf5430
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.decorators import allowed_methods
46 from mygpo.utils import parse_range
47 from mygpo.core.json import json, JSONDecodeError
48 from mygpo.db.couchdb import BulkException
49 from mygpo.db.couchdb.podcast import podcasts_by_id
50 from mygpo.db.couchdb.user import suggestions_for_user
52 import logging
53 logger = logging.getLogger(__name__)
56 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
58 def check_format(fn):
59 @wraps(fn)
60 def tmp(request, format, *args, **kwargs):
61 if not format in ALLOWED_FORMATS:
62 return HttpResponseBadRequest('Invalid format')
64 return fn(request, *args, format=format, **kwargs)
65 return tmp
68 @csrf_exempt
69 @require_valid_user
70 @check_username
71 @check_format
72 @never_cache
73 @allowed_methods(['GET', 'PUT', 'POST'])
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 def all_subscriptions(request, username, format):
102 try:
103 scale = int(request.GET.get('scale_logo', 64))
104 except (TypeError, ValueError):
105 return HttpResponseBadRequest('scale_logo has to be a numeric value')
107 if scale not in range(1, 257):
108 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
111 subscriptions = request.user.get_subscribed_podcasts()
112 title = _('%(username)s\'s Subscription List') % {'username': username}
113 domain = RequestSite(request).domain
114 p_data = lambda p: podcast_data(p, domain, scale)
115 return format_podcast_list(subscriptions, format, title,
116 json_map=p_data, xml_template='podcasts.xml', request=request)
119 def format_podcast_list(obj_list, format, title, get_podcast=None,
120 json_map=lambda x: x.url, jsonp_padding=None,
121 xml_template=None, request=None, template_args={}):
123 Formats a list of podcasts for use in a API response
125 obj_list is a list of podcasts or objects that contain podcasts
126 format is one if txt, opml or json
127 title is a label of the list
128 if obj_list is a list of objects containing podcasts, get_podcast is the
129 function used to get the podcast out of the each of these objects
130 json_map is a function returning the contents of an object (from obj_list)
131 that should be contained in the result (only used for format='json')
134 def default_get_podcast(p):
135 return p.get_podcast()
137 get_podcast = get_podcast or default_get_podcast
139 if format == 'txt':
140 podcasts = map(get_podcast, obj_list)
141 s = '\n'.join([p.url for p in podcasts] + [''])
142 return HttpResponse(s, mimetype='text/plain')
144 elif format == 'opml':
145 podcasts = map(get_podcast, obj_list)
146 exporter = Exporter(title)
147 opml = exporter.generate(podcasts)
148 return HttpResponse(opml, mimetype='text/xml')
150 elif format == 'json':
151 objs = map(json_map, obj_list)
152 return JsonResponse(objs)
154 elif format == 'jsonp':
155 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
157 if not jsonp_padding:
158 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
160 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
161 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
163 objs = map(json_map, obj_list)
164 return JsonResponse(objs, jsonp_padding=jsonp_padding)
166 elif format == 'xml':
167 if None in (xml_template, request):
168 return HttpResponseBadRequest('XML is not a valid format for this request')
170 podcasts = map(json_map, obj_list)
171 template_args.update({'podcasts': podcasts})
173 return render(request, xml_template, template_args,
174 content_type='application/xml')
176 else:
177 return None
180 def get_subscriptions(user, device_uid, user_agent=None):
181 device = get_device(user, device_uid, user_agent)
182 return device.get_subscribed_podcasts()
185 def parse_subscription(raw_post_data, format):
186 if format == 'txt':
187 urls = raw_post_data.split('\n')
189 elif format == 'opml':
190 begin = raw_post_data.find('<?xml')
191 end = raw_post_data.find('</opml>') + 7
192 i = Importer(content=raw_post_data[begin:end])
193 urls = [p['url'] for p in i.items]
195 elif format == 'json':
196 begin = raw_post_data.find('[')
197 end = raw_post_data.find(']') + 1
198 urls = json.loads(raw_post_data[begin:end])
200 else:
201 return []
204 urls = sanitize_urls(urls)
205 urls = filter(None, urls)
206 urls = set(urls)
207 return urls
210 def set_subscriptions(urls, user, device_uid, user_agent):
212 device = get_device(user, device_uid, user_agent, undelete=True)
214 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
215 new = [p for p in urls if p not in subscriptions.keys()]
216 rem = [p for p in subscriptions.keys() if p not in urls]
218 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
220 for r in rem:
221 subscriber.add_action(r, 'unsubscribe')
223 for n in new:
224 subscriber.add_action(n, 'subscribe')
226 try:
227 errors = subscriber.execute()
228 except BulkException as be:
229 for err in be.errors:
230 logger.warn('Simple API: %(username)s: Updating subscription for '
231 '%(podcast_url)s on %(device_uid)s failed: '
232 '%(error)s (%(reason)s)'.format(username=user.username,
233 podcast_url=err.doc, device_uid=device.uid,
234 error=err.error, reason=err.reason)
237 # Only an empty response is a successful response
238 return HttpResponse('', mimetype='text/plain')
241 @check_format
242 @allowed_methods(['GET'])
243 @cache_page(60 * 60)
244 def toplist(request, count, format):
245 count = parse_range(count, 1, 100, 100)
247 toplist = PodcastToplist()
248 entries = toplist[:count]
249 domain = RequestSite(request).domain
251 try:
252 scale = int(request.GET.get('scale_logo', 64))
253 except (TypeError, ValueError):
254 return HttpResponseBadRequest('scale_logo has to be a numeric value')
256 if scale not in range(1, 257):
257 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
260 def get_podcast(t):
261 old_pos, podcast = t
262 return podcast.get_podcast()
264 def json_map(t):
265 old_pos, podcast = t
266 podcast.old_pos = old_pos
268 p = podcast_data(podcast, domain, scale)
269 p.update(dict(
270 subscribers = podcast.subscriber_count(),
271 subscribers_last_week = podcast.prev_subscriber_count(),
272 position_last_week = podcast.old_pos,
274 return p
276 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
277 return format_podcast_list(entries,
278 format,
279 title,
280 get_podcast=get_podcast,
281 json_map=json_map,
282 jsonp_padding=request.GET.get('jsonp', ''),
283 xml_template='podcasts.xml',
284 request=request,
288 @check_format
289 @cache_page(60 * 60)
290 @allowed_methods(['GET'])
291 def search(request, format):
293 NUM_RESULTS = 20
295 query = request.GET.get('q', '').encode('utf-8')
297 try:
298 scale = int(request.GET.get('scale_logo', 64))
299 except (TypeError, ValueError):
300 return HttpResponseBadRequest('scale_logo has to be a numeric value')
302 if scale not in range(1, 257):
303 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
305 if not query:
306 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
308 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
310 title = _('gpodder.net - Search')
311 domain = RequestSite(request).domain
312 p_data = lambda p: podcast_data(p, domain, scale)
313 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
316 @require_valid_user
317 @check_format
318 @never_cache
319 @allowed_methods(['GET'])
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 def example_podcasts(request, format):
336 podcasts = cache.get('example-podcasts', None)
338 try:
339 scale = int(request.GET.get('scale_logo', 64))
340 except (TypeError, ValueError):
341 return HttpResponseBadRequest('scale_logo has to be a numeric value')
343 if scale not in range(1, 257):
344 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
347 if not podcasts:
349 try:
350 examples = ExamplePodcasts.get('example_podcasts')
351 ids = examples.podcast_ids
352 podcasts = podcasts_by_id(ids)
353 cache.set('example-podcasts', podcasts)
355 except ResourceNotFound:
356 podcasts = []
358 title = 'gPodder Podcast Directory'
359 domain = RequestSite(request).domain
360 p_data = lambda p: podcast_data(p, domain, scale)
361 return format_podcast_list(
362 podcasts,
363 format,
364 title,
365 json_map=p_data,
366 xml_template='podcasts.xml',
367 request=request,