[History] store episode history in Django ORM
[mygpo.git] / mygpo / api / advanced / __init__.py
blob81691806b21929ba1a9257a1f13f20088056c281
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
54 import logging
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')
67 @csrf_exempt
68 @require_valid_user
69 @check_username
70 @never_cache
71 @allowed_methods(['GET', 'POST'])
72 @cors_origin()
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':
81 try:
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': []})
107 try:
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))
127 try:
128 since = int(since_) if since_ else None
129 except ValueError:
130 return HttpResponseBadRequest('since-value is not a valid timestamp')
132 if podcast_url:
133 podcast = get_object_or_404(Podcast, urls__url=podcast_url)
134 else:
135 podcast = None
137 if device_uid:
139 try:
140 user = request.user
141 device = user.client_set.get(uid=device_uid)
142 except Client.DoesNotExist as e:
143 return HttpResponseNotFound(str(e))
145 else:
146 device = None
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)
158 if pos is not None:
159 action.position = format_time(pos)
160 return action
164 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
166 history = EpisodeHistoryEntry.objects.filter(user=user,
167 timestamp__lt=until)
169 if since:
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)
178 if version == 1:
179 history = imap(convert_position, history)
181 actions = [episode_action_json(a, user) for a in history]
183 if aggregated:
184 actions = dict( (a['episode'], a) for a in actions ).values()
186 return {'actions': actions, 'timestamp': until}
189 def episode_action_json(history, user):
191 action = {
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(),
198 if history.client:
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
206 return action
209 def update_episodes(user, actions, now, ua_string):
210 update_urls = []
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 == '':
219 continue
221 episode_url = action['episode']
222 episode_url = sanitize_append(episode_url, update_urls)
223 if episode_url == '':
224 continue
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,
231 ua_string)
233 # we could save ``history`` directly, but we check for duplicates first
234 EpisodeHistoryEntry.objects.get_or_create(
235 user = user,
236 client = history.client,
237 episode = episode,
238 action = history.action,
239 timestamp = history.timestamp,
240 defaults = {
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)
250 return update_urls
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'])
268 else:
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)
275 return history
278 @csrf_exempt
279 @require_valid_user
280 @check_username
281 @never_cache
282 # Workaround for mygpoclient 1.0: It uses "PUT" requests
283 # instead of "POST" requests for uploading device settings
284 @allowed_methods(['POST', 'PUT'])
285 @cors_origin()
286 def device(request, username, device_uid):
287 d = get_device(request.user, device_uid,
288 request.META.get('HTTP_USER_AGENT', ''))
290 try:
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']
304 if 'type' in data:
305 if not valid_devicetype(data['type']):
306 return HttpResponseBadRequest('invalid device type %s' % data['type'])
307 d.type = data['type']
309 d.save()
310 return HttpResponse()
313 def valid_devicetype(type):
314 for t in Client.TYPES:
315 if t[0] == type:
316 return True
317 return False
319 def valid_episodeaction(type):
320 for t in EPISODE_ACTION_TYPES:
321 if t[0] == type:
322 return True
323 return False
326 @csrf_exempt
327 @require_valid_user
328 @check_username
329 @never_cache
330 @allowed_methods(['GET'])
331 @cors_origin()
332 def devices(request, username):
333 user = request.user
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):
340 return dict(
341 id = client.uid,
342 caption = client.name,
343 type = client.type,
344 subscriptions= Subscription.objects.filter(user=user, client=client)\
345 .count(),
349 @require_valid_user
350 @check_username
351 @never_cache
352 @cors_origin()
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)
363 if url != urls:
364 sanitized_list.append( (url, urls or '') )
365 return urls