simplify, refactor APIs
[mygpo.git] / mygpo / api / advanced / __init__.py
blob6bb5e216bc0d0d9babe09fe037d07deb8608259e
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, get_device
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 since_ = request.GET.get('since', None)
63 if since_ == None:
64 return HttpResponseBadRequest('parameter since missing')
66 since = datetime.fromtimestamp(float(since_))
68 changes = get_subscription_changes(request.user, d, since, now)
70 return JsonResponse(changes)
72 elif request.method == 'POST':
73 d = get_device(request.user, device_uid)
75 actions = json.loads(request.raw_post_data)
76 add = actions['add'] if 'add' in actions else []
77 rem = actions['remove'] if 'remove' in actions else []
79 try:
80 update_urls = update_subscriptions(request.user, d, add, rem)
81 except IntegrityError, e:
82 return HttpResponseBadRequest(e)
84 return JsonResponse({
85 'timestamp': now_,
86 'update_urls': update_urls,
90 def update_subscriptions(user, device, add, remove):
91 updated_urls = []
92 add_sanitized = []
93 rem_sanitized = []
95 for a in add:
96 if a in remove:
97 raise IntegrityError('can not add and remove %s at the same time' % a)
99 for u in add:
100 us = sanitize_append(u, updated_urls)
101 if us != '': add_sanitized.append(us)
103 for u in remove:
104 us = sanitize_append(u, updated_urls))
105 if us != '' and us not in add_sanitized:
106 rem_sanitized.append(us)
108 for a in add_sanitized:
109 p, p_created = Podcast.objects.get_or_create(url=a)
110 try:
111 p.subscribe(device)
112 except IntegrityError, e:
113 log('can\'t add subscription %s for user %s: %s' % (a, user, e))
115 for r in rem_sanitized:
116 p, p_created = Podcast.objects.get_or_create(url=r)
117 try:
118 p.unsubscribe(device)
119 except IntegrityError, e:
120 log('can\'t remove subscription %s for user %s: %s' % (r, user, e))
122 return updated_urls
124 def get_subscription_changes(user, device, since, until):
125 #ordered by ascending date; newer entries overwriter older ones
126 query = SubscriptionAction.objects.filter(device=device,
127 timestamp__gt=since, timestamp__lte=until).order_by('timestamp'):
128 actions = dict([(a.podcast, a) for a in query])
130 add = filter(lambda a: a.action == SUBSCRIBE_ACTION, actions)
131 rem = filter(lambda a: a.action == UNSUBSCRIBE_ACTION, actions)
133 until_ = int(mktime(until.timetuple()))
134 return {'add': add, 'remove': remove, 'timestamp': until_}
137 @csrf_exempt
138 @require_valid_user
139 @check_username
140 @allowed_methods(['GET', 'POST'])
141 def episodes(request, username, version=1):
143 version = int(version)
144 now = datetime.now()
145 now_ = int(mktime(now.timetuple()))
147 if request.method == 'POST':
148 try:
149 actions = json.loads(request.raw_post_data)
150 except KeyError, e:
151 log('could not parse episode update info for user %s: %s' % (username, e))
152 return HttpResponseBadRequest()
154 try:
155 update_urls = update_episodes(request.user, actions)
156 except Exception, e:
157 log('could not update episodes for user %s: %s' % (username, e))
158 return HttpResponseBadRequest(e)
160 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
162 elif request.method == 'GET':
163 podcast_url= request.GET.get('podcast', None)
164 device_uid = request.GET.get('device', None)
165 since_ = request.GET.get('since', None)
166 aggregated = parse_bool(request.GET.get('aggregated', False))
168 since = datetime.fromtimestamp(float(since_)) if since_ else None
170 podcast = get_object_or_404(Podcast, url=podcast_url) if podcast_url else None
171 device = get_object_or_404(Device, user=request.user,uid=device_uid, deleted=False) if device_uid else None
173 return JsonResponse(get_episode_changes(request.user, podcast, device, since, now, aggregated, version))
176 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
177 if aggregated:
178 actions = {}
179 else:
180 actions = []
181 eactions = EpisodeAction.objects.filter(user=user, timestamp__lte=until)
183 if podcast:
184 eactions = eactions.filter(episode__podcast=podcast)
186 if device:
187 eactions = eactions.filter(device=device)
189 if since: # we can't use None with __gt
190 eactions = eactions.filter(timestamp__gt=since)
192 if aggregated:
193 eactions = eactions.order_by('timestamp')
195 for a in eactions:
196 action = {
197 'podcast': a.episode.podcast.url,
198 'episode': a.episode.url,
199 'action': a.action,
200 'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S') #2009-12-12T09:00:00
203 if a.action == 'play' and a.playmark:
204 if version == 1:
205 t = gmtime(a.playmark)
206 action['position'] = strftime('%H:%M:%S', t)
207 elif None in (a.playmark, a.started, a.total):
208 log('Ignoring broken episode action in DB: %r' % (a,))
209 continue
210 else:
211 action['position'] = int(a.playmark)
212 action['started'] = int(a.started)
213 action['total'] = int(a.total)
215 if aggregated:
216 actions[a.episode] = action
217 else:
218 actions.append(action)
220 until_ = int(mktime(until.timetuple()))
222 if aggregated:
223 actions = list(actions.itervalues())
225 return {'actions': actions, 'timestamp': until_}
228 def update_episodes(user, actions):
229 update_urls = []
231 for e in actions:
232 us = sanitize_append(e['podcast'], update_urls)
233 if us == '': continue
235 podcast, p_created = Podcast.objects.get_or_create(url=us)
237 eus = sanitize_append(e['episode'], update_urls)
238 if eus == '': continue
240 episode, e_created = Episode.objects.get_or_create(podcast=podcast, url=eus)
241 action = e['action']
242 if not valid_episodeaction(action):
243 raise Exception('invalid action %s' % action)
245 if 'device' in e:
246 device = get_device(user, e['device'])
247 else:
248 device = None
250 timestamp = dateutil.parser.parse(e['timestamp']) if e.get('timestamp', None) else datetime.now()
252 time_values = check_time_values(e)
254 try:
255 EpisodeAction.objects.create(user=user, episode=episode,
256 device=device, action=action, timestamp=timestamp,
257 playmark=time_values.get('position', None),
258 started=time_values.get('started', None),
259 total=time_values.get('total', None))
260 except Exception, e:
261 log('error while adding episode action (user %s, episode %s, device %s, action %s, timestamp %s): %s' % (user, episode, device, action, timestamp, e))
263 return update_urls
266 @csrf_exempt
267 @require_valid_user
268 @check_username
269 # Workaround for mygpoclient 1.0: It uses "PUT" requests
270 # instead of "POST" requests for uploading device settings
271 @allowed_methods(['POST', 'PUT'])
272 def device(request, username, device_uid):
273 d = get_device(request.user, device_uid)
275 data = json.loads(request.raw_post_data)
277 if 'caption' in data:
278 d.name = data['caption']
280 if 'type' in data:
281 if not valid_devicetype(data['type']):
282 return HttpResponseBadRequest('invalid device type %s' % data['type'])
283 d.type = data['type']
285 d.save()
287 return HttpResponse()
290 def valid_devicetype(type):
291 for t in DEVICE_TYPES:
292 if t[0] == type:
293 return True
294 return False
296 def valid_episodeaction(type):
297 for t in EPISODE_ACTION_TYPES:
298 if t[0] == type:
299 return True
300 return False
303 @csrf_exempt
304 @require_valid_user
305 @check_username
306 @allowed_methods(['GET'])
307 def devices(request, username):
308 devices = Device.objects.filter(user=request.user, deleted=False)
309 devices = map(device_data, devices)
311 return JsonResponse(devices)
314 def device_data(device):
315 return dict(
316 id = device.uid,
317 caption = device.name,
318 type = device.type,
319 subscription = Subscription.objects.filter(device=device).count()
323 @csrf_exempt
324 @require_valid_user
325 @check_username
326 def updates(request, username, device_uid):
327 now = datetime.now()
328 now_ = int(mktime(now.timetuple()))
330 device = get_object_or_404(Device, user=request.user, uid=device_uid)
332 since_ = request.GET.get('since', None)
333 if since_ == None:
334 return HttpResponseBadRequest('parameter since missing')
336 since = datetime.fromtimestamp(float(since_))
338 ret = get_subscription_changes(request.user, device, since, now)
340 # replace added urls with details
341 podcast_details = []
342 for url in ret['add']:
343 podcast = Podcast.objects.get(url=url)
344 podcast_details.append(podcast_data(podcast))
346 ret['add'] = podcast_details
349 # add episode details
350 subscriptions = get_all_subscriptions(request.user)
351 episode_status = {}
352 for e in Episode.objects.filter(podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
353 episode_status[e] = 'new'
354 for a in EpisodeAction.objects.filter(user=request.user, episode__podcast__in=subscriptions, timestamp__gte=since).order_by('timestamp'):
355 episode_status[a.episode] = a.action
357 updates = []
358 for episode, status in episode_status.iteritems():
359 t = episode_data(episode)
360 t['released'] = e.timestamp.strftime('%Y-%m-%dT%H:%M:%S')
361 t['status'] = status
362 updates.append(t)
364 ret['updates'] = updates
366 return JsonResponse(ret)
369 @require_valid_user
370 @check_username
371 def favorites(request, username):
372 favorites = [x.episode for x in EpisodeFavorite.objects.filter(user=request.user).order_by('-created')]
373 ret = map(episode_data, favorites)
374 return JsonResponse(ret)
377 def sanitize_append(url, sanitized_list):
378 urls = sanitize_url(url)
379 if url != urls:
380 sanitized_list.append( (url, urls) )
381 return urls
384 def check_time_values(action)
385 PLAY_ACTION_KEYS = ('position', 'started', 'total')
387 # Key found, but must not be supplied (no play action!)
388 if action['action'] != 'play':
389 for key in PLAY_ACTION_KEYS:
390 if key in e:
391 raise ValueError('%s only allowed in play actions' % key)
393 supplied_keys = filter(lambda: x: x in e, PLAY_ACTION_KEYS)
394 time_values = map(lambda x: parse_time(e[x]), supplied_keys)
396 # Sanity check: If started or total are given, require position
397 if (('started' in time_values) or \
398 ('total' in time_values)) and \
399 (not 'position' in time_values):
400 raise ValueError('started and total require position')
402 # Sanity check: total and position can only appear together
403 if (('total' in time_values) or ('started' in time_values)) and \
404 not (('total' in time_values) and ('started' in time_values)):
405 raise HttpResponseBadRequest('total and started parameters can only appear together')
407 return time_values