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
.core
.exceptions
import ValidationError
29 from django
.contrib
.sites
.models
import RequestSite
30 from django
.views
.decorators
.csrf
import csrf_exempt
31 from django
.views
.decorators
.cache
import never_cache
32 from django
.conf
import settings
as dsettings
33 from django
.shortcuts
import get_object_or_404
35 from mygpo
.podcasts
.models
import Podcast
, Episode
36 from mygpo
.subscriptions
.models
import Subscription
37 from mygpo
.api
.constants
import EPISODE_ACTION_TYPES
38 from mygpo
.api
.httpresponse
import JsonResponse
39 from mygpo
.api
.advanced
.directory
import episode_data
40 from mygpo
.api
.backend
import get_device
41 from mygpo
.utils
import format_time
, parse_bool
, get_timestamp
, \
42 parse_request_body
, normalize_feed_url
43 from mygpo
.decorators
import allowed_methods
, cors_origin
44 from mygpo
.history
.models
import EpisodeHistoryEntry
45 from mygpo
.core
.tasks
import auto_flattr_episode
46 from mygpo
.users
.models
import (EpisodeAction
, Client
,
47 InvalidEpisodeActionAttributes
, )
48 from mygpo
.users
.settings
import FLATTR_AUTO
49 from mygpo
.favorites
.models
import FavoriteEpisode
50 from mygpo
.core
.json
import JSONDecodeError
51 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
55 logger
= logging
.getLogger(__name__
)
58 class RequestException(Exception):
59 """ Raised if the request is malfored or otherwise invalid """
62 # keys that are allowed in episode actions
63 EPISODE_ACTION_KEYS
= ('position', 'episode', 'action', 'device', 'timestamp',
64 'started', 'total', 'podcast')
71 @allowed_methods(['GET', 'POST'])
73 def episodes(request
, username
, version
=1):
75 version
= int(version
)
76 now
= datetime
.utcnow()
77 now_
= get_timestamp(now
)
78 ua_string
= request
.META
.get('HTTP_USER_AGENT', '')
80 if request
.method
== 'POST':
82 actions
= parse_request_body(request
)
83 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
84 msg
= ('Could not decode episode update POST data for ' +
85 'user %s: %s') % (username
,
86 request
.body
.decode('ascii', errors
='replace'))
87 logger
.warn(msg
, exc_info
=True)
88 return HttpResponseBadRequest(msg
)
90 logger
.info('start: user %s: %d actions from %s' % (request
.user
, len(actions
), ua_string
))
92 # handle in background
93 if len(actions
) > dsettings
.API_ACTIONS_MAX_NONBG
:
94 bg_handler
= dsettings
.API_ACTIONS_BG_HANDLER
95 if bg_handler
is not None:
97 modname
, funname
= bg_handler
.rsplit('.', 1)
98 mod
= import_module(modname
)
99 fun
= getattr(mod
, funname
)
101 fun(request
.user
, actions
, now
, ua_string
)
103 # TODO: return 202 Accepted
104 return JsonResponse({'timestamp': now_
, 'update_urls': []})
108 update_urls
= update_episodes(request
.user
, actions
, now
, ua_string
)
109 except ValidationError
as e
:
110 logger
.warn(u
'Validation Error while uploading episode actions for user %s: %s', username
, unicode(e
))
111 return HttpResponseBadRequest(str(e
))
113 except InvalidEpisodeActionAttributes
as e
:
114 msg
= 'invalid episode action attributes while uploading episode actions for user %s' % (username
,)
115 logger
.warn(msg
, exc_info
=True)
116 return HttpResponseBadRequest(str(e
))
118 logger
.info('done: user %s: %d actions from %s' % (request
.user
, len(actions
), ua_string
))
119 return JsonResponse({'timestamp': now_
, 'update_urls': update_urls
})
121 elif request
.method
== 'GET':
122 podcast_url
= request
.GET
.get('podcast', None)
123 device_uid
= request
.GET
.get('device', None)
124 since_
= request
.GET
.get('since', None)
125 aggregated
= parse_bool(request
.GET
.get('aggregated', False))
128 since
= int(since_
) if since_
else None
130 return HttpResponseBadRequest('since-value is not a valid timestamp')
133 podcast
= get_object_or_404(Podcast
, urls__url
=podcast_url
)
141 device
= user
.client_set
.get(uid
=device_uid
)
142 except Client
.DoesNotExist
as e
:
143 return HttpResponseNotFound(str(e
))
148 changes
= get_episode_changes(request
.user
, podcast
, device
, since
,
149 now
, aggregated
, version
)
151 return JsonResponse(changes
)
155 def convert_position(action
):
156 """ convert position parameter for API 1 compatibility """
157 pos
= getattr(action
, 'position', None)
159 action
.position
= format_time(pos
)
164 def get_episode_changes(user
, podcast
, device
, since
, until
, aggregated
, version
):
166 history
= EpisodeHistoryEntry
.objects
.filter(user
=user
,
170 history
= history
.filter(timestamp__gte
=since
)
172 if podcast
is not None:
173 history
= history
.filter(episode__podcast
=podcast
)
175 if device
is not None:
176 history
= history
.filter(client
=device
)
179 history
= imap(convert_position
, history
)
181 actions
= [episode_action_json(a
, user
) for a
in history
]
184 actions
= dict( (a
['episode'], a
) for a
in actions
).values()
186 return {'actions': actions
, 'timestamp': until
}
189 def episode_action_json(history
, user
):
192 'podcast': history
.podcast_ref_url
or history
.episode
.podcast
.url
,
193 'episode': history
.episode_ref_url
or history
.episode
.url
,
194 'action': history
.action
,
195 'timestamp': history
.timestamp
.isoformat(),
199 action
['device'] = history
.client
.uid
201 if history
.action
== EpisodeHistoryEntry
.PLAY
:
202 action
['started'] = history
.started
203 action
['position'] = history
.stopped
# TODO: check "playmark"
204 action
['total'] = history
.total
209 def update_episodes(user
, actions
, now
, ua_string
):
211 auto_flattr
= user
.profile
.get_wksetting(FLATTR_AUTO
)
213 # group all actions by their episode
214 for action
in actions
:
216 podcast_url
= action
['podcast']
217 podcast_url
= sanitize_append(podcast_url
, update_urls
)
218 if podcast_url
== '':
221 episode_url
= action
['episode']
222 episode_url
= sanitize_append(episode_url
, update_urls
)
223 if episode_url
== '':
226 podcast
= Podcast
.objects
.get_or_create_for_url(podcast_url
)
227 episode
= Episode
.objects
.get_or_create_for_url(podcast
, episode_url
)
229 # parse_episode_action returns a EpisodeHistoryEntry obj
230 history
= parse_episode_action(action
, user
, update_urls
, now
,
233 # we could save ``history`` directly, but we check for duplicates first
234 EpisodeHistoryEntry
.objects
.get_or_create(
236 client
= history
.client
,
238 action
= history
.action
,
239 timestamp
= history
.timestamp
,
241 'started': history
.started
,
242 'stopped': history
.stopped
,
243 'total': history
.total
,
247 if history
.action
== EpisodeHistoryEntry
.PLAY
and auto_flattr
:
248 auto_flattr_episode
.delay(user
, episode
.id)
253 def parse_episode_action(action
, user
, update_urls
, now
, ua_string
):
254 action_str
= action
.get('action', None)
255 if not valid_episodeaction(action_str
):
256 raise Exception('invalid action %s' % action_str
)
258 history
= EpisodeHistoryEntry()
260 history
.action
= action
['action']
262 if action
.get('device', False):
263 client
= get_device(user
, action
['device'], ua_string
)
264 history
.client
= client
266 if action
.get('timestamp', False):
267 history
.timestamp
= dateutil
.parser
.parse(action
['timestamp'])
269 history
.timestamp
= now
271 history
.started
= action
.get('started', None)
272 history
.stopped
= action
.get('position', None)
273 history
.total
= action
.get('total', None)
282 # Workaround for mygpoclient 1.0: It uses "PUT" requests
283 # instead of "POST" requests for uploading device settings
284 @allowed_methods(['POST', 'PUT'])
286 def device(request
, username
, device_uid
):
287 d
= get_device(request
.user
, device_uid
,
288 request
.META
.get('HTTP_USER_AGENT', ''))
291 data
= parse_request_body(request
)
292 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
293 msg
= ('Could not decode device update POST data for ' +
294 'user %s: %s') % (username
,
295 request
.body
.decode('ascii', errors
='replace'))
296 logger
.warn(msg
, exc_info
=True)
297 return HttpResponseBadRequest(msg
)
299 if 'caption' in data
:
300 if not data
['caption']:
301 return HttpResponseBadRequest('caption must not be empty')
302 d
.name
= data
['caption']
305 if not valid_devicetype(data
['type']):
306 return HttpResponseBadRequest('invalid device type %s' % data
['type'])
307 d
.type = data
['type']
310 return HttpResponse()
313 def valid_devicetype(type):
314 for t
in Client
.TYPES
:
319 def valid_episodeaction(type):
320 for t
in EPISODE_ACTION_TYPES
:
330 @allowed_methods(['GET'])
332 def devices(request
, username
):
334 clients
= user
.client_set
.filter(deleted
=False)
335 client_data
= [get_client_data(user
, client
) for client
in clients
]
336 return JsonResponse(client_data
)
339 def get_client_data(user
, client
):
342 caption
= client
.name
,
344 subscriptions
= Subscription
.objects
.filter(user
=user
, client
=client
)\
353 def favorites(request
, username
):
354 favorites
= FavoriteEpisode
.episodes_for_user(request
.user
)
355 domain
= RequestSite(request
).domain
356 e_data
= lambda e
: episode_data(e
, domain
)
357 ret
= map(e_data
, favorites
)
358 return JsonResponse(ret
)
361 def sanitize_append(url
, sanitized_list
):
362 urls
= normalize_feed_url(url
)
364 sanitized_list
.append( (url
, urls
or '') )