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
, namedtuple
21 from datetime
import datetime
22 from importlib
import import_module
24 import dateutil
.parser
26 from django
.http
import HttpResponse
, HttpResponseBadRequest
, Http404
, HttpResponseNotFound
27 from django
.contrib
.sites
.models
import RequestSite
28 from django
.views
.decorators
.csrf
import csrf_exempt
29 from django
.views
.decorators
.cache
import never_cache
30 from django
.conf
import settings
as dsettings
32 from mygpo
.api
.constants
import EPISODE_ACTION_TYPES
, DEVICE_TYPES
33 from mygpo
.api
.httpresponse
import JsonResponse
34 from mygpo
.api
.advanced
.directory
import episode_data
35 from mygpo
.api
.backend
import get_device
, BulkSubscribe
36 from mygpo
.utils
import format_time
, parse_bool
, get_timestamp
, \
37 parse_request_body
, normalize_feed_url
38 from mygpo
.decorators
import allowed_methods
39 from mygpo
.core
.tasks
import auto_flattr_episode
40 from mygpo
.users
.models
import EpisodeAction
, \
41 DeviceDoesNotExist
, DeviceUIDException
, \
42 InvalidEpisodeActionAttributes
43 from mygpo
.users
.settings
import FLATTR_AUTO
44 from mygpo
.core
.json
import JSONDecodeError
45 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
46 from mygpo
.db
.couchdb
import BulkException
, bulk_save_retry
47 from mygpo
.db
.couchdb
.episode
import favorite_episodes_for_user
48 from mygpo
.db
.couchdb
.podcast
import podcast_for_url
49 from mygpo
.db
.couchdb
.podcast_state
import subscribed_podcast_ids_by_device
50 from mygpo
.db
.couchdb
.episode_state
import episode_state_for_ref_urls
, \
55 logger
= logging
.getLogger(__name__
)
58 # keys that are allowed in episode actions
59 EPISODE_ACTION_KEYS
= ('position', 'episode', 'action', 'device', 'timestamp',
60 'started', 'total', 'podcast')
67 @allowed_methods(['GET', 'POST'])
68 def subscriptions(request
, username
, device_uid
):
71 now_
= get_timestamp(now
)
73 if request
.method
== 'GET':
76 device
= request
.user
.get_device_by_uid(device_uid
)
77 except DeviceDoesNotExist
as e
:
78 return HttpResponseNotFound(str(e
))
80 since_
= request
.GET
.get('since', None)
82 return HttpResponseBadRequest('parameter since missing')
84 since
= datetime
.fromtimestamp(float(since_
))
86 return HttpResponseBadRequest('since-value is not a valid timestamp')
88 changes
= get_subscription_changes(request
.user
, device
, since
, now
)
90 return JsonResponse(changes
)
92 elif request
.method
== 'POST':
93 d
= get_device(request
.user
, device_uid
,
94 request
.META
.get('HTTP_USER_AGENT', ''))
97 return HttpResponseBadRequest('POST data must not be empty')
100 actions
= parse_request_body(request
)
101 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
102 msg
= (u
'Could not decode subscription update POST data for ' +
103 'user %s: %s') % (username
,
104 request
.body
.decode('ascii', errors
='replace'))
105 logger
.exception(msg
)
106 return HttpResponseBadRequest(msg
)
108 add
= actions
['add'] if 'add' in actions
else []
109 rem
= actions
['remove'] if 'remove' in actions
else []
111 add
= filter(None, add
)
112 rem
= filter(None, rem
)
115 update_urls
= update_subscriptions(request
.user
, d
, add
, rem
)
116 except ValueError, e
:
117 return HttpResponseBadRequest(e
)
119 return JsonResponse({
121 'update_urls': update_urls
,
125 def update_subscriptions(user
, device
, add
, remove
):
129 raise ValueError('can not add and remove %s at the same time' % a
)
131 add_s
= map(normalize_feed_url
, add
)
132 rem_s
= map(normalize_feed_url
, remove
)
134 assert len(add
) == len(add_s
) and len(remove
) == len(rem_s
)
136 updated_urls
= filter(lambda (a
, b
): a
!= b
, zip(add
+ remove
, add_s
+ rem_s
))
138 add_s
= filter(None, add_s
)
139 rem_s
= filter(None, rem_s
)
141 # If two different URLs (in add and remove) have
142 # been sanitized to the same, we ignore the removal
143 rem_s
= filter(lambda x
: x
not in add_s
, rem_s
)
145 subscriber
= BulkSubscribe(user
, device
)
148 subscriber
.add_action(a
, 'subscribe')
151 subscriber
.add_action(r
, 'unsubscribe')
155 except BulkException
as be
:
156 for err
in be
.errors
:
157 loger
.error('Advanced API: %(username)s: Updating subscription for '
158 '%(podcast_url)s on %(device_uid)s failed: '
159 '%(rerror)s (%(reason)s)'.format(username
=user
.username
,
160 podcast_url
=err
.doc
, device_uid
=device
.uid
,
161 error
=err
.error
, reason
=err
.reason
)
167 def get_subscription_changes(user
, device
, since
, until
):
168 add_urls
, rem_urls
= device
.get_subscription_changes(since
, until
)
169 until_
= get_timestamp(until
)
170 return {'add': add_urls
, 'remove': rem_urls
, 'timestamp': until_
}
177 @allowed_methods(['GET', 'POST'])
178 def episodes(request
, username
, version
=1):
180 version
= int(version
)
182 now_
= get_timestamp(now
)
183 ua_string
= request
.META
.get('HTTP_USER_AGENT', '')
185 if request
.method
== 'POST':
187 actions
= parse_request_body(request
)
188 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
189 msg
= ('Could not decode episode update POST data for ' +
190 'user %s: %s') % (username
,
191 request
.body
.decode('ascii', errors
='replace'))
192 logger
.exception(msg
)
193 return HttpResponseBadRequest(msg
)
195 logger
.info('start: user %s: %d actions from %s' % (request
.user
._id
, len(actions
), ua_string
))
197 # handle in background
198 if len(actions
) > dsettings
.API_ACTIONS_MAX_NONBG
:
199 bg_handler
= dsettings
.API_ACTIONS_BG_HANDLER
200 if bg_handler
is not None:
202 modname
, funname
= bg_handler
.rsplit('.', 1)
203 mod
= import_module(modname
)
204 fun
= getattr(mod
, funname
)
206 fun(request
.user
, actions
, now
, ua_string
)
208 # TODO: return 202 Accepted
209 return JsonResponse({'timestamp': now_
, 'update_urls': []})
213 update_urls
= update_episodes(request
.user
, actions
, now
, ua_string
)
214 except DeviceUIDException
as e
:
215 logger
.warn('invalid device UID while uploading episode actions for user %s', username
)
216 return HttpResponseBadRequest(str(e
))
218 except InvalidEpisodeActionAttributes
as e
:
219 logger
.exception('invalid episode action attributes while uploading episode actions for user %s: %s' % (username
,))
220 return HttpResponseBadRequest(str(e
))
222 logger
.info('done: user %s: %d actions from %s' % (request
.user
._id
, len(actions
), ua_string
))
223 return JsonResponse({'timestamp': now_
, 'update_urls': update_urls
})
225 elif request
.method
== 'GET':
226 podcast_url
= request
.GET
.get('podcast', None)
227 device_uid
= request
.GET
.get('device', None)
228 since_
= request
.GET
.get('since', None)
229 aggregated
= parse_bool(request
.GET
.get('aggregated', False))
232 since
= int(since_
) if since_
else None
234 return HttpResponseBadRequest('since-value is not a valid timestamp')
237 podcast
= podcast_for_url(podcast_url
)
246 device
= request
.user
.get_device_by_uid(device_uid
)
247 except DeviceDoesNotExist
as e
:
248 return HttpResponseNotFound(str(e
))
253 changes
= get_episode_changes(request
.user
, podcast
, device
, since
,
254 now_
, aggregated
, version
)
256 return JsonResponse(changes
)
260 def convert_position(action
):
261 """ convert position parameter for API 1 compatibility """
262 pos
= getattr(action
, 'position', None)
264 action
.position
= format_time(pos
)
269 def get_episode_changes(user
, podcast
, device
, since
, until
, aggregated
, version
):
271 devices
= dict( (dev
.id, dev
.uid
) for dev
in user
.devices
)
274 if podcast
is not None:
275 args
['podcast_id'] = podcast
.get_id()
277 if device
is not None:
278 args
['device_id'] = device
.id
280 actions
= get_episode_actions(user
._id
, since
, until
, **args
)
283 actions
= imap(convert_position
, actions
)
285 clean_data
= partial(clean_episode_action_data
,
286 user
=user
, devices
=devices
)
288 actions
= map(clean_data
, actions
)
289 actions
= filter(None, actions
)
292 actions
= dict( (a
['episode'], a
) for a
in actions
).values()
294 return {'actions': actions
, 'timestamp': until
}
299 def clean_episode_action_data(action
, user
, devices
):
301 if None in (action
.get('podcast', None), action
.get('episode', None)):
304 if 'device_id' in action
:
305 device_id
= action
['device_id']
306 device_uid
= devices
.get(device_id
)
308 action
['device'] = device_uid
310 del action
['device_id']
312 # remove superfluous keys
313 for x
in action
.keys():
314 if x
not in EPISODE_ACTION_KEYS
:
317 # set missing keys to None
318 for x
in EPISODE_ACTION_KEYS
:
322 if action
['action'] != 'play':
323 if 'position' in action
:
324 del action
['position']
326 if 'total' in action
:
329 if 'started' in action
:
330 del action
['started']
332 if 'playmark' in action
:
333 del action
['playmark']
336 action
['position'] = action
.get('position', False) or 0
344 def update_episodes(user
, actions
, now
, ua_string
):
347 grouped_actions
= defaultdict(list)
349 # group all actions by their episode
350 for action
in actions
:
352 podcast_url
= action
['podcast']
353 podcast_url
= sanitize_append(podcast_url
, update_urls
)
354 if podcast_url
== '':
357 episode_url
= action
['episode']
358 episode_url
= sanitize_append(episode_url
, update_urls
)
359 if episode_url
== '':
362 act
= parse_episode_action(action
, user
, update_urls
, now
, ua_string
)
363 grouped_actions
[ (podcast_url
, episode_url
) ].append(act
)
366 auto_flattr_episodes
= []
368 # Prepare the updates for each episode state
371 for (p_url
, e_url
), action_list
in grouped_actions
.iteritems():
372 episode_state
= episode_state_for_ref_urls(user
, p_url
, e_url
)
374 if any(a
['action'] == 'play' for a
in actions
):
375 auto_flattr_episodes
.append(episode_state
.episode
)
377 fun
= partial(update_episode_actions
, action_list
=action_list
)
378 obj_funs
.append( (episode_state
, fun
) )
380 bulk_save_retry(obj_funs
)
382 if user
.get_wksetting(FLATTR_AUTO
):
383 for episode_id
in auto_flattr_episodes
:
384 auto_flattr_episode
.delay(user
, episode_id
)
389 def update_episode_actions(episode_state
, action_list
):
390 """ Adds actions to the episode state and saves if necessary """
392 len1
= len(episode_state
.actions
)
393 episode_state
.add_actions(action_list
)
395 if len(episode_state
.actions
) == len1
:
402 def parse_episode_action(action
, user
, update_urls
, now
, ua_string
):
403 action_str
= action
.get('action', None)
404 if not valid_episodeaction(action_str
):
405 raise Exception('invalid action %s' % action_str
)
407 new_action
= EpisodeAction()
409 new_action
.action
= action
['action']
411 if action
.get('device', False):
412 device
= get_device(user
, action
['device'], ua_string
)
413 new_action
.device
= device
.id
415 if action
.get('timestamp', False):
416 new_action
.timestamp
= dateutil
.parser
.parse(action
['timestamp'])
418 new_action
.timestamp
= now
419 new_action
.timestamp
= new_action
.timestamp
.replace(microsecond
=0)
421 new_action
.upload_timestamp
= get_timestamp(now
)
423 new_action
.started
= action
.get('started', None)
424 new_action
.playmark
= action
.get('position', None)
425 new_action
.total
= action
.get('total', None)
434 # Workaround for mygpoclient 1.0: It uses "PUT" requests
435 # instead of "POST" requests for uploading device settings
436 @allowed_methods(['POST', 'PUT'])
437 def device(request
, username
, device_uid
):
438 d
= get_device(request
.user
, device_uid
,
439 request
.META
.get('HTTP_USER_AGENT', ''))
442 data
= parse_request_body(request
)
443 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
444 msg
= ('Could not decode device update POST data for ' +
445 'user %s: %s') % (username
,
446 request
.body
.decode('ascii', errors
='replace'))
447 logger
.exception(msg
)
448 return HttpResponseBadRequest(msg
)
450 if 'caption' in data
:
451 if not data
['caption']:
452 return HttpResponseBadRequest('caption must not be empty')
453 d
.name
= data
['caption']
456 if not valid_devicetype(data
['type']):
457 return HttpResponseBadRequest('invalid device type %s' % data
['type'])
458 d
.type = data
['type']
461 request
.user
.update_device(d
)
463 return HttpResponse()
466 def valid_devicetype(type):
467 for t
in DEVICE_TYPES
:
472 def valid_episodeaction(type):
473 for t
in EPISODE_ACTION_TYPES
:
483 @allowed_methods(['GET'])
484 def devices(request
, username
):
485 devices
= filter(lambda d
: not d
.deleted
, request
.user
.devices
)
486 devices
= map(device_data
, devices
)
487 return JsonResponse(devices
)
490 def device_data(device
):
493 caption
= device
.name
,
495 subscriptions
= len(subscribed_podcast_ids_by_device(device
)),
502 def favorites(request
, username
):
503 favorites
= favorite_episodes_for_user(request
.user
)
504 domain
= RequestSite(request
).domain
505 e_data
= lambda e
: episode_data(e
, domain
)
506 ret
= map(e_data
, favorites
)
507 return JsonResponse(ret
)
510 def sanitize_append(url
, sanitized_list
):
511 urls
= normalize_feed_url(url
)
513 sanitized_list
.append( (url
, urls
or '') )