83fcaf8a9a9c9f2b673bae87828cc520f3a7922a
[mygpo.git] / mygpo / api / advanced / __init__.py
blob83fcaf8a9a9c9f2b673bae87828cc520f3a7922a
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 from mygpo.api.basic_auth import require_valid_user, check_username
19 from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, Http404, HttpResponseNotAllowed
20 from mygpo.api.models import Device, Podcast, SubscriptionAction, Episode, EpisodeAction, SUBSCRIBE_ACTION, UNSUBSCRIBE_ACTION, EPISODE_ACTION_TYPES, DEVICE_TYPES, Subscription
21 from mygpo.api.models.users import EpisodeFavorite
22 from mygpo.api.httpresponse import JsonResponse
23 from mygpo.api.sanitizing import sanitize_url
24 from mygpo.api.advanced.directory import episode_data, podcast_data
25 from mygpo.api.backend import get_all_subscriptions
26 from django.core import serializers
27 from django.shortcuts import get_object_or_404
28 from time import mktime, gmtime, strftime
29 from datetime import datetime, timedelta
30 import dateutil.parser
31 from mygpo.log import log
32 from mygpo.utils import parse_time, parse_bool
33 from django.db import IntegrityError
34 import re
35 from django.views.decorators.csrf import csrf_exempt
37 try:
38 #try to import the JSON module (if we are on Python 2.6)
39 import json
41 # Python 2.5 seems to have a different json module
42 if not 'dumps' in dir(json):
43 raise ImportError
45 except ImportError:
46 # No JSON module available - fallback to simplejson (Python < 2.6)
47 print "No JSON module available - fallback to simplejson (Python < 2.6)"
48 import simplejson as json
51 @csrf_exempt
52 @require_valid_user
53 @check_username
54 def subscriptions(request, username, device_uid):
56 now = datetime.now()
57 now_ = int(mktime(now.timetuple()))
59 if request.method == 'GET':
60 try:
61 d = Device.objects.get(user=request.user, uid=device_uid, deleted=False)
62 except Device.DoesNotExist:
63 raise Http404('device %s does not exist' % device_uid)
65 try:
66 since_ = request.GET['since']
67 except KeyError:
68 return HttpResponseBadRequest('parameter since missing')
70 since = datetime.fromtimestamp(float(since_))
72 changes = get_subscription_changes(request.user, d, since, now)
74 return JsonResponse(changes)
76 elif request.method == 'POST':
77 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid, defaults = {'type': 'other', 'name': 'New Device'})
79 if d.deleted:
80 d.deleted = False
81 d.save()
83 actions = json.loads(request.raw_post_data)
84 add = actions['add'] if 'add' in actions else []
85 rem = actions['remove'] if 'remove' in actions else []
87 try:
88 update_urls = update_subscriptions(request.user, d, add, rem)
89 except IntegrityError, e:
90 return HttpResponseBadRequest(e)
92 return JsonResponse({
93 'timestamp': now_,
94 'update_urls': update_urls,
97 else:
98 return HttpResponseNotAllowed(['GET', 'POST'])
101 def update_subscriptions(user, device, add, remove):
102 updated_urls = []
103 add_sanitized = []
104 rem_sanitized = []
106 for a in add:
107 if a in remove:
108 raise IntegrityError('can not add and remove %s at the same time' % a)
110 for u in add:
111 us = sanitize_url(u)
112 if u != us: updated_urls.append( (u, us) )
113 if us != '': add_sanitized.append(us)
115 for u in remove:
116 us = sanitize_url(u)
117 if u != us: updated_urls.append( (u, us) )
118 if us != '' and us not in add_sanitized:
119 rem_sanitized.append(us)
121 for a in add_sanitized:
122 p, p_created = Podcast.objects.get_or_create(url=a)
123 try:
124 s = SubscriptionAction.objects.create(podcast=p,device=device,action=SUBSCRIBE_ACTION)
125 except IntegrityError, e:
126 log('can\'t add subscription %s for user %s: %s' % (a, user, e))
128 for r in rem_sanitized:
129 p, p_created = Podcast.objects.get_or_create(url=r)
130 try:
131 s = SubscriptionAction.objects.create(podcast=p,device=device,action=UNSUBSCRIBE_ACTION)
132 except IntegrityError, e:
133 log('can\'t remove subscription %s for user %s: %s' % (r, user, e))
135 return updated_urls
137 def get_subscription_changes(user, device, since, until):
138 actions = {}
139 for a in SubscriptionAction.objects.filter(device=device, timestamp__gt=since, timestamp__lte=until).order_by('timestamp'):
140 #ordered by ascending date; newer entries overwriter older ones
141 actions[a.podcast] = a
143 add = []
144 remove = []
146 for a in actions.values():
147 if a.action == SUBSCRIBE_ACTION:
148 add.append(a.podcast.url)
149 elif a.action == UNSUBSCRIBE_ACTION:
150 remove.append(a.podcast.url)
152 until_ = int(mktime(until.timetuple()))
153 return {'add': add, 'remove': remove, 'timestamp': until_}
156 @csrf_exempt
157 @require_valid_user
158 @check_username
159 def episodes(request, username, version=1):
161 version = int(version)
162 now = datetime.now()
163 now_ = int(mktime(now.timetuple()))
165 if request.method == 'POST':
166 try:
167 actions = json.loads(request.raw_post_data)
168 except KeyError, e:
169 log('could not parse episode update info for user %s: %s' % (username, e))
170 return HttpResponseBadRequest()
172 try:
173 update_urls = update_episodes(request.user, actions)
174 except Exception, e:
175 log('could not update episodes for user %s: %s' % (username, e))
176 return HttpResponseBadRequest(e)
178 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
180 elif request.method == 'GET':
181 podcast_url= request.GET.get('podcast', None)
182 device_uid = request.GET.get('device', None)
183 since_ = request.GET.get('since', None)
184 aggregated = parse_bool(request.GET.get('aggregated', False))
186 since = datetime.fromtimestamp(float(since_)) if since_ else None
188 try:
189 podcast = Podcast.objects.get(url=podcast_url) if podcast_url else None
190 device = Device.objects.get(user=request.user,uid=device_uid, deleted=False) if device_uid else None
191 except:
192 raise Http404
194 return JsonResponse(get_episode_changes(request.user, podcast, device, since, now, aggregated, version))
196 else:
197 return HttpResponseNotAllowed(['POST', 'GET'])
200 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
201 if aggregated:
202 actions = {}
203 else:
204 actions = []
205 eactions = EpisodeAction.objects.filter(user=user, timestamp__lte=until)
207 if podcast:
208 eactions = eactions.filter(episode__podcast=podcast)
210 if device:
211 eactions = eactions.filter(device=device)
213 if since: # we can't use None with __gt
214 eactions = eactions.filter(timestamp__gt=since)
216 if aggregated:
217 eactions = eactions.order_by('timestamp')
219 for a in eactions:
220 action = {
221 'podcast': a.episode.podcast.url,
222 'episode': a.episode.url,
223 'action': a.action,
224 'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S') #2009-12-12T09:00:00
227 if a.action == 'play' and a.playmark:
228 if version == 1:
229 t = gmtime(a.playmark)
230 action['position'] = strftime('%H:%M:%S', t)
231 elif None in (a.playmark, a.started, a.total):
232 log('Ignoring broken episode action in DB: %r' % (a,))
233 continue
234 else:
235 action['position'] = int(a.playmark)
236 action['started'] = int(a.started)
237 action['total'] = int(a.total)
239 if aggregated:
240 actions[a.episode] = action
241 else:
242 actions.append(action)
244 until_ = int(mktime(until.timetuple()))
246 if aggregated:
247 actions = list(actions.itervalues())
249 return {'actions': actions, 'timestamp': until_}
252 def update_episodes(user, actions):
253 update_urls = []
255 for e in actions:
256 u = e['podcast']
257 us = sanitize_url(u)
258 if u != us: update_urls.append( (u, us) )
259 if us == '': continue
261 podcast, p_created = Podcast.objects.get_or_create(url=us)
263 eu = e['episode']
264 eus = sanitize_url(eu, podcast=False, episode=True)
265 if eu != eus: update_urls.append( (eu, eus) )
266 if eus == '': continue
268 episode, e_created = Episode.objects.get_or_create(podcast=podcast, url=eus)
269 action = e['action']
270 if not valid_episodeaction(action):
271 raise Exception('invalid action %s' % action)
273 if 'device' in e:
274 device, created = Device.objects.get_or_create(user=user, uid=e['device'], defaults={'name': 'Unknown', 'type': 'other'})
276 # undelete a previously deleted device
277 if device.deleted:
278 device.deleted = False
279 device.save()
281 else:
282 device, created = None, False
284 timestamp = dateutil.parser.parse(e['timestamp']) if 'timestamp' in e and e['timestamp'] else datetime.now()
286 # Time values for play actions and their keys in JSON
287 PLAY_ACTION_KEYS = ('position', 'started', 'total')
288 time_values = {}
290 for key in PLAY_ACTION_KEYS:
291 if key in e:
292 if action != 'play':
293 # Key found, but must not be supplied (no play action!)
294 return HttpResponseBadRequest('%s only allowed in play actions' % key)
296 try:
297 time_values[key] = parse_time(e[key])
298 except ValueError:
299 log('could not parse %s parameter (value: %s) for user %s' % (key, repr(e[key]), user))
300 return HttpResponseBadRequest('Wrong format for %s: %s' % (key, repr(e[key])))
301 else:
302 # Value not supplied by client
303 time_values[key] = None
305 # Sanity check: If started or total are given, require position
306 if (time_values['started'] is not None or \
307 time_values['total'] is not None) and \
308 time_values['position'] is None:
309 return HttpResponseBadRequest('started and total require position')
311 # Sanity check: total and position can only appear together
312 if (time_values['total'] or time_values['started']) and \
313 not (time_values['total'] and time_values['started']):
314 return HttpResponseBadRequest('total and started parameters can only appear together')
316 try:
317 EpisodeAction.objects.create(user=user, episode=episode, device=device, action=action, timestamp=timestamp,
318 playmark=time_values['position'], started=time_values['started'], total=time_values['total'])
319 except Exception, e:
320 log('error while adding episode action (user %s, episode %s, device %s, action %s, timestamp %s): %s' % (user, episode, device, action, timestamp, e))
322 return update_urls
325 @csrf_exempt
326 @require_valid_user
327 @check_username
328 def device(request, username, device_uid):
330 # Workaround for mygpoclient 1.0: It uses "PUT" requests
331 # instead of "POST" requests for uploading device settings
332 if request.method in ('POST', 'PUT'):
333 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid)
335 #undelete a previously deleted device
336 if d.deleted:
337 d.deleted = False
338 d.save()
340 data = json.loads(request.raw_post_data)
342 if 'caption' in data:
343 d.name = data['caption']
345 if 'type' in data:
346 if not valid_devicetype(data['type']):
347 return HttpResponseBadRequest('invalid device type %s' % data['type'])
348 d.type = data['type']
350 d.save()
352 return HttpResponse()
354 else:
355 return HttpResponseNotAllowed(['POST'])
357 def valid_devicetype(type):
358 for t in DEVICE_TYPES:
359 if t[0] == type:
360 return True
361 return False
363 def valid_episodeaction(type):
364 for t in EPISODE_ACTION_TYPES:
365 if t[0] == type:
366 return True
367 return False
370 @csrf_exempt
371 @require_valid_user
372 @check_username
373 def devices(request, username):
375 if request.method == 'GET':
376 devices = []
377 for d in Device.objects.filter(user=request.user, deleted=False):
378 devices.append({
379 'id': d.uid,
380 'caption': d.name,
381 'type': d.type,
382 'subscriptions': Subscription.objects.filter(device=d).count()
385 return JsonResponse(devices)
387 else:
388 return HttpResponseNotAllowed(['GET'])
391 @csrf_exempt
392 @require_valid_user
393 @check_username
394 def updates(request, username, device_uid):
395 now = datetime.now()
396 now_ = int(mktime(now.timetuple()))
398 device = get_object_or_404(Device, user=request.user, uid=device_uid)
400 try:
401 since_ = request.GET['since']
402 except KeyError:
403 return HttpResponseBadRequest('parameter since missing')
405 since = datetime.fromtimestamp(float(since_))
407 ret = get_subscription_changes(request.user, device, since, now)
409 # replace added urls with details
410 podcast_details = []
411 for url in ret['add']:
412 podcast = Podcast.objects.get(url=url)
413 podcast_details.append(podcast_data(podcast))
415 ret['add'] = podcast_details
418 # add episode details
419 subscriptions = get_all_subscriptions(request.user)
420 episode_status = {}
421 for e in Episode.objects.filter(podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
422 episode_status[e] = 'new'
423 for a in EpisodeAction.objects.filter(user=request.user, episode__podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
424 episode_status[a.episode] = a.action
426 updates = []
427 for episode, status in episode_status.iteritems():
428 t = episode_data(episode)
429 t['released'] = e.timestamp.strftime('%Y-%m-%dT%H:%M:%S')
430 t['status'] = status
431 updates.append(t)
433 ret['updates'] = updates
435 return JsonResponse(ret)
438 @require_valid_user
439 @check_username
440 def favorites(request, username):
441 favorites = [x.episode for x in EpisodeFavorite.objects.filter(user=request.user).order_by('-created')]
442 ret = map(episode_data, favorites)
443 return JsonResponse(ret)