[API] normalize URLs after removing empty entries
[mygpo.git] / mygpo / api / simple.py
blob4c3e95934af460ce20114136f1ca14c186cf1fb7
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 django.shortcuts import render
23 from django.core.cache import cache
24 from django.http import HttpResponse, HttpResponseBadRequest
25 from django.views.decorators.cache import cache_page
26 from django.views.decorators.csrf import csrf_exempt
27 from django.views.decorators.cache import never_cache
28 from django.contrib.sites.models import RequestSite
29 from django.utils.translation import ugettext as _
31 from mygpo.api.basic_auth import require_valid_user, check_username
32 from mygpo.api.backend import get_device
33 from mygpo.podcasts.models import Podcast
34 from mygpo.api.opml import Exporter, Importer
35 from mygpo.api.httpresponse import JsonResponse
36 from mygpo.directory.models import ExamplePodcast
37 from mygpo.api.advanced.directory import podcast_data
38 from mygpo.subscriptions import get_subscribed_podcasts, subscribe, unsubscribe
39 from mygpo.directory.search import search_podcasts
40 from mygpo.decorators import allowed_methods, cors_origin
41 from mygpo.utils import parse_range, normalize_feed_url
42 from mygpo.core.json import json, JSONDecodeError
44 import logging
45 logger = logging.getLogger(__name__)
48 ALLOWED_FORMATS = ('txt', 'opml', 'json', 'jsonp', 'xml')
50 def check_format(fn):
51 @wraps(fn)
52 def tmp(request, format, *args, **kwargs):
53 if not format in ALLOWED_FORMATS:
54 return HttpResponseBadRequest('Invalid format')
56 return fn(request, *args, format=format, **kwargs)
57 return tmp
60 @csrf_exempt
61 @require_valid_user
62 @check_username
63 @check_format
64 @never_cache
65 @allowed_methods(['GET', 'PUT', 'POST'])
66 @cors_origin()
67 def subscriptions(request, username, device_uid, format):
69 user_agent = request.META.get('HTTP_USER_AGENT', '')
71 if request.method == 'GET':
72 title = _('%(username)s\'s Subscription List') % {'username': username}
73 subscriptions = get_subscriptions(request.user, device_uid, user_agent)
74 return format_podcast_list(subscriptions, format, title, jsonp_padding=request.GET.get('jsonp'))
76 elif request.method in ('PUT', 'POST'):
77 try:
78 subscriptions = parse_subscription(request.body, format)
80 except JSONDecodeError as e:
81 return HttpResponseBadRequest('Unable to parse POST data: %s' % str(e))
83 return set_subscriptions(subscriptions, request.user, device_uid,
84 user_agent)
87 @csrf_exempt
88 @require_valid_user
89 @check_username
90 @check_format
91 @never_cache
92 @allowed_methods(['GET'])
93 @cors_origin()
94 def all_subscriptions(request, username, format):
96 try:
97 scale = int(request.GET.get('scale_logo', 64))
98 except (TypeError, ValueError):
99 return HttpResponseBadRequest('scale_logo has to be a numeric value')
101 if scale not in range(1, 257):
102 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
105 subscriptions = get_subscribed_podcasts(request.user)
106 title = _('%(username)s\'s Subscription List') % {'username': username}
107 domain = RequestSite(request).domain
108 p_data = lambda p: podcast_data(p, domain, scale)
109 return format_podcast_list(subscriptions, format, title,
110 json_map=p_data, xml_template='podcasts.xml', request=request)
113 def format_podcast_list(obj_list, format, title, get_podcast=None,
114 json_map=lambda x: x.url, jsonp_padding=None,
115 xml_template=None, request=None, template_args={}):
117 Formats a list of podcasts for use in a API response
119 obj_list is a list of podcasts or objects that contain podcasts
120 format is one if txt, opml or json
121 title is a label of the list
122 if obj_list is a list of objects containing podcasts, get_podcast is the
123 function used to get the podcast out of the each of these objects
124 json_map is a function returning the contents of an object (from obj_list)
125 that should be contained in the result (only used for format='json')
128 def default_get_podcast(p):
129 return p
131 get_podcast = get_podcast or default_get_podcast
133 if format == 'txt':
134 podcasts = map(get_podcast, obj_list)
135 s = '\n'.join([p.url for p in podcasts] + [''])
136 return HttpResponse(s, content_type='text/plain')
138 elif format == 'opml':
139 podcasts = map(get_podcast, obj_list)
140 exporter = Exporter(title)
141 opml = exporter.generate(podcasts)
142 return HttpResponse(opml, content_type='text/xml')
144 elif format == 'json':
145 objs = map(json_map, obj_list)
146 return JsonResponse(objs)
148 elif format == 'jsonp':
149 ALLOWED_FUNCNAME = string.letters + string.digits + '_'
151 if not jsonp_padding:
152 return HttpResponseBadRequest('For a JSONP response, specify the name of the callback function in the jsonp parameter')
154 if any(x not in ALLOWED_FUNCNAME for x in jsonp_padding):
155 return HttpResponseBadRequest('JSONP padding can only contain the characters %(char)s' % {'char': ALLOWED_FUNCNAME})
157 objs = map(json_map, obj_list)
158 return JsonResponse(objs, jsonp_padding=jsonp_padding)
160 elif format == 'xml':
161 if None in (xml_template, request):
162 return HttpResponseBadRequest('XML is not a valid format for this request')
164 podcasts = map(json_map, obj_list)
165 template_args.update({'podcasts': podcasts})
167 return render(request, xml_template, template_args,
168 content_type='application/xml')
170 else:
171 return None
174 def get_subscriptions(user, device_uid, user_agent=None):
175 device = get_device(user, device_uid, user_agent)
176 return device.get_subscribed_podcasts()
179 def parse_subscription(raw_post_data, format):
180 """ Parses the data according to the format """
181 if format == 'txt':
182 urls = raw_post_data.split('\n')
184 elif format == 'opml':
185 begin = raw_post_data.find('<?xml')
186 end = raw_post_data.find('</opml>') + 7
187 i = Importer(content=raw_post_data[begin:end])
188 urls = [p['url'] for p in i.items]
190 elif format == 'json':
191 begin = raw_post_data.find('[')
192 end = raw_post_data.find(']') + 1
193 urls = json.loads(raw_post_data[begin:end])
195 else:
196 return []
198 urls = filter(None, urls)
199 urls = map(normalize_feed_url, urls)
200 return urls
203 def set_subscriptions(urls, user, device_uid, user_agent):
205 device = get_device(user, device_uid, user_agent, undelete=True)
207 subscriptions = dict( (p.url, p) for p in device.get_subscribed_podcasts())
208 new = [p for p in urls if p not in subscriptions.keys()]
209 rem = [p for p in subscriptions.keys() if p not in urls]
211 remove_podcasts = Podcast.objects.filter(urls__url__in=rem)
212 for podcast in remove_podcasts:
213 unsubscribe(podcast, user, device)
215 for url in new:
216 podcast = Podcast.objects.get_or_create_for_url(url)
217 subscribe(podcast, user, device, url)
219 # Only an empty response is a successful response
220 return HttpResponse('', content_type='text/plain')
223 @check_format
224 @allowed_methods(['GET'])
225 @cache_page(60 * 60)
226 @cors_origin()
227 def toplist(request, count, format):
228 count = parse_range(count, 1, 100, 100)
230 entries = Podcast.objects.all().toplist()[:count]
231 domain = RequestSite(request).domain
233 try:
234 scale = int(request.GET.get('scale_logo', 64))
235 except (TypeError, ValueError):
236 return HttpResponseBadRequest('scale_logo has to be a numeric value')
238 if scale not in range(1, 257):
239 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
242 def get_podcast(t):
243 return t
245 def json_map(t):
246 podcast = t
247 p = podcast_data(podcast, domain, scale)
248 return p
250 title = _('gpodder.net - Top %(count)d') % {'count': len(entries)}
251 return format_podcast_list(entries,
252 format,
253 title,
254 get_podcast=get_podcast,
255 json_map=json_map,
256 jsonp_padding=request.GET.get('jsonp', ''),
257 xml_template='podcasts.xml',
258 request=request,
262 @check_format
263 @cache_page(60 * 60)
264 @allowed_methods(['GET'])
265 @cors_origin()
266 def search(request, format):
268 NUM_RESULTS = 20
270 query = request.GET.get('q', '').encode('utf-8')
272 try:
273 scale = int(request.GET.get('scale_logo', 64))
274 except (TypeError, ValueError):
275 return HttpResponseBadRequest('scale_logo has to be a numeric value')
277 if scale not in range(1, 257):
278 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
280 if not query:
281 return HttpResponseBadRequest('/search.opml|txt|json?q={query}')
283 results = search_podcasts(query)[:NUM_RESULTS]
285 title = _('gpodder.net - Search')
286 domain = RequestSite(request).domain
287 p_data = lambda p: podcast_data(p, domain, scale)
288 return format_podcast_list(results, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp', ''), xml_template='podcasts.xml', request=request)
291 @require_valid_user
292 @check_format
293 @never_cache
294 @allowed_methods(['GET'])
295 @cors_origin()
296 def suggestions(request, count, format):
297 count = parse_range(count, 1, 100, 100)
299 user = request.user
300 suggestions = Podcast.objects.filter(podcastsuggestion__suggested_to=user,
301 podcastsuggestion__deleted=False)
302 title = _('gpodder.net - %(count)d Suggestions') % {'count': len(suggestions)}
303 domain = RequestSite(request).domain
304 p_data = lambda p: podcast_data(p, domain)
305 return format_podcast_list(suggestions, format, title, json_map=p_data, jsonp_padding=request.GET.get('jsonp'))
308 @check_format
309 @allowed_methods(['GET'])
310 @cache_page(60 * 60)
311 @cors_origin()
312 def example_podcasts(request, format):
314 podcasts = cache.get('example-podcasts', None)
316 try:
317 scale = int(request.GET.get('scale_logo', 64))
318 except (TypeError, ValueError):
319 return HttpResponseBadRequest('scale_logo has to be a numeric value')
321 if scale not in range(1, 257):
322 return HttpResponseBadRequest('scale_logo has to be a number from 1 to 256')
324 if not podcasts:
325 podcasts = ExamplePodcast.objects.get_podcasts()
326 cache.set('example-podcasts', podcasts)
328 title = 'gPodder Podcast Directory'
329 domain = RequestSite(request).domain
330 p_data = lambda p: podcast_data(p, domain, scale)
331 return format_podcast_list(
332 podcasts,
333 format,
334 title,
335 json_map=p_data,
336 xml_template='podcasts.xml',
337 request=request,