move all User and EpisodetUserState db queries into separate module
[mygpo.git] / mygpo / api / simple.py
blobc397c0f9120d6d6bb025b03293f4cb7e089df270
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.couch import BulkException
36 from mygpo.core import models
37 from mygpo.core.models import Podcast
38 from mygpo.users.models import Suggestions
39 from mygpo.api.opml import Exporter, Importer
40 from mygpo.api.httpresponse import JsonResponse
41 from mygpo.api.sanitizing import sanitize_urls
42 from mygpo.directory.toplist import PodcastToplist
43 from mygpo.directory.models import ExamplePodcasts
44 from mygpo.api.advanced.directory import podcast_data
45 from mygpo.directory.search import search_podcasts
46 from mygpo.log import log
47 from mygpo.decorators import allowed_methods
48 from mygpo.utils import parse_range
49 from mygpo.json import json
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 subscriptions = parse_subscription(request.raw_post_data, format)
82 return set_subscriptions(subscriptions, request.user, device_uid,
83 user_agent)
86 @csrf_exempt
87 @require_valid_user
88 @check_username
89 @check_format
90 @never_cache
91 @allowed_methods(['GET'])
92 def all_subscriptions(request, username, format):
94 try:
95 scale = int(request.GET.get('scale_logo', 64))
96 except (TypeError, ValueError):
97 return HttpResponseBadRequest('scale_logo has to be a numeric value')
99 if scale not in range(1, 257):
100 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
103 subscriptions = request.user.get_subscribed_podcasts()
104 title = _('%(username)s\'s Subscription List') % {'username': username}
105 domain = RequestSite(request).domain
106 p_data = lambda p: podcast_data(p, domain, scale)
107 return format_podcast_list(subscriptions, format, title,
108 json_map=p_data, xml_template='podcasts.xml', request=request)
111 def format_podcast_list(obj_list, format, title, get_podcast=None,
112 json_map=lambda x: x.url, jsonp_padding=None,
113 xml_template=None, request=None, template_args={}):
115 Formats a list of podcasts for use in a API response
117 obj_list is a list of podcasts or objects that contain podcasts
118 format is one if txt, opml or json
119 title is a label of the list
120 if obj_list is a list of objects containing podcasts, get_podcast is the
121 function used to get the podcast out of the each of these objects
122 json_map is a function returning the contents of an object (from obj_list)
123 that should be contained in the result (only used for format='json')
126 def default_get_podcast(p):
127 return p.get_podcast()
129 get_podcast = get_podcast or default_get_podcast
131 if format == 'txt':
132 podcasts = map(get_podcast, obj_list)
133 s = '\n'.join([p.url for p in podcasts] + [''])
134 return HttpResponse(s, mimetype='text/plain')
136 elif format == 'opml':
137 podcasts = map(get_podcast, obj_list)
138 exporter = Exporter(title)
139 opml = exporter.generate(podcasts)
140 return HttpResponse(opml, mimetype='text/xml')
142 elif format == 'json':
143 objs = map(json_map, obj_list)
144 return JsonResponse(objs)
146 elif format == 'jsonp':
147 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
149 if not jsonp_padding:
150 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
152 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
153 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
155 objs = map(json_map, obj_list)
156 return JsonResponse(objs, jsonp_padding=jsonp_padding)
158 elif format == 'xml':
159 if None in (xml_template, request):
160 return HttpResponseBadRequest('XML is not a valid format for this request')
162 podcasts = map(json_map, obj_list)
163 template_args.update({'podcasts': podcasts})
165 return render(request, xml_template, template_args,
166 content_type='application/xml')
168 else:
169 return None
172 def get_subscriptions(user, device_uid, user_agent=None):
173 device = get_device(user, device_uid, user_agent)
174 return device.get_subscribed_podcasts()
177 def parse_subscription(raw_post_data, format):
178 if format == 'txt':
179 urls = raw_post_data.split('\n')
181 elif format == 'opml':
182 begin = raw_post_data.find('<?xml')
183 end = raw_post_data.find('</opml>') + 7
184 i = Importer(content=raw_post_data[begin:end])
185 urls = [p['url'] for p in i.items]
187 elif format == 'json':
188 begin = raw_post_data.find('[')
189 end = raw_post_data.find(']') + 1
190 urls = json.loads(raw_post_data[begin:end])
192 else:
193 return []
196 urls = sanitize_urls(urls)
197 urls = filter(None, urls)
198 urls = set(urls)
199 return urls
202 def set_subscriptions(urls, user, device_uid, user_agent):
204 device = get_device(user, device_uid, user_agent, undelete=True)
206 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
207 new = [p for p in urls if p not in subscriptions.keys()]
208 rem = [p for p in subscriptions.keys() if p not in urls]
210 subscriber = BulkSubscribe(user, device, podcasts=subscriptions)
212 for r in rem:
213 subscriber.add_action(r, 'unsubscribe')
215 for n in new:
216 subscriber.add_action(n, 'subscribe')
218 try:
219 errors = subscriber.execute()
220 except BulkException as be:
221 for err in be.errors:
222 log('Simple API: %(username)s: Updating subscription for '
223 '%(podcast_url)s on %(device_uid)s failed: '
224 '%(error)s (%(reason)s)'.format(username=user.username,
225 podcast_url=err.doc, device_uid=device.uid,
226 error=err.error, reason=err.reason)
229 # Only an empty response is a successful response
230 return HttpResponse('', mimetype='text/plain')
233 @check_format
234 @allowed_methods(['GET'])
235 @cache_page(60 * 60)
236 def toplist(request, count, format):
237 count = parse_range(count, 1, 100, 100)
239 toplist = PodcastToplist()
240 entries = toplist[:count]
241 domain = RequestSite(request).domain
243 try:
244 scale = int(request.GET.get('scale_logo', 64))
245 except (TypeError, ValueError):
246 return HttpResponseBadRequest('scale_logo has to be a numeric value')
248 if scale not in range(1, 257):
249 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
252 def get_podcast(t):
253 old_pos, podcast = t
254 return podcast.get_podcast()
256 def json_map(t):
257 old_pos, podcast = t
258 podcast.old_pos = old_pos
260 p = podcast_data(podcast, domain, scale)
261 p.update(dict(
262 subscribers= podcast.subscriber_count(),
263 subscribers_last_week= podcast.prev_subscriber_count(),
264 position_last_week= podcast.old_pos,
266 return p
268 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
269 return format_podcast_list(entries,
270 format,
271 title,
272 get_podcast=get_podcast,
273 json_map=json_map,
274 jsonp_padding=request.GET.get('jsonp', ''),
275 xml_template='podcasts.xml',
276 request=request,
280 @check_format
281 @cache_page(60 * 60)
282 @allowed_methods(['GET'])
283 def search(request, format):
285 NUM_RESULTS = 20
287 query = request.GET.get('q', '').encode('utf-8')
289 try:
290 scale = int(request.GET.get('scale_logo', 64))
291 except (TypeError, ValueError):
292 return HttpResponseBadRequest('scale_logo has to be a numeric value')
294 if scale not in range(1, 257):
295 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
297 if not query:
298 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
300 results, total = search_podcasts(q=query, limit=NUM_RESULTS)
302 title = _('gpodder.net - Search')
303 domain = RequestSite(request).domain
304 p_data = lambda p: podcast_data(p, domain, scale)
305 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
308 @require_valid_user
309 @check_format
310 @never_cache
311 @allowed_methods(['GET'])
312 def suggestions(request, count, format):
313 count = parse_range(count, 1, 100, 100)
315 suggestion_obj = suggestions_for_user(request.user)
316 suggestions = suggestion_obj.get_podcasts(count)
317 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
318 domain = RequestSite(request).domain
319 p_data = lambda p: podcast_data(p, domain)
320 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
323 @check_format
324 @allowed_methods(['GET'])
325 @cache_page(60 * 60)
326 def example_podcasts(request, format):
328 podcasts = cache.get('example-podcasts', None)
330 try:
331 scale = int(request.GET.get('scale_logo', 64))
332 except (TypeError, ValueError):
333 return HttpResponseBadRequest('scale_logo has to be a numeric value')
335 if scale not in range(1, 257):
336 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
339 if not podcasts:
341 try:
342 examples = ExamplePodcasts.get('example_podcasts')
343 ids = examples.podcast_ids
344 podcasts = podcasts_by_id(ids)
345 cache.set('example-podcasts', podcasts)
347 except ResourceNotFound:
348 podcasts = []
350 title = 'gPodder Podcast Directory'
351 domain = RequestSite(request).domain
352 p_data = lambda p: podcast_data(p, domain, scale)
353 return format_podcast_list(
354 podcasts,
355 format,
356 title,
357 json_map=p_data,
358 xml_template='podcasts.xml',
359 request=request,