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
,
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
, \
55 from mygpo
.db
.couchdb
.user
import set_device
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')
75 @allowed_methods(['GET', 'POST'])
77 def episodes(request
, username
, version
=1):
79 version
= int(version
)
81 now_
= get_timestamp(now
)
82 ua_string
= request
.META
.get('HTTP_USER_AGENT', '')
84 if request
.method
== 'POST':
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': []})
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))
132 since
= int(since_
) if since_
else None
134 return HttpResponseBadRequest('since-value is not a valid timestamp')
137 podcast
= get_object_or_404(Podcast
, urls__url
=podcast_url
)
144 device
= request
.user
.get_device_by_uid(device_uid
)
145 except DeviceDoesNotExist
as e
:
146 return HttpResponseNotFound(str(e
))
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)
162 action
.position
= format_time(pos
)
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()}
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
)
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
)
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)):
202 if 'device_id' in action
:
203 device_id
= action
['device_id']
204 device_uid
= devices
.get(device_id
)
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
:
215 # set missing keys to None
216 for x
in EPISODE_ACTION_KEYS
:
220 if action
['action'] != 'play':
221 if 'position' in action
:
222 del action
['position']
224 if 'total' in action
:
227 if 'started' in action
:
228 del action
['started']
230 if 'playmark' in action
:
231 del action
['playmark']
234 action
['position'] = action
.get('position', False) or 0
242 def update_episodes(user
, actions
, now
, ua_string
):
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
== '':
255 episode_url
= action
['episode']
256 episode_url
= sanitize_append(episode_url
, update_urls
)
257 if episode_url
== '':
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
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
)
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
:
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'])
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)
333 # Workaround for mygpoclient 1.0: It uses "PUT" requests
334 # instead of "POST" requests for uploading device settings
335 @allowed_methods(['POST', 'PUT'])
337 def device(request
, username
, device_uid
):
338 d
= get_device(request
.user
, device_uid
,
339 request
.META
.get('HTTP_USER_AGENT', ''))
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']
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
:
372 def valid_episodeaction(type):
373 for t
in EPISODE_ACTION_TYPES
:
383 @allowed_methods(['GET'])
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
):
394 caption
= device
.name
,
396 subscriptions
= len(subscribed_podcast_ids_by_device(device
)),
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
)
416 sanitized_list
.append( (url
, urls
or '') )