remove now unused {% load url from future %}
[mygpo.git] / mygpo / api / advanced / __init__.py
blobd8ece382cc9dccb9c958ea887d3c148bc5315f55
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
23 import dateutil.parser
25 try:
26 import gevent
27 except ImportError:
28 gevent = None
30 from django.http import HttpResponse, HttpResponseBadRequest, Http404, HttpResponseNotFound
31 from django.contrib.sites.models import RequestSite
32 from django.views.decorators.csrf import csrf_exempt
33 from django.views.decorators.cache import never_cache
34 from django.utils.decorators import method_decorator
35 from django.views.generic.base import View
37 from mygpo.api.constants import EPISODE_ACTION_TYPES, DEVICE_TYPES
38 from mygpo.api.httpresponse import JsonResponse
39 from mygpo.api.sanitizing import sanitize_url, sanitize_urls
40 from mygpo.api.advanced.directory import episode_data, podcast_data
41 from mygpo.api.backend import get_device, BulkSubscribe
42 from mygpo.log import log
43 from mygpo.utils import parse_time, format_time, parse_bool, get_timestamp
44 from mygpo.decorators import allowed_methods, repeat_on_conflict
45 from mygpo.core import models
46 from mygpo.core.models import SanitizingRule, Podcast
47 from mygpo.core.tasks import auto_flattr_episode
48 from mygpo.users.models import PodcastUserState, EpisodeAction, \
49 EpisodeUserState, DeviceDoesNotExist, DeviceUIDException, \
50 InvalidEpisodeActionAttributes
51 from mygpo.users.settings import FLATTR_AUTO
52 from mygpo.core.json import json, JSONDecodeError
53 from mygpo.api.basic_auth import require_valid_user, check_username
54 from mygpo.db.couchdb import BulkException, bulk_save_retry
55 from mygpo.db.couchdb.episode import episode_by_id, \
56 favorite_episodes_for_user, episodes_for_podcast
57 from mygpo.db.couchdb.podcast import podcast_for_url
58 from mygpo.db.couchdb.podcast_state import subscribed_podcast_ids_by_device
59 from mygpo.db.couchdb.episode_state import get_podcasts_episode_states, \
60 episode_state_for_ref_urls, get_episode_actions
63 # keys that are allowed in episode actions
64 EPISODE_ACTION_KEYS = ('position', 'episode', 'action', 'device', 'timestamp',
65 'started', 'total', 'podcast')
68 @csrf_exempt
69 @require_valid_user
70 @check_username
71 @never_cache
72 @allowed_methods(['GET', 'POST'])
73 def subscriptions(request, username, device_uid):
75 now = datetime.now()
76 now_ = get_timestamp(now)
78 if request.method == 'GET':
80 try:
81 device = request.user.get_device_by_uid(device_uid)
82 except DeviceDoesNotExist as e:
83 return HttpResponseNotFound(str(e))
85 since_ = request.GET.get('since', None)
86 if since_ == None:
87 return HttpResponseBadRequest('parameter since missing')
88 try:
89 since = datetime.fromtimestamp(float(since_))
90 except ValueError:
91 return HttpResponseBadRequest('since-value is not a valid timestamp')
93 changes = get_subscription_changes(request.user, device, since, now)
95 return JsonResponse(changes)
97 elif request.method == 'POST':
98 d = get_device(request.user, device_uid,
99 request.META.get('HTTP_USER_AGENT', ''))
101 if not request.body:
102 return HttpResponseBadRequest('POST data must not be empty')
104 actions = json.loads(request.body)
105 add = actions['add'] if 'add' in actions else []
106 rem = actions['remove'] if 'remove' in actions else []
108 add = filter(None, add)
109 rem = filter(None, rem)
111 try:
112 update_urls = update_subscriptions(request.user, d, add, rem)
113 except ValueError, e:
114 return HttpResponseBadRequest(e)
116 return JsonResponse({
117 'timestamp': now_,
118 'update_urls': update_urls,
122 def update_subscriptions(user, device, add, remove):
124 for a in add:
125 if a in remove:
126 raise ValueError('can not add and remove %s at the same time' % a)
128 add_s = list(sanitize_urls(add, 'podcast'))
129 rem_s = list(sanitize_urls(remove, 'podcast'))
131 assert len(add) == len(add_s) and len(remove) == len(rem_s)
133 updated_urls = filter(lambda (a, b): a != b, zip(add + remove, add_s + rem_s))
135 add_s = filter(None, add_s)
136 rem_s = filter(None, rem_s)
138 # If two different URLs (in add and remove) have
139 # been sanitized to the same, we ignore the removal
140 rem_s = filter(lambda x: x not in add_s, rem_s)
142 subscriber = BulkSubscribe(user, device)
144 for a in add_s:
145 subscriber.add_action(a, 'subscribe')
147 for r in rem_s:
148 subscriber.add_action(r, 'unsubscribe')
150 try:
151 subscriber.execute()
152 except BulkException as be:
153 for err in be.errors:
154 log('Advanced API: %(username)s: Updating subscription for '
155 '%(podcast_url)s on %(device_uid)s failed: '
156 '%(rerror)s (%(reason)s)'.format(username=user.username,
157 podcast_url=err.doc, device_uid=device.uid,
158 error=err.error, reason=err.reason)
161 return updated_urls
164 def get_subscription_changes(user, device, since, until):
165 add_urls, rem_urls = device.get_subscription_changes(since, until)
166 until_ = get_timestamp(until)
167 return {'add': add_urls, 'remove': rem_urls, 'timestamp': until_}
170 @csrf_exempt
171 @require_valid_user
172 @check_username
173 @never_cache
174 @allowed_methods(['GET', 'POST'])
175 def episodes(request, username, version=1):
177 version = int(version)
178 now = datetime.now()
179 now_ = get_timestamp(now)
180 ua_string = request.META.get('HTTP_USER_AGENT', '')
182 if request.method == 'POST':
183 try:
184 actions = json.loads(request.body)
185 except (JSONDecodeError, UnicodeDecodeError) as e:
186 msg = 'Advanced API: could not decode episode update POST data for user %s: %s' % (username, e)
187 log(msg)
188 return HttpResponseBadRequest(msg)
190 try:
191 update_urls = update_episodes(request.user, actions, now, ua_string)
192 except DeviceUIDException as e:
193 import traceback
194 log('could not update episodes for user %s: %s %s: %s' % (username, e, traceback.format_exc(), actions))
195 return HttpResponseBadRequest(str(e))
196 except InvalidEpisodeActionAttributes as e:
197 import traceback
198 log('could not update episodes for user %s: %s %s: %s' % (username, e, traceback.format_exc(), actions))
199 return HttpResponseBadRequest(str(e))
201 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
203 elif request.method == 'GET':
204 podcast_url= request.GET.get('podcast', None)
205 device_uid = request.GET.get('device', None)
206 since_ = request.GET.get('since', None)
207 aggregated = parse_bool(request.GET.get('aggregated', False))
209 try:
210 since = int(since_) if since_ else None
211 except ValueError:
212 return HttpResponseBadRequest('since-value is not a valid timestamp')
214 if podcast_url:
215 podcast = podcast_for_url(podcast_url)
216 if not podcast:
217 raise Http404
218 else:
219 podcast = None
221 if device_uid:
223 try:
224 device = request.user.get_device_by_uid(device_uid)
225 except DeviceDoesNotExist as e:
226 return HttpResponseNotFound(str(e))
228 else:
229 device = None
231 changes = get_episode_changes(request.user, podcast, device, since,
232 now_, aggregated, version)
234 return JsonResponse(changes)
238 def convert_position(action):
239 """ convert position parameter for API 1 compatibility """
240 pos = getattr(action, 'position', None)
241 if pos is not None:
242 action.position = format_time(pos)
243 return action
247 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
249 devices = dict( (dev.id, dev.uid) for dev in user.devices )
251 args = {}
252 if podcast is not None:
253 args['podcast_id'] = podcast.get_id()
255 if device is not None:
256 args['device_id'] = device.id
258 actions = get_episode_actions(user._id, since, until, **args)
260 if version == 1:
261 actions = imap(convert_position, actions)
263 clean_data = partial(clean_episode_action_data,
264 user=user, devices=devices)
266 actions = map(clean_data, actions)
267 actions = filter(None, actions)
269 if aggregated:
270 actions = dict( (a['episode'], a) for a in actions ).values()
272 return {'actions': actions, 'timestamp': until}
277 def clean_episode_action_data(action, user, devices):
279 if None in (action.get('podcast', None), action.get('episode', None)):
280 return None
282 if 'device_id' in action:
283 device_id = action['device_id']
284 device_uid = devices.get(device_id)
285 if device_uid:
286 action['device'] = device_uid
288 del action['device_id']
290 # remove superfluous keys
291 for x in action.keys():
292 if x not in EPISODE_ACTION_KEYS:
293 del action[x]
295 # set missing keys to None
296 for x in EPISODE_ACTION_KEYS:
297 if x not in action:
298 action[x] = None
300 if action['action'] != 'play':
301 if 'position' in action:
302 del action['position']
304 if 'total' in action:
305 del action['total']
307 if 'started' in action:
308 del action['started']
310 if 'playmark' in action:
311 del action['playmark']
313 else:
314 action['position'] = action.get('position', False) or 0
316 return action
322 def update_episodes(user, actions, now, ua_string):
323 update_urls = []
325 grouped_actions = defaultdict(list)
327 # group all actions by their episode
328 for action in actions:
330 podcast_url = action['podcast']
331 podcast_url = sanitize_append(podcast_url, 'podcast', update_urls)
332 if podcast_url == '': continue
334 episode_url = action['episode']
335 episode_url = sanitize_append(episode_url, 'episode', update_urls)
336 if episode_url == '': continue
338 act = parse_episode_action(action, user, update_urls, now, ua_string)
339 grouped_actions[ (podcast_url, episode_url) ].append(act)
342 auto_flattr_episodes = []
344 # Prepare the updates for each episode state
345 obj_funs = []
347 for (p_url, e_url), action_list in grouped_actions.iteritems():
348 episode_state = episode_state_for_ref_urls(user, p_url, e_url)
350 if any(a['action'] == 'play' for a in actions):
351 auto_flattr_episodes.append(episode_state.episode)
353 fun = partial(update_episode_actions, action_list=action_list)
354 obj_funs.append( (episode_state, fun) )
356 bulk_save_retry(obj_funs)
358 if user.get_wksetting(FLATTR_AUTO):
359 for episode_id in auto_flattr_episodes:
360 auto_flattr_episode.delay(user, episode_id)
362 return update_urls
365 def update_episode_actions(episode_state, action_list):
366 """ Adds actions to the episode state and saves if necessary """
368 len1 = len(episode_state.actions)
369 episode_state.add_actions(action_list)
371 if len(episode_state.actions) == len1:
372 return None
374 return episode_state
378 def parse_episode_action(action, user, update_urls, now, ua_string):
379 action_str = action.get('action', None)
380 if not valid_episodeaction(action_str):
381 raise Exception('invalid action %s' % action_str)
383 new_action = EpisodeAction()
385 new_action.action = action['action']
387 if action.get('device', False):
388 device = get_device(user, action['device'], ua_string)
389 new_action.device = device.id
391 if action.get('timestamp', False):
392 new_action.timestamp = dateutil.parser.parse(action['timestamp'])
393 else:
394 new_action.timestamp = now
395 new_action.timestamp = new_action.timestamp.replace(microsecond=0)
397 new_action.upload_timestamp = get_timestamp(now)
399 new_action.started = action.get('started', None)
400 new_action.playmark = action.get('position', None)
401 new_action.total = action.get('total', None)
403 return new_action
406 @csrf_exempt
407 @require_valid_user
408 @check_username
409 @never_cache
410 # Workaround for mygpoclient 1.0: It uses "PUT" requests
411 # instead of "POST" requests for uploading device settings
412 @allowed_methods(['POST', 'PUT'])
413 def device(request, username, device_uid):
414 d = get_device(request.user, device_uid,
415 request.META.get('HTTP_USER_AGENT', ''))
417 data = json.loads(request.body)
419 if 'caption' in data:
420 if not data['caption']:
421 return HttpResponseBadRequest('caption must not be empty')
422 d.name = data['caption']
424 if 'type' in data:
425 if not valid_devicetype(data['type']):
426 return HttpResponseBadRequest('invalid device type %s' % data['type'])
427 d.type = data['type']
430 request.user.update_device(d)
432 return HttpResponse()
435 def valid_devicetype(type):
436 for t in DEVICE_TYPES:
437 if t[0] == type:
438 return True
439 return False
441 def valid_episodeaction(type):
442 for t in EPISODE_ACTION_TYPES:
443 if t[0] == type:
444 return True
445 return False
448 @csrf_exempt
449 @require_valid_user
450 @check_username
451 @never_cache
452 @allowed_methods(['GET'])
453 def devices(request, username):
454 devices = filter(lambda d: not d.deleted, request.user.devices)
455 devices = map(device_data, devices)
456 return JsonResponse(devices)
459 def device_data(device):
460 return dict(
461 id = device.uid,
462 caption = device.name,
463 type = device.type,
464 subscriptions= len(subscribed_podcast_ids_by_device(device)),
469 def get_podcast_data(podcasts, domain, url):
470 """ Gets podcast data for a URL from a dict of podcasts """
471 podcast = podcasts.get(url)
472 return podcast_data(podcast, domain)
475 def get_episode_data(podcasts, domain, clean_action_data, include_actions, episode_status):
476 """ Get episode data for an episode status object """
477 podcast_id = episode_status.episode.podcast
478 podcast = podcasts.get(podcast_id, None)
479 t = episode_data(episode_status.episode, domain, podcast)
480 t['status'] = episode_status.status
482 # include latest action (bug 1419)
483 if include_actions and episode_status.action:
484 t['action'] = clean_action_data(episode_status.action)
486 return t
490 class DeviceUpdates(View):
492 @method_decorator(csrf_exempt)
493 @method_decorator(require_valid_user)
494 @method_decorator(check_username)
495 @method_decorator(never_cache)
496 def get(self, request, username, device_uid):
497 now = datetime.now()
498 now_ = get_timestamp(now)
500 try:
501 device = request.user.get_device_by_uid(device_uid)
502 except DeviceDoesNotExist as e:
503 return HttpResponseNotFound(str(e))
505 since_ = request.GET.get('since', None)
506 if since_ == None:
507 return HttpResponseBadRequest('parameter since missing')
508 try:
509 since = datetime.fromtimestamp(float(since_))
510 except ValueError:
511 return HttpResponseBadRequest("'since' is not a valid timestamp")
513 include_actions = parse_bool(request.GET.get('include_actions', False))
515 ret = get_subscription_changes(request.user, device, since, now)
516 domain = RequestSite(request).domain
518 subscriptions = list(device.get_subscribed_podcasts())
520 podcasts = dict( (p.url, p) for p in subscriptions )
521 prepare_podcast_data = partial(get_podcast_data, podcasts, domain)
523 ret['add'] = map(prepare_podcast_data, ret['add'])
525 devices = dict( (dev.id, dev.uid) for dev in request.user.devices )
526 clean_action_data = partial(clean_episode_action_data,
527 user=request.user, devices=devices)
529 # index subscribed podcasts by their Id for fast access
530 podcasts = dict( (p.get_id(), p) for p in subscriptions )
531 prepare_episode_data = partial(get_episode_data, podcasts, domain,
532 clean_action_data, include_actions)
534 episode_updates = self.get_episode_updates(request.user,
535 subscriptions, since)
536 ret['updates'] = map(prepare_episode_data, episode_updates)
538 return JsonResponse(ret)
541 def get_episode_updates(self, user, subscribed_podcasts, since,
542 max_per_podcast=5):
543 """ Returns the episode updates since the timestamp """
545 EpisodeStatus = namedtuple('EpisodeStatus', 'episode status action')
547 episode_status = {}
549 # get episodes
550 if gevent:
551 episode_jobs = [gevent.spawn(episodes_for_podcast, p, since,
552 limit=max_per_podcast) for p in subscribed_podcasts]
553 gevent.joinall(episode_jobs)
554 episodes = chain.from_iterable(job.get() for job in episode_jobs)
556 else:
557 episodes = chain.from_iterable(episodes_for_podcast(p, since,
558 limit=max_per_podcast) for p in subscribed_podcasts)
561 for episode in episodes:
562 episode_status[episode._id] = EpisodeStatus(episode, 'new', None)
565 # get episode states
566 if gevent:
567 e_action_jobs = [gevent.spawn(get_podcasts_episode_states, p,
568 user._id) for p in subscribed_podcasts]
569 gevent.joinall(e_action_jobs)
570 e_actions = chain.from_iterable(job.get() for job in e_action_jobs)
572 else:
573 e_actions = chain.from_iterable(get_podcasts_episode_states(p,
574 user._id) for p in subscribed_podcasts)
577 for action in e_actions:
578 e_id = action['episode_id']
580 if e_id in episode_status:
581 episode = episode_status[e_id].episode
582 else:
583 episode = episode_by_id(e_id)
585 episode_status[e_id] = EpisodeStatus(episode, action['action'],
586 action)
588 return episode_status.itervalues()
591 @require_valid_user
592 @check_username
593 @never_cache
594 def favorites(request, username):
595 favorites = favorite_episodes_for_user(request.user)
596 domain = RequestSite(request).domain
597 e_data = lambda e: episode_data(e, domain)
598 ret = map(e_data, favorites)
599 return JsonResponse(ret)
602 def sanitize_append(url, obj_type, sanitized_list):
603 urls = sanitize_url(url, obj_type)
604 if url != urls:
605 sanitized_list.append( (url, urls) )
606 return urls