[Migration] remove podcasts_by_id
[mygpo.git] / mygpo / api / simple.py
blob5d93329d82eff9091d44003c47f767fb82b31237
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.toplist import PodcastToplist
40 from mygpo.directory.models import ExamplePodcasts
41 from mygpo.api.advanced.directory import podcast_data
42 from mygpo.directory.search import search_podcasts
43 from mygpo.decorators import allowed_methods, cors_origin
44 from mygpo.utils import parse_range, normalize_feed_url
45 from mygpo.core.json import json, JSONDecodeError
46 from mygpo.db.couchdb import BulkException
47 from mygpo.db.couchdb.user import suggestions_for_user
49 import logging
50 logger = logging.getLogger(__name__)
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 @cors_origin()
72 def subscriptions(request, username, device_uid, format):
74 user_agent = request.META.get('HTTP_USER_AGENT', '')
76 if request.method == 'GET':
77 title = _('%(username)s\'s Subscription List') % {'username': username}
78 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
79 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
81 elif request.method in ('PUT', 'POST'):
82 try:
83 subscriptions = parse_subscription(request.body, format)
85 except JSONDecodeError as e:
86 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e))
88 return set_subscriptions(subscriptions, request.user, device_uid,
89 user_agent)
92 @csrf_exempt
93 @require_valid_user
94 @check_username
95 @check_format
96 @never_cache
97 @allowed_methods(['GET'])
98 @cors_origin()
99 def all_subscriptions(request, username, format):
101 try:
102 scale = int(request.GET.get('scale_logo', 64))
103 except (TypeError, ValueError):
104 return HttpResponseBadRequest('scale_logo has to be a numeric value')
106 if scale not in range(1, 257):
107 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
110 subscriptions = request.user.get_subscribed_podcasts()
111 title = _('%(username)s\'s Subscription List') % {'username': username}
112 domain = RequestSite(request).domain
113 p_data = lambda p: podcast_data(p, domain, scale)
114 return format_podcast_list(subscriptions, format, title,
115 json_map=p_data, xml_template='podcasts.xml', request=request)
118 def format_podcast_list(obj_list, format, title, get_podcast=None,
119 json_map=lambda x: x.url, jsonp_padding=None,
120 xml_template=None, request=None, template_args={}):
122 Formats a list of podcasts for use in a API response
124 obj_list is a list of podcasts or objects that contain podcasts
125 format is one if txt, opml or json
126 title is a label of the list
127 if obj_list is a list of objects containing podcasts, get_podcast is the
128 function used to get the podcast out of the each of these objects
129 json_map is a function returning the contents of an object (from obj_list)
130 that should be contained in the result (only used for format='json')
133 def default_get_podcast(p):
134 return p.get_podcast()
136 get_podcast = get_podcast or default_get_podcast
138 if format == 'txt':
139 podcasts = map(get_podcast, obj_list)
140 s = '\n'.join([p.url for p in podcasts] + [''])
141 return HttpResponse(s, mimetype='text/plain')
143 elif format == 'opml':
144 podcasts = map(get_podcast, obj_list)
145 exporter = Exporter(title)
146 opml = exporter.generate(podcasts)
147 return HttpResponse(opml, mimetype='text/xml')
149 elif format == 'json':
150 objs = map(json_map, obj_list)
151 return JsonResponse(objs)
153 elif format == 'jsonp':
154 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
156 if not jsonp_padding:
157 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
159 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
160 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
162 objs = map(json_map, obj_list)
163 return JsonResponse(objs, jsonp_padding=jsonp_padding)
165 elif format == 'xml':
166 if None in (xml_template, request):
167 return HttpResponseBadRequest('XML is not a valid format for this request')
169 podcasts = map(json_map, obj_list)
170 template_args.update({'podcasts': podcasts})
172 return render(request, xml_template, template_args,
173 content_type='application/xml')
175 else:
176 return None
179 def get_subscriptions(user, device_uid, user_agent=None):
180 device = get_device(user, device_uid, user_agent)
181 return device.get_subscribed_podcasts()
184 def parse_subscription(raw_post_data, format):
185 if format == 'txt':
186 urls = raw_post_data.split('\n')
188 elif format == 'opml':
189 begin = raw_post_data.find('<?xml')
190 end = raw_post_data.find('</opml>') + 7
191 i = Importer(content=raw_post_data[begin:end])
192 urls = [p['url'] for p in i.items]
194 elif format == 'json':
195 begin = raw_post_data.find('[')
196 end = raw_post_data.find(']') + 1
197 urls = json.loads(raw_post_data[begin:end])
199 else:
200 return []
203 urls = map(normalize_feed_url, urls)
204 urls = filter(None, urls)
205 urls = set(urls)
206 return urls
209 def set_subscriptions(urls, user, device_uid, user_agent):
211 device = get_device(user, device_uid, user_agent, undelete=True)
213 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
214 new = [p for p in urls if p not in subscriptions.keys()]
215 rem = [p for p in subscriptions.keys() if p not in urls]
217 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
219 for r in rem:
220 subscriber.add_action(r, 'unsubscribe')
222 for n in new:
223 subscriber.add_action(n, 'subscribe')
225 try:
226 errors = subscriber.execute()
227 except BulkException as be:
228 for err in be.errors:
229 logger.warn('Simple API: %(username)s: Updating subscription for '
230 '%(podcast_url)s on %(device_uid)s failed: '
231 '%(error)s (%(reason)s)'.format(username=user.username,
232 podcast_url=err.doc, device_uid=device.uid,
233 error=err.error, reason=err.reason)
236 # Only an empty response is a successful response
237 return HttpResponse('', mimetype='text/plain')
240 @check_format
241 @allowed_methods(['GET'])
242 @cache_page(60 * 60)
243 @cors_origin()
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 @cors_origin()
292 def search(request, format):
294 NUM_RESULTS = 20
296 query = request.GET.get('q', '').encode('utf-8')
298 try:
299 scale = int(request.GET.get('scale_logo', 64))
300 except (TypeError, ValueError):
301 return HttpResponseBadRequest('scale_logo has to be a numeric value')
303 if scale not in range(1, 257):
304 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
306 if not query:
307 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
309 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
311 title = _('gpodder.net - Search')
312 domain = RequestSite(request).domain
313 p_data = lambda p: podcast_data(p, domain, scale)
314 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
317 @require_valid_user
318 @check_format
319 @never_cache
320 @allowed_methods(['GET'])
321 @cors_origin()
322 def suggestions(request, count, format):
323 count = parse_range(count, 1, 100, 100)
325 suggestion_obj = suggestions_for_user(request.user)
326 suggestions = suggestion_obj.get_podcasts(count)
327 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
328 domain = RequestSite(request).domain
329 p_data = lambda p: podcast_data(p, domain)
330 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
333 @check_format
334 @allowed_methods(['GET'])
335 @cache_page(60 * 60)
336 @cors_origin()
337 def example_podcasts(request, format):
339 podcasts = cache.get('example-podcasts', None)
341 try:
342 scale = int(request.GET.get('scale_logo', 64))
343 except (TypeError, ValueError):
344 return HttpResponseBadRequest('scale_logo has to be a numeric value')
346 if scale not in range(1, 257):
347 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
350 if not podcasts:
352 try:
353 examples = ExamplePodcasts.get('example_podcasts')
354 ids = examples.podcast_ids
355 podcasts = Podcasts.objects.filter(id__in=ids)
356 cache.set('example-podcasts', podcasts)
358 except ResourceNotFound:
359 podcasts = []
361 title = 'gPodder Podcast Directory'
362 domain = RequestSite(request).domain
363 p_data = lambda p: podcast_data(p, domain, scale)
364 return format_podcast_list(
365 podcasts,
366 format,
367 title,
368 json_map=p_data,
369 xml_template='podcasts.xml',
370 request=request,