[Migration] use migrated users
[mygpo.git] / mygpo / api / advanced / __init__.py
blob1e7257ef35369bc71abab8c436e784780d444ecc
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
20 from collections import defaultdict
21 from datetime import datetime
22 from importlib import import_module
24 import dateutil.parser
26 from django.http import (HttpResponse, HttpResponseBadRequest, Http404,
27 HttpResponseNotFound, )
28 from django.contrib.sites.models import RequestSite
29 from django.views.decorators.csrf import csrf_exempt
30 from django.views.decorators.cache import never_cache
31 from django.conf import settings as dsettings
32 from django.shortcuts import get_object_or_404
34 from mygpo.podcasts.models import Podcast, Episode
35 from mygpo.api.constants import EPISODE_ACTION_TYPES
36 from mygpo.api.httpresponse import JsonResponse
37 from mygpo.api.advanced.directory import episode_data
38 from mygpo.api.backend import get_device
39 from mygpo.utils import format_time, parse_bool, get_timestamp, \
40 parse_request_body, normalize_feed_url
41 from mygpo.decorators import allowed_methods, cors_origin
42 from mygpo.core.tasks import auto_flattr_episode
43 from mygpo.users.models import (EpisodeAction, DeviceDoesNotExist, Client,
44 DeviceUIDException,
45 InvalidEpisodeActionAttributes, )
46 from mygpo.users.settings import FLATTR_AUTO
47 from mygpo.core.json import JSONDecodeError
48 from mygpo.api.basic_auth import require_valid_user, check_username
49 from mygpo.db.couchdb import bulk_save_retry, get_userdata_database
50 from mygpo.db.couchdb.episode_state import favorite_episode_ids_for_user
52 from mygpo.db.couchdb.podcast_state import subscribed_podcast_ids_by_device
53 from mygpo.db.couchdb.episode_state import episode_state_for_ref_urls, \
54 get_episode_actions
55 from mygpo.db.couchdb.user import set_device
58 import logging
59 logger = logging.getLogger(__name__)
62 class RequestException(Exception):
63 """ Raised if the request is malfored or otherwise invalid """
66 # keys that are allowed in episode actions
67 EPISODE_ACTION_KEYS = ('position', 'episode', 'action', 'device', 'timestamp',
68 'started', 'total', 'podcast')
71 @csrf_exempt
72 @require_valid_user
73 @check_username
74 @never_cache
75 @allowed_methods(['GET', 'POST'])
76 @cors_origin()
77 def episodes(request, username, version=1):
79 version = int(version)
80 now = datetime.now()
81 now_ = get_timestamp(now)
82 ua_string = request.META.get('HTTP_USER_AGENT', '')
84 if request.method == 'POST':
85 try:
86 actions = parse_request_body(request)
87 except (JSONDecodeError, UnicodeDecodeError, ValueError) as e:
88 msg = ('Could not decode episode update POST data for ' +
89 'user %s: %s') % (username,
90 request.body.decode('ascii', errors='replace'))
91 logger.warn(msg, exc_info=True)
92 return HttpResponseBadRequest(msg)
94 logger.info('start: user %s: %d actions from %s' % (request.user, len(actions), ua_string))
96 # handle in background
97 if len(actions) > dsettings.API_ACTIONS_MAX_NONBG:
98 bg_handler = dsettings.API_ACTIONS_BG_HANDLER
99 if bg_handler is not None:
101 modname, funname = bg_handler.rsplit('.', 1)
102 mod = import_module(modname)
103 fun = getattr(mod, funname)
105 fun(request.user, actions, now, ua_string)
107 # TODO: return 202 Accepted
108 return JsonResponse({'timestamp': now_, 'update_urls': []})
111 try:
112 update_urls = update_episodes(request.user, actions, now, ua_string)
113 except DeviceUIDException as e:
114 logger.warn('invalid device UID while uploading episode actions for user %s', username)
115 return HttpResponseBadRequest(str(e))
117 except InvalidEpisodeActionAttributes as e:
118 msg = 'invalid episode action attributes while uploading episode actions for user %s' % (username,)
119 logger.warn(msg, exc_info=True)
120 return HttpResponseBadRequest(str(e))
122 logger.info('done: user %s: %d actions from %s' % (request.user, len(actions), ua_string))
123 return JsonResponse({'timestamp': now_, 'update_urls': update_urls})
125 elif request.method == 'GET':
126 podcast_url= request.GET.get('podcast', None)
127 device_uid = request.GET.get('device', None)
128 since_ = request.GET.get('since', None)
129 aggregated = parse_bool(request.GET.get('aggregated', False))
131 try:
132 since = int(since_) if since_ else None
133 except ValueError:
134 return HttpResponseBadRequest('since-value is not a valid timestamp')
136 if podcast_url:
137 podcast = get_object_or_404(Podcast, urls__url=podcast_url)
138 else:
139 podcast = None
141 if device_uid:
143 try:
144 device = request.user.get_device_by_uid(device_uid)
145 except DeviceDoesNotExist as e:
146 return HttpResponseNotFound(str(e))
148 else:
149 device = None
151 changes = get_episode_changes(request.user, podcast, device, since,
152 now_, aggregated, version)
154 return JsonResponse(changes)
158 def convert_position(action):
159 """ convert position parameter for API 1 compatibility """
160 pos = getattr(action, 'position', None)
161 if pos is not None:
162 action.position = format_time(pos)
163 return action
167 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
169 devices = {client.id.hex: client.uid for client in user.client_set.all()}
171 args = {}
172 if podcast is not None:
173 args['podcast_id'] = podcast.get_id()
175 if device is not None:
176 args['device_id'] = device.id.hex
178 actions, until = get_episode_actions(user.profile.uuid.hex, since, until, **args)
180 if version == 1:
181 actions = imap(convert_position, actions)
183 clean_data = partial(clean_episode_action_data,
184 user=user, devices=devices)
186 actions = map(clean_data, actions)
187 actions = filter(None, actions)
189 if aggregated:
190 actions = dict( (a['episode'], a) for a in actions ).values()
192 return {'actions': actions, 'timestamp': until}
197 def clean_episode_action_data(action, user, devices):
199 if None in (action.get('podcast', None), action.get('episode', None)):
200 return None
202 if 'device_id' in action:
203 device_id = action['device_id']
204 device_uid = devices.get(device_id)
205 if device_uid:
206 action['device'] = device_uid
208 del action['device_id']
210 # remove superfluous keys
211 for x in action.keys():
212 if x not in EPISODE_ACTION_KEYS:
213 del action[x]
215 # set missing keys to None
216 for x in EPISODE_ACTION_KEYS:
217 if x not in action:
218 action[x] = None
220 if action['action'] != 'play':
221 if 'position' in action:
222 del action['position']
224 if 'total' in action:
225 del action['total']
227 if 'started' in action:
228 del action['started']
230 if 'playmark' in action:
231 del action['playmark']
233 else:
234 action['position'] = action.get('position', False) or 0
236 return action
242 def update_episodes(user, actions, now, ua_string):
243 update_urls = []
245 grouped_actions = defaultdict(list)
247 # group all actions by their episode
248 for action in actions:
250 podcast_url = action['podcast']
251 podcast_url = sanitize_append(podcast_url, update_urls)
252 if podcast_url == '':
253 continue
255 episode_url = action['episode']
256 episode_url = sanitize_append(episode_url, update_urls)
257 if episode_url == '':
258 continue
260 act = parse_episode_action(action, user, update_urls, now, ua_string)
261 grouped_actions[ (podcast_url, episode_url) ].append(act)
264 auto_flattr_episodes = []
266 # Prepare the updates for each episode state
267 obj_funs = []
269 for (p_url, e_url), action_list in grouped_actions.iteritems():
270 episode_state = episode_state_for_ref_urls(user, p_url, e_url)
272 if any(a['action'] == 'play' for a in actions):
273 auto_flattr_episodes.append(episode_state.episode)
275 fun = partial(update_episode_actions, action_list=action_list)
276 obj_funs.append( (episode_state, fun) )
278 udb = get_userdata_database()
279 bulk_save_retry(obj_funs, udb)
281 if user.profile.get_wksetting(FLATTR_AUTO):
282 for episode_id in auto_flattr_episodes:
283 auto_flattr_episode.delay(user, episode_id)
285 return update_urls
288 def update_episode_actions(episode_state, action_list):
289 """ Adds actions to the episode state and saves if necessary """
291 len1 = len(episode_state.actions)
292 episode_state.add_actions(action_list)
294 if len(episode_state.actions) == len1:
295 return None
297 return episode_state
301 def parse_episode_action(action, user, update_urls, now, ua_string):
302 action_str = action.get('action', None)
303 if not valid_episodeaction(action_str):
304 raise Exception('invalid action %s' % action_str)
306 new_action = EpisodeAction()
308 new_action.action = action['action']
310 if action.get('device', False):
311 device = get_device(user, action['device'], ua_string)
312 new_action.device = device.id.hex
314 if action.get('timestamp', False):
315 new_action.timestamp = dateutil.parser.parse(action['timestamp'])
316 else:
317 new_action.timestamp = now
318 new_action.timestamp = new_action.timestamp.replace(microsecond=0)
320 new_action.upload_timestamp = get_timestamp(now)
322 new_action.started = action.get('started', None)
323 new_action.playmark = action.get('position', None)
324 new_action.total = action.get('total', None)
326 return new_action
329 @csrf_exempt
330 @require_valid_user
331 @check_username
332 @never_cache
333 # Workaround for mygpoclient 1.0: It uses "PUT" requests
334 # instead of "POST" requests for uploading device settings
335 @allowed_methods(['POST', 'PUT'])
336 @cors_origin()
337 def device(request, username, device_uid):
338 d = get_device(request.user, device_uid,
339 request.META.get('HTTP_USER_AGENT', ''))
341 try:
342 data = parse_request_body(request)
343 except (JSONDecodeError, UnicodeDecodeError, ValueError) as e:
344 msg = ('Could not decode device update POST data for ' +
345 'user %s: %s') % (username,
346 request.body.decode('ascii', errors='replace'))
347 logger.warn(msg, exc_info=True)
348 return HttpResponseBadRequest(msg)
350 if 'caption' in data:
351 if not data['caption']:
352 return HttpResponseBadRequest('caption must not be empty')
353 d.name = data['caption']
355 if 'type' in data:
356 if not valid_devicetype(data['type']):
357 return HttpResponseBadRequest('invalid device type %s' % data['type'])
358 d.type = data['type']
361 set_device(request.user, d)
363 return HttpResponse()
366 def valid_devicetype(type):
367 for t in Client.TYPES:
368 if t[0] == type:
369 return True
370 return False
372 def valid_episodeaction(type):
373 for t in EPISODE_ACTION_TYPES:
374 if t[0] == type:
375 return True
376 return False
379 @csrf_exempt
380 @require_valid_user
381 @check_username
382 @never_cache
383 @allowed_methods(['GET'])
384 @cors_origin()
385 def devices(request, username):
386 devices = filter(lambda d: not d.deleted, request.user.devices)
387 devices = map(device_data, devices)
388 return JsonResponse(devices)
391 def device_data(device):
392 return dict(
393 id = device.uid,
394 caption = device.name,
395 type = device.type,
396 subscriptions= len(subscribed_podcast_ids_by_device(device)),
400 @require_valid_user
401 @check_username
402 @never_cache
403 @cors_origin()
404 def favorites(request, username):
405 favorite_ids = favorite_episode_ids_for_user(request.user)
406 favorites = Episode.objects.get(id__in=favorite_ids)
407 domain = RequestSite(request).domain
408 e_data = lambda e: episode_data(e, domain)
409 ret = map(e_data, favorites)
410 return JsonResponse(ret)
413 def sanitize_append(url, sanitized_list):
414 urls = normalize_feed_url(url)
415 if url != urls:
416 sanitized_list.append( (url, urls or '') )
417 return urls