32db6615a6aeb5095fd57460cd818c7723a7af40
[mygpo.git] / mygpo / api / advanced / __init__.py
blob32db6615a6aeb5095fd57460cd818c7723a7af40
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
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.shortcuts import get_object_or_404
27 from time import mktime, gmtime, strftime
28 from datetime import datetime
29 import dateutil.parser
30 from mygpo.log import log
31 from mygpo.utils import parse_time, parse_bool
32 from mygpo.decorators import allowed_methods
33 from django.db import IntegrityError
34 from django.views.decorators.csrf import csrf_exempt
36 try:
37 #try to import the JSON module (if we are on Python 2.6)
38 import json
40 # Python 2.5 seems to have a different json module
41 if not 'dumps' in dir(json):
42 raise ImportError
44 except ImportError:
45 # No JSON module available - fallback to simplejson (Python < 2.6)
46 print "No JSON module available - fallback to simplejson (Python < 2.6)"
47 import simplejson as json
50 @csrf_exempt
51 @require_valid_user
52 @check_username
53 @allowed_methods(['GET', 'POST'])
54 def subscriptions(request, username, device_uid):
56 now = datetime.now()
57 now_ = int(mktime(now.timetuple()))
59 if request.method == 'GET':
60 d = get_object_or_404(Device, user=request.user, uid=device_uid, deleted=False)
62 try:
63 since_ = request.GET['since']
64 except KeyError:
65 return HttpResponseBadRequest('parameter since missing')
67 since = datetime.fromtimestamp(float(since_))
69 changes = get_subscription_changes(request.user, d, since, now)
71 return JsonResponse(changes)
73 elif request.method == 'POST':
74 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid, defaults = {'type': 'other', 'name': 'New Device'})
76 if d.deleted:
77 d.deleted = False
78 d.save()
80 actions = json.loads(request.raw_post_data)
81 add = actions['add'] if 'add' in actions else []
82 rem = actions['remove'] if 'remove' in actions else []
84 try:
85 update_urls = update_subscriptions(request.user, d, add, rem)
86 except IntegrityError, e:
87 return HttpResponseBadRequest(e)
89 return JsonResponse({
90 'timestamp': now_,
91 'update_urls': update_urls,
95 def update_subscriptions(user, device, add, remove):
96 updated_urls = []
97 add_sanitized = []
98 rem_sanitized = []
100 for a in add:
101 if a in remove:
102 raise IntegrityError('can not add and remove %s at the same time' % a)
104 for u in add:
105 us = sanitize_url(u)
106 if u != us: updated_urls.append( (u, us) )
107 if us != '': add_sanitized.append(us)
109 for u in remove:
110 us = sanitize_url(u)
111 if u != us: updated_urls.append( (u, us) )
112 if us != '' and us not in add_sanitized:
113 rem_sanitized.append(us)
115 for a in add_sanitized:
116 p, p_created = Podcast.objects.get_or_create(url=a)
117 try:
118 s = SubscriptionAction.objects.create(podcast=p,device=device,action=SUBSCRIBE_ACTION)
119 except IntegrityError, e:
120 log('can\'t add subscription %s for user %s: %s' % (a, user, e))
122 for r in rem_sanitized:
123 p, p_created = Podcast.objects.get_or_create(url=r)
124 try:
125 s = SubscriptionAction.objects.create(podcast=p,device=device,action=UNSUBSCRIBE_ACTION)
126 except IntegrityError, e:
127 log('can\'t remove subscription %s for user %s: %s' % (r, user, e))
129 return updated_urls
131 def get_subscription_changes(user, device, since, until):
132 actions = {}
133 for a in SubscriptionAction.objects.filter(device=device, timestamp__gt=since, timestamp__lte=until).order_by('timestamp'):
134 #ordered by ascending date; newer entries overwriter older ones
135 actions[a.podcast] = a
137 add = []
138 remove = []
140 for a in actions.values():
141 if a.action == SUBSCRIBE_ACTION:
142 add.append(a.podcast.url)
143 elif a.action == UNSUBSCRIBE_ACTION:
144 remove.append(a.podcast.url)
146 until_ = int(mktime(until.timetuple()))
147 return {'add': add, 'remove': remove, 'timestamp': until_}
150 @csrf_exempt
151 @require_valid_user
152 @check_username
153 @allowed_methods(['GET', 'POST'])
154 def episodes(request, username, version=1):
156 version = int(version)
157 now = datetime.now()
158 now_ = int(mktime(now.timetuple()))
160 if request.method == 'POST':
161 try:
162 actions = json.loads(request.raw_post_data)
163 except KeyError, e:
164 log('could not parse episode update info for user %s: %s' % (username, e))
165 return HttpResponseBadRequest()
167 try:
168 update_urls = update_episodes(request.user, actions)
169 except Exception, e:
170 log('could not update episodes for user %s: %s' % (username, e))
171 return HttpResponseBadRequest(e)
173 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
175 elif request.method == 'GET':
176 podcast_url= request.GET.get('podcast', None)
177 device_uid = request.GET.get('device', None)
178 since_ = request.GET.get('since', None)
179 aggregated = parse_bool(request.GET.get('aggregated', False))
181 since = datetime.fromtimestamp(float(since_)) if since_ else None
183 podcast = get_object_or_404(Podcast, url=podcast_url) if podcast_url else None
184 device = get_object_or_404(Device, user=request.user,uid=device_uid, deleted=False) if device_uid else None
186 return JsonResponse(get_episode_changes(request.user, podcast, device, since, now, aggregated, version))
189 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
190 if aggregated:
191 actions = {}
192 else:
193 actions = []
194 eactions = EpisodeAction.objects.filter(user=user, timestamp__lte=until)
196 if podcast:
197 eactions = eactions.filter(episode__podcast=podcast)
199 if device:
200 eactions = eactions.filter(device=device)
202 if since: # we can't use None with __gt
203 eactions = eactions.filter(timestamp__gt=since)
205 if aggregated:
206 eactions = eactions.order_by('timestamp')
208 for a in eactions:
209 action = {
210 'podcast': a.episode.podcast.url,
211 'episode': a.episode.url,
212 'action': a.action,
213 'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S') #2009-12-12T09:00:00
216 if a.action == 'play' and a.playmark:
217 if version == 1:
218 t = gmtime(a.playmark)
219 action['position'] = strftime('%H:%M:%S', t)
220 elif None in (a.playmark, a.started, a.total):
221 log('Ignoring broken episode action in DB: %r' % (a,))
222 continue
223 else:
224 action['position'] = int(a.playmark)
225 action['started'] = int(a.started)
226 action['total'] = int(a.total)
228 if aggregated:
229 actions[a.episode] = action
230 else:
231 actions.append(action)
233 until_ = int(mktime(until.timetuple()))
235 if aggregated:
236 actions = list(actions.itervalues())
238 return {'actions': actions, 'timestamp': until_}
241 def update_episodes(user, actions):
242 update_urls = []
244 for e in actions:
245 u = e['podcast']
246 us = sanitize_url(u)
247 if u != us: update_urls.append( (u, us) )
248 if us == '': continue
250 podcast, p_created = Podcast.objects.get_or_create(url=us)
252 eu = e['episode']
253 eus = sanitize_url(eu, podcast=False, episode=True)
254 if eu != eus: update_urls.append( (eu, eus) )
255 if eus == '': continue
257 episode, e_created = Episode.objects.get_or_create(podcast=podcast, url=eus)
258 action = e['action']
259 if not valid_episodeaction(action):
260 raise Exception('invalid action %s' % action)
262 if 'device' in e:
263 device, created = Device.objects.get_or_create(user=user, uid=e['device'], defaults={'name': 'Unknown', 'type': 'other'})
265 # undelete a previously deleted device
266 if device.deleted:
267 device.deleted = False
268 device.save()
270 else:
271 device, created = None, False
273 timestamp = dateutil.parser.parse(e['timestamp']) if 'timestamp' in e and e['timestamp'] else datetime.now()
275 # Time values for play actions and their keys in JSON
276 PLAY_ACTION_KEYS = ('position', 'started', 'total')
277 time_values = {}
279 for key in PLAY_ACTION_KEYS:
280 if key in e:
281 if action != 'play':
282 # Key found, but must not be supplied (no play action!)
283 return HttpResponseBadRequest('%s only allowed in play actions' % key)
285 try:
286 time_values[key] = parse_time(e[key])
287 except ValueError:
288 log('could not parse %s parameter (value: %s) for user %s' % (key, repr(e[key]), user))
289 return HttpResponseBadRequest('Wrong format for %s: %s' % (key, repr(e[key])))
290 else:
291 # Value not supplied by client
292 time_values[key] = None
294 # Sanity check: If started or total are given, require position
295 if (time_values['started'] is not None or \
296 time_values['total'] is not None) and \
297 time_values['position'] is None:
298 return HttpResponseBadRequest('started and total require position')
300 # Sanity check: total and position can only appear together
301 if (time_values['total'] or time_values['started']) and \
302 not (time_values['total'] and time_values['started']):
303 return HttpResponseBadRequest('total and started parameters can only appear together')
305 try:
306 EpisodeAction.objects.create(user=user, episode=episode, device=device, action=action, timestamp=timestamp,
307 playmark=time_values['position'], started=time_values['started'], total=time_values['total'])
308 except Exception, e:
309 log('error while adding episode action (user %s, episode %s, device %s, action %s, timestamp %s): %s' % (user, episode, device, action, timestamp, e))
311 return update_urls
314 @csrf_exempt
315 @require_valid_user
316 @check_username
317 # Workaround for mygpoclient 1.0: It uses "PUT" requests
318 # instead of "POST" requests for uploading device settings
319 @allowed_methods(['POST', 'PUT'])
320 def device(request, username, device_uid):
322 d, created = Device.objects.get_or_create(user=request.user, uid=device_uid)
324 #undelete a previously deleted device
325 if d.deleted:
326 d.deleted = False
327 d.save()
329 data = json.loads(request.raw_post_data)
331 if 'caption' in data:
332 d.name = data['caption']
334 if 'type' in data:
335 if not valid_devicetype(data['type']):
336 return HttpResponseBadRequest('invalid device type %s' % data['type'])
337 d.type = data['type']
339 d.save()
341 return HttpResponse()
344 def valid_devicetype(type):
345 for t in DEVICE_TYPES:
346 if t[0] == type:
347 return True
348 return False
350 def valid_episodeaction(type):
351 for t in EPISODE_ACTION_TYPES:
352 if t[0] == type:
353 return True
354 return False
357 @csrf_exempt
358 @require_valid_user
359 @check_username
360 @allowed_methods(['GET'])
361 def devices(request, username):
362 devices = Device.objects.filter(user=request.user, deleted=False)
363 devices = map(device_data, devices)
365 return JsonResponse(devices)
368 def device_data(device):
369 return {
370 'id': d.uid,
371 'caption': d.name,
372 'type': d.type,
373 'subscriptions': Subscription.objects.filter(device=d).count()
377 @csrf_exempt
378 @require_valid_user
379 @check_username
380 def updates(request, username, device_uid):
381 now = datetime.now()
382 now_ = int(mktime(now.timetuple()))
384 device = get_object_or_404(Device, user=request.user, uid=device_uid)
386 try:
387 since_ = request.GET['since']
388 except KeyError:
389 return HttpResponseBadRequest('parameter since missing')
391 since = datetime.fromtimestamp(float(since_))
393 ret = get_subscription_changes(request.user, device, since, now)
395 # replace added urls with details
396 podcast_details = []
397 for url in ret['add']:
398 podcast = Podcast.objects.get(url=url)
399 podcast_details.append(podcast_data(podcast))
401 ret['add'] = podcast_details
404 # add episode details
405 subscriptions = get_all_subscriptions(request.user)
406 episode_status = {}
407 for e in Episode.objects.filter(podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
408 episode_status[e] = 'new'
409 for a in EpisodeAction.objects.filter(user=request.user, episode__podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
410 episode_status[a.episode] = a.action
412 updates = []
413 for episode, status in episode_status.iteritems():
414 t = episode_data(episode)
415 t['released'] = e.timestamp.strftime('%Y-%m-%dT%H:%M:%S')
416 t['status'] = status
417 updates.append(t)
419 ret['updates'] = updates
421 return JsonResponse(ret)
424 @require_valid_user
425 @check_username
426 def favorites(request, username):
427 favorites = [x.episode for x in EpisodeFavorite.objects.filter(user=request.user).order_by('-created')]
428 ret = map(episode_data, favorites)
429 return JsonResponse(ret)