fix parameter name to render()
[mygpo.git] / mygpo / api / simple.py
blob372e9eb2869bfded160d9b12e3065d69903f3a89
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
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.json import json
51 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
53 def check_format(fn):
54 @wraps(fn)
55 def tmp(request, format, *args, **kwargs):
56 if not format in ALLOWED_FORMATS:
57 return HttpResponseBadRequest('Invalid format')
59 return fn(request, *args, format=format, **kwargs)
60 return tmp
63 @csrf_exempt
64 @require_valid_user
65 @check_username
66 @check_format
67 @never_cache
68 @allowed_methods(['GET', 'PUT', 'POST'])
69 def subscriptions(request, username, device_uid, format):
71 user_agent = request.META.get('HTTP_USER_AGENT', '')
73 if request.method == 'GET':
74 title = _('%(username)s\'s Subscription List') % {'username': username}
75 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
76 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
78 elif request.method in ('PUT', 'POST'):
79 subscriptions = parse_subscription(request.raw_post_data, format)
80 return set_subscriptions(subscriptions, request.user, device_uid,
81 user_agent)
84 @csrf_exempt
85 @require_valid_user
86 @check_username
87 @check_format
88 @never_cache
89 @allowed_methods(['GET'])
90 def all_subscriptions(request, username, format):
92 try:
93 scale = int(request.GET.get('scale_logo', 64))
94 except (TypeError, ValueError):
95 return HttpResponseBadRequest('scale_logo has to be a numeric value')
97 if scale not in range(1, 257):
98 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
101 subscriptions = request.user.get_subscribed_podcasts()
102 title = _('%(username)s\'s Subscription List') % {'username': username}
103 domain = RequestSite(request).domain
104 p_data = lambda p: podcast_data(p, domain, scale)
105 return format_podcast_list(subscriptions, format, title,
106 json_map=p_data, xml_template='podcasts.xml', request=request)
109 def format_podcast_list(obj_list, format, title, get_podcast=None,
110 json_map=lambda x: x.url, jsonp_padding=None,
111 xml_template=None, request=None, template_args={}):
113 Formats a list of podcasts for use in a API response
115 obj_list is a list of podcasts or objects that contain podcasts
116 format is one if txt, opml or json
117 title is a label of the list
118 if obj_list is a list of objects containing podcasts, get_podcast is the
119 function used to get the podcast out of the each of these objects
120 json_map is a function returning the contents of an object (from obj_list)
121 that should be contained in the result (only used for format='json')
124 def default_get_podcast(p):
125 return p.get_podcast()
127 get_podcast = get_podcast or default_get_podcast
129 if format == 'txt':
130 podcasts = map(get_podcast, obj_list)
131 s = '\n'.join([p.url for p in podcasts] + [''])
132 return HttpResponse(s, mimetype='text/plain')
134 elif format == 'opml':
135 podcasts = map(get_podcast, obj_list)
136 exporter = Exporter(title)
137 opml = exporter.generate(podcasts)
138 return HttpResponse(opml, mimetype='text/xml')
140 elif format == 'json':
141 objs = map(json_map, obj_list)
142 return JsonResponse(objs)
144 elif format == 'jsonp':
145 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
147 if not jsonp_padding:
148 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
150 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
151 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
153 objs = map(json_map, obj_list)
154 return JsonResponse(objs, jsonp_padding=jsonp_padding)
156 elif format == 'xml':
157 if None in (xml_template, request):
158 return HttpResponseBadRequest('XML is not a valid format for this request')
160 podcasts = map(json_map, obj_list)
161 template_args.update({'podcasts': podcasts})
163 return render(request, xml_template, template_args,
164 content_type='application/xml')
166 else:
167 return None
170 def get_subscriptions(user, device_uid, user_agent=None):
171 device = get_device(user, device_uid, user_agent)
172 return device.get_subscribed_podcasts()
175 def parse_subscription(raw_post_data, format):
176 if format == 'txt':
177 urls = raw_post_data.split('\n')
179 elif format == 'opml':
180 begin = raw_post_data.find('<?xml')
181 end = raw_post_data.find('</opml>') + 7
182 i = Importer(content=raw_post_data[begin:end])
183 urls = [p['url'] for p in i.items]
185 elif format == 'json':
186 begin = raw_post_data.find('[')
187 end = raw_post_data.find(']') + 1
188 urls = json.loads(raw_post_data[begin:end])
190 else:
191 return []
194 urls = sanitize_urls(urls)
195 urls = filter(None, urls)
196 urls = set(urls)
197 return urls
200 def set_subscriptions(urls, user, device_uid, user_agent):
202 device = get_device(user, device_uid, user_agent, undelete=True)
204 old = [p.url for p in device.get_subscribed_podcasts()]
205 new = [p for p in urls if p not in old]
206 rem = [p for p in old if p not in urls]
208 for r in rem:
209 p = Podcast.for_url(r, create=True)
210 try:
211 p.unsubscribe(user, device)
212 except Exception as e:
213 log('Simple API: %(username)s: Could not remove subscription for podcast %(podcast_url)s on device %(device_id)s: %(exception)s' %
214 {'username': user.username, 'podcast_url': r, 'device_id': device.id, 'exception': e})
216 for n in new:
217 p = Podcast.for_url(n, create=True)
218 try:
219 p.subscribe(user, device)
220 except Exception as e:
221 log('Simple API: %(username)s: Could not add subscription for podcast %(podcast_url)s on device %(device_id)s: %(exception)s' %
222 {'username': user.username, 'podcast_url': n, 'device_id': device.id, 'exception': e})
224 # Only an empty response is a successful response
225 return HttpResponse('', mimetype='text/plain')
228 @check_format
229 @allowed_methods(['GET'])
230 @cache_page(60 * 60)
231 def toplist(request, count, format):
232 count = parse_range(count, 1, 100, 100)
234 toplist = PodcastToplist()
235 entries = toplist[:count]
236 domain = RequestSite(request).domain
238 try:
239 scale = int(request.GET.get('scale_logo', 64))
240 except (TypeError, ValueError):
241 return HttpResponseBadRequest('scale_logo has to be a numeric value')
243 if scale not in range(1, 257):
244 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
247 def get_podcast(t):
248 old_pos, podcast = t
249 return podcast.get_podcast()
251 def json_map(t):
252 old_pos, podcast = t
253 podcast.old_pos = old_pos
255 p = podcast_data(podcast, domain, scale)
256 p.update(dict(
257 subscribers= podcast.subscriber_count(),
258 subscribers_last_week= podcast.prev_subscriber_count(),
259 position_last_week= podcast.old_pos,
261 return p
263 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
264 return format_podcast_list(entries,
265 format,
266 title,
267 get_podcast=get_podcast,
268 json_map=json_map,
269 jsonp_padding=request.GET.get('jsonp', ''),
270 xml_template='podcasts.xml',
271 request=request,
275 @check_format
276 @cache_page(60 * 60)
277 @allowed_methods(['GET'])
278 def search(request, format):
280 NUM_RESULTS = 20
282 query = request.GET.get('q', '').encode('utf-8')
284 try:
285 scale = int(request.GET.get('scale_logo', 64))
286 except (TypeError, ValueError):
287 return HttpResponseBadRequest('scale_logo has to be a numeric value')
289 if scale not in range(1, 257):
290 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
292 if not query:
293 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
295 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
297 title = _('gpodder.net - Search')
298 domain = RequestSite(request).domain
299 p_data = lambda p: podcast_data(p, domain, scale)
300 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
303 @require_valid_user
304 @check_format
305 @never_cache
306 @allowed_methods(['GET'])
307 def suggestions(request, count, format):
308 count = parse_range(count, 1, 100, 100)
310 suggestion_obj = Suggestions.for_user(request.user)
311 suggestions = suggestion_obj.get_podcasts(count)
312 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
313 domain = RequestSite(request).domain
314 p_data = lambda p: podcast_data(p, domain)
315 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
318 @check_format
319 @allowed_methods(['GET'])
320 @cache_page(60 * 60)
321 def example_podcasts(request, format):
323 podcasts = cache.get('example-podcasts', None)
325 try:
326 scale = int(request.GET.get('scale_logo', 64))
327 except (TypeError, ValueError):
328 return HttpResponseBadRequest('scale_logo has to be a numeric value')
330 if scale not in range(1, 257):
331 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
334 if not podcasts:
336 try:
337 examples = ExamplePodcasts.get('example_podcasts')
338 ids = examples.podcast_ids
339 podcasts = list(Podcast.get_multi(ids))
340 cache.set('example-podcasts', podcasts)
342 except ResourceNotFound:
343 podcasts = []
345 title = 'gPodder Podcast Directory'
346 domain = RequestSite(request).domain
347 p_data = lambda p: podcast_data(p, domain, scale)
348 return format_podcast_list(
349 podcasts,
350 format,
351 title,
352 json_map=p_data,
353 xml_template='podcasts.xml',
354 request=request,