4e406a2475626d78b5337ea68fb5e4a6bffa050b
[mygpo.git] / mygpo / api / advanced / __init__.py
blob4e406a2475626d78b5337ea68fb5e4a6bffa050b
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 functools import partial
19 from itertools import imap, chain
20 from collections import defaultdict, namedtuple
21 from datetime import datetime
22 from importlib import import_module
24 import dateutil.parser
26 try:
27 import gevent
28 except ImportError:
29 gevent = None
31 from django.http import HttpResponse, HttpResponseBadRequest, Http404, HttpResponseNotFound
32 from django.contrib.sites.models import RequestSite
33 from django.views.decorators.csrf import csrf_exempt
34 from django.views.decorators.cache import never_cache
35 from django.utils.decorators import method_decorator
36 from django.views.generic.base import View
37 from django.conf import settings
39 from mygpo.api.constants import EPISODE_ACTION_TYPES, DEVICE_TYPES
40 from mygpo.api.httpresponse import JsonResponse
41 from mygpo.api.sanitizing import sanitize_url, sanitize_urls
42 from mygpo.api.advanced.directory import episode_data, podcast_data
43 from mygpo.api.backend import get_device, BulkSubscribe
44 from mygpo.log import log
45 from mygpo.utils import parse_time, format_time, parse_bool, get_timestamp
46 from mygpo.decorators import allowed_methods, repeat_on_conflict
47 from mygpo.core import models
48 from mygpo.core.models import SanitizingRule, Podcast
49 from mygpo.core.tasks import auto_flattr_episode
50 from mygpo.users.models import PodcastUserState, EpisodeAction, \
51 EpisodeUserState, DeviceDoesNotExist, DeviceUIDException, \
52 InvalidEpisodeActionAttributes
53 from mygpo.users.settings import FLATTR_AUTO
54 from mygpo.core.json import json, JSONDecodeError
55 from mygpo.api.basic_auth import require_valid_user, check_username
56 from mygpo.db.couchdb import BulkException, bulk_save_retry
57 from mygpo.db.couchdb.episode import episode_by_id, \
58 favorite_episodes_for_user, episodes_for_podcast
59 from mygpo.db.couchdb.podcast import podcast_for_url
60 from mygpo.db.couchdb.podcast_state import subscribed_podcast_ids_by_device
61 from mygpo.db.couchdb.episode_state import get_podcasts_episode_states, \
62 episode_state_for_ref_urls, get_episode_actions
65 # keys that are allowed in episode actions
66 EPISODE_ACTION_KEYS = ('position', 'episode', 'action', 'device', 'timestamp',
67 'started', 'total', 'podcast')
70 @csrf_exempt
71 @require_valid_user
72 @check_username
73 @never_cache
74 @allowed_methods(['GET', 'POST'])
75 def subscriptions(request, username, device_uid):
77 now = datetime.now()
78 now_ = get_timestamp(now)
80 if request.method == 'GET':
82 try:
83 device = request.user.get_device_by_uid(device_uid)
84 except DeviceDoesNotExist as e:
85 return HttpResponseNotFound(str(e))
87 since_ = request.GET.get('since', None)
88 if since_ is None:
89 return HttpResponseBadRequest('parameter since missing')
90 try:
91 since = datetime.fromtimestamp(float(since_))
92 except ValueError:
93 return HttpResponseBadRequest('since-value is not a valid timestamp')
95 changes = get_subscription_changes(request.user, device, since, now)
97 return JsonResponse(changes)
99 elif request.method == 'POST':
100 d = get_device(request.user, device_uid,
101 request.META.get('HTTP_USER_AGENT', ''))
103 if not request.body:
104 return HttpResponseBadRequest('POST data must not be empty')
106 actions = json.loads(request.body)
107 add = actions['add'] if 'add' in actions else []
108 rem = actions['remove'] if 'remove' in actions else []
110 add = filter(None, add)
111 rem = filter(None, rem)
113 try:
114 update_urls = update_subscriptions(request.user, d, add, rem)
115 except ValueError, e:
116 return HttpResponseBadRequest(e)
118 return JsonResponse({
119 'timestamp': now_,
120 'update_urls': update_urls,
124 def update_subscriptions(user, device, add, remove):
126 for a in add:
127 if a in remove:
128 raise ValueError('can not add and remove %s at the same time' % a)
130 add_s = list(sanitize_urls(add, 'podcast'))
131 rem_s = list(sanitize_urls(remove, 'podcast'))
133 assert len(add) == len(add_s) and len(remove) == len(rem_s)
135 updated_urls = filter(lambda (a, b): a != b, zip(add + remove, add_s + rem_s))
137 add_s = filter(None, add_s)
138 rem_s = filter(None, rem_s)
140 # If two different URLs (in add and remove) have
141 # been sanitized to the same, we ignore the removal
142 rem_s = filter(lambda x: x not in add_s, rem_s)
144 subscriber = BulkSubscribe(user, device)
146 for a in add_s:
147 subscriber.add_action(a, 'subscribe')
149 for r in rem_s:
150 subscriber.add_action(r, 'unsubscribe')
152 try:
153 subscriber.execute()
154 except BulkException as be:
155 for err in be.errors:
156 log('Advanced API: %(username)s: Updating subscription for '
157 '%(podcast_url)s on %(device_uid)s failed: '
158 '%(rerror)s (%(reason)s)'.format(username=user.username,
159 podcast_url=err.doc, device_uid=device.uid,
160 error=err.error, reason=err.reason)
163 return updated_urls
166 def get_subscription_changes(user, device, since, until):
167 add_urls, rem_urls = device.get_subscription_changes(since, until)
168 until_ = get_timestamp(until)
169 return {'add': add_urls, 'remove': rem_urls, 'timestamp': until_}
172 @csrf_exempt
173 @require_valid_user
174 @check_username
175 @never_cache
176 @allowed_methods(['GET', 'POST'])
177 def episodes(request, username, version=1):
179 version = int(version)
180 now = datetime.now()
181 now_ = get_timestamp(now)
182 ua_string = request.META.get('HTTP_USER_AGENT', '')
184 if request.method == 'POST':
185 try:
186 actions = json.loads(request.body)
187 except (JSONDecodeError, UnicodeDecodeError) as e:
188 msg = 'Advanced API: could not decode episode update POST data for user %s: %s' % (username, e)
189 log(msg)
190 return HttpResponseBadRequest(msg)
192 log('start: user %s: %d actions from %s' % (request.user._id, len(actions), ua_string))
194 # handle in background
195 if len(actions) > settings.API_ACTIONS_MAX_NONBG:
196 bg_handler = settings.API_ACTIONS_BG_HANDLER
197 if bg_handler is not None:
199 modname, funname = bg_handler.rsplit('.', 1)
200 mod = import_module(modname)
201 fun = getattr(mod, funname)
203 fun(request.user, actions, now, ua_string)
205 # TODO: return 202 Accepted
206 return JsonResponse({'timestamp': now_, 'update_urls': []})
209 try:
210 update_urls = update_episodes(request.user, actions, now, ua_string)
211 except DeviceUIDException as e:
212 import traceback
213 s = u'could not update episodes for user %s: %s %s: %s' % (username, e, traceback.format_exc(), actions)
214 log(s.decode('utf-8', errors='ignore'))
215 return HttpResponseBadRequest(str(e))
216 except InvalidEpisodeActionAttributes as e:
217 import traceback
218 log(u'could not update episodes for user %s: %s %s: %s' % (username, e, traceback.format_exc(), actions))
219 return HttpResponseBadRequest(str(e))
221 log('done: user %s: %d actions from %s' % (request.user._id, len(actions), ua_string))
222 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
224 elif request.method == 'GET':
225 podcast_url= request.GET.get('podcast', None)
226 device_uid = request.GET.get('device', None)
227 since_ = request.GET.get('since', None)
228 aggregated = parse_bool(request.GET.get('aggregated', False))
230 try:
231 since = int(since_) if since_ else None
232 except ValueError:
233 return HttpResponseBadRequest('since-value is not a valid timestamp')
235 if podcast_url:
236 podcast = podcast_for_url(podcast_url)
237 if not podcast:
238 raise Http404
239 else:
240 podcast = None
242 if device_uid:
244 try:
245 device = request.user.get_device_by_uid(device_uid)
246 except DeviceDoesNotExist as e:
247 return HttpResponseNotFound(str(e))
249 else:
250 device = None
252 changes = get_episode_changes(request.user, podcast, device, since,
253 now_, aggregated, version)
255 return JsonResponse(changes)
259 def convert_position(action):
260 """ convert position parameter for API 1 compatibility """
261 pos = getattr(action, 'position', None)
262 if pos is not None:
263 action.position = format_time(pos)
264 return action
268 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
270 devices = dict( (dev.id, dev.uid) for dev in user.devices )
272 args = {}
273 if podcast is not None:
274 args['podcast_id'] = podcast.get_id()
276 if device is not None:
277 args['device_id'] = device.id
279 actions = get_episode_actions(user._id, since, until, **args)
281 if version == 1:
282 actions = imap(convert_position, actions)
284 clean_data = partial(clean_episode_action_data,
285 user=user, devices=devices)
287 actions = map(clean_data, actions)
288 actions = filter(None, actions)
290 if aggregated:
291 actions = dict( (a['episode'], a) for a in actions ).values()
293 return {'actions': actions, 'timestamp': until}
298 def clean_episode_action_data(action, user, devices):
300 if None in (action.get('podcast', None), action.get('episode', None)):
301 return None
303 if 'device_id' in action:
304 device_id = action['device_id']
305 device_uid = devices.get(device_id)
306 if device_uid:
307 action['device'] = device_uid
309 del action['device_id']
311 # remove superfluous keys
312 for x in action.keys():
313 if x not in EPISODE_ACTION_KEYS:
314 del action[x]
316 # set missing keys to None
317 for x in EPISODE_ACTION_KEYS:
318 if x not in action:
319 action[x] = None
321 if action['action'] != 'play':
322 if 'position' in action:
323 del action['position']
325 if 'total' in action:
326 del action['total']
328 if 'started' in action:
329 del action['started']
331 if 'playmark' in action:
332 del action['playmark']
334 else:
335 action['position'] = action.get('position', False) or 0
337 return action
343 def update_episodes(user, actions, now, ua_string):
344 update_urls = []
346 grouped_actions = defaultdict(list)
348 # group all actions by their episode
349 for action in actions:
351 podcast_url = action['podcast']
352 podcast_url = sanitize_append(podcast_url, 'podcast', update_urls)
353 if podcast_url == '':
354 continue
356 episode_url = action['episode']
357 episode_url = sanitize_append(episode_url, 'episode', update_urls)
358 if episode_url == '':
359 continue
361 act = parse_episode_action(action, user, update_urls, now, ua_string)
362 grouped_actions[ (podcast_url, episode_url) ].append(act)
365 auto_flattr_episodes = []
367 # Prepare the updates for each episode state
368 obj_funs = []
370 for (p_url, e_url), action_list in grouped_actions.iteritems():
371 episode_state = episode_state_for_ref_urls(user, p_url, e_url)
373 if any(a['action'] == 'play' for a in actions):
374 auto_flattr_episodes.append(episode_state.episode)
376 fun = partial(update_episode_actions, action_list=action_list)
377 obj_funs.append( (episode_state, fun) )
379 bulk_save_retry(obj_funs)
381 if user.get_wksetting(FLATTR_AUTO):
382 for episode_id in auto_flattr_episodes:
383 auto_flattr_episode.delay(user, episode_id)
385 return update_urls
388 def update_episode_actions(episode_state, action_list):
389 """ Adds actions to the episode state and saves if necessary """
391 len1 = len(episode_state.actions)
392 episode_state.add_actions(action_list)
394 if len(episode_state.actions) == len1:
395 return None
397 return episode_state
401 def parse_episode_action(action, user, update_urls, now, ua_string):
402 action_str = action.get('action', None)
403 if not valid_episodeaction(action_str):
404 raise Exception('invalid action %s' % action_str)
406 new_action = EpisodeAction()
408 new_action.action = action['action']
410 if action.get('device', False):
411 device = get_device(user, action['device'], ua_string)
412 new_action.device = device.id
414 if action.get('timestamp', False):
415 new_action.timestamp = dateutil.parser.parse(action['timestamp'])
416 else:
417 new_action.timestamp = now
418 new_action.timestamp = new_action.timestamp.replace(microsecond=0)
420 new_action.upload_timestamp = get_timestamp(now)
422 new_action.started = action.get('started', None)
423 new_action.playmark = action.get('position', None)
424 new_action.total = action.get('total', None)
426 return new_action
429 @csrf_exempt
430 @require_valid_user
431 @check_username
432 @never_cache
433 # Workaround for mygpoclient 1.0: It uses "PUT" requests
434 # instead of "POST" requests for uploading device settings
435 @allowed_methods(['POST', 'PUT'])
436 def device(request, username, device_uid):
437 d = get_device(request.user, device_uid,
438 request.META.get('HTTP_USER_AGENT', ''))
440 data = json.loads(request.body)
442 if 'caption' in data:
443 if not data['caption']:
444 return HttpResponseBadRequest('caption must not be empty')
445 d.name = data['caption']
447 if 'type' in data:
448 if not valid_devicetype(data['type']):
449 return HttpResponseBadRequest('invalid device type %s' % data['type'])
450 d.type = data['type']
453 request.user.update_device(d)
455 return HttpResponse()
458 def valid_devicetype(type):
459 for t in DEVICE_TYPES:
460 if t[0] == type:
461 return True
462 return False
464 def valid_episodeaction(type):
465 for t in EPISODE_ACTION_TYPES:
466 if t[0] == type:
467 return True
468 return False
471 @csrf_exempt
472 @require_valid_user
473 @check_username
474 @never_cache
475 @allowed_methods(['GET'])
476 def devices(request, username):
477 devices = filter(lambda d: not d.deleted, request.user.devices)
478 devices = map(device_data, devices)
479 return JsonResponse(devices)
482 def device_data(device):
483 return dict(
484 id = device.uid,
485 caption = device.name,
486 type = device.type,
487 subscriptions= len(subscribed_podcast_ids_by_device(device)),
492 def get_podcast_data(podcasts, domain, url):
493 """ Gets podcast data for a URL from a dict of podcasts """
494 podcast = podcasts.get(url)
495 return podcast_data(podcast, domain)
498 def get_episode_data(podcasts, domain, clean_action_data, include_actions, episode_status):
499 """ Get episode data for an episode status object """
500 podcast_id = episode_status.episode.podcast
501 podcast = podcasts.get(podcast_id, None)
502 t = episode_data(episode_status.episode, domain, podcast)
503 t['status'] = episode_status.status
505 # include latest action (bug 1419)
506 if include_actions and episode_status.action:
507 t['action'] = clean_action_data(episode_status.action)
509 return t
513 class DeviceUpdates(View):
515 @method_decorator(csrf_exempt)
516 @method_decorator(require_valid_user)
517 @method_decorator(check_username)
518 @method_decorator(never_cache)
519 def get(self, request, username, device_uid):
520 now = datetime.now()
521 now_ = get_timestamp(now)
523 try:
524 device = request.user.get_device_by_uid(device_uid)
525 except DeviceDoesNotExist as e:
526 return HttpResponseNotFound(str(e))
528 since_ = request.GET.get('since', None)
529 if since_ is None:
530 return HttpResponseBadRequest('parameter since missing')
531 try:
532 since = datetime.fromtimestamp(float(since_))
533 except ValueError:
534 return HttpResponseBadRequest("'since' is not a valid timestamp")
536 include_actions = parse_bool(request.GET.get('include_actions', False))
538 ret = get_subscription_changes(request.user, device, since, now)
539 domain = RequestSite(request).domain
541 subscriptions = list(device.get_subscribed_podcasts())
543 podcasts = dict( (p.url, p) for p in subscriptions )
544 prepare_podcast_data = partial(get_podcast_data, podcasts, domain)
546 ret['add'] = map(prepare_podcast_data, ret['add'])
548 devices = dict( (dev.id, dev.uid) for dev in request.user.devices )
549 clean_action_data = partial(clean_episode_action_data,
550 user=request.user, devices=devices)
552 # index subscribed podcasts by their Id for fast access
553 podcasts = dict( (p.get_id(), p) for p in subscriptions )
554 prepare_episode_data = partial(get_episode_data, podcasts, domain,
555 clean_action_data, include_actions)
557 episode_updates = self.get_episode_updates(request.user,
558 subscriptions, since)
559 ret['updates'] = map(prepare_episode_data, episode_updates)
561 return JsonResponse(ret)
564 def get_episode_updates(self, user, subscribed_podcasts, since,
565 max_per_podcast=5):
566 """ Returns the episode updates since the timestamp """
568 EpisodeStatus = namedtuple('EpisodeStatus', 'episode status action')
570 episode_status = {}
572 # get episodes
573 if gevent:
574 episode_jobs = [gevent.spawn(episodes_for_podcast, p, since,
575 limit=max_per_podcast) for p in subscribed_podcasts]
576 gevent.joinall(episode_jobs)
577 episodes = chain.from_iterable(job.get() for job in episode_jobs)
579 else:
580 episodes = chain.from_iterable(episodes_for_podcast(p, since,
581 limit=max_per_podcast) for p in subscribed_podcasts)
584 for episode in episodes:
585 episode_status[episode._id] = EpisodeStatus(episode, 'new', None)
588 # get episode states
589 if gevent:
590 e_action_jobs = [gevent.spawn(get_podcasts_episode_states, p,
591 user._id) for p in subscribed_podcasts]
592 gevent.joinall(e_action_jobs)
593 e_actions = chain.from_iterable(job.get() for job in e_action_jobs)
595 else:
596 e_actions = chain.from_iterable(get_podcasts_episode_states(p,
597 user._id) for p in subscribed_podcasts)
600 for action in e_actions:
601 e_id = action['episode_id']
603 if e_id in episode_status:
604 episode = episode_status[e_id].episode
605 else:
606 episode = episode_by_id(e_id)
608 episode_status[e_id] = EpisodeStatus(episode, action['action'],
609 action)
611 return episode_status.itervalues()
614 @require_valid_user
615 @check_username
616 @never_cache
617 def favorites(request, username):
618 favorites = favorite_episodes_for_user(request.user)
619 domain = RequestSite(request).domain
620 e_data = lambda e: episode_data(e, domain)
621 ret = map(e_data, favorites)
622 return JsonResponse(ret)
625 def sanitize_append(url, obj_type, sanitized_list):
626 urls = sanitize_url(url, obj_type)
627 if url != urls:
628 sanitized_list.append( (url, urls) )
629 return urls