Merge pull request #793 from gpodder/remove-advertise
[mygpo.git] / mygpo / api / advanced / __init__.py
blob08d5b760e901ac9c57c385ff580005f44a36d764
1 from functools import partial
3 from collections import defaultdict
4 from datetime import datetime
5 from importlib import import_module
7 import dateutil.parser
9 from django.http import (
10 HttpResponse,
11 HttpResponseBadRequest,
12 Http404,
13 HttpResponseNotFound,
15 from django.core.exceptions import ValidationError
16 from django.contrib.sites.requests import RequestSite
17 from django.views.decorators.csrf import csrf_exempt
18 from django.views.decorators.cache import never_cache
19 from django.conf import settings as dsettings
20 from django.shortcuts import get_object_or_404
22 from mygpo.podcasts.models import Podcast, Episode
23 from mygpo.subscriptions.models import Subscription
24 from mygpo.api.constants import EPISODE_ACTION_TYPES
25 from mygpo.api.httpresponse import JsonResponse
26 from mygpo.api.advanced.directory import episode_data
27 from mygpo.api.backend import get_device
28 from mygpo.utils import (
29 format_time,
30 parse_bool,
31 get_timestamp,
32 parse_request_body,
33 normalize_feed_url,
35 from mygpo.decorators import allowed_methods, cors_origin
36 from mygpo.history.models import EpisodeHistoryEntry
37 from mygpo.users.models import Client, InvalidEpisodeActionAttributes
38 from mygpo.favorites.models import FavoriteEpisode
39 from mygpo.api.basic_auth import require_valid_user, check_username
42 import logging
44 logger = logging.getLogger(__name__)
47 # keys that are allowed in episode actions
48 EPISODE_ACTION_KEYS = (
49 "position",
50 "episode",
51 "action",
52 "device",
53 "timestamp",
54 "started",
55 "total",
56 "podcast",
60 @csrf_exempt
61 @require_valid_user
62 @check_username
63 @never_cache
64 @allowed_methods(["GET", "POST"])
65 @cors_origin()
66 def episodes(request, username, version=1):
68 version = int(version)
69 now = datetime.utcnow()
70 now_ = get_timestamp(now)
71 ua_string = request.META.get("HTTP_USER_AGENT", "")
73 if request.method == "POST":
74 try:
75 actions = parse_request_body(request)
76 except (UnicodeDecodeError, ValueError) as e:
77 msg = ("Could not decode episode update POST data for " + "user %s: %s") % (
78 username,
79 request.body.decode("ascii", errors="replace"),
81 logger.warning(msg, exc_info=True)
82 return HttpResponseBadRequest(msg)
84 logger.info(
85 "start: user %s: %d actions from %s"
86 % (request.user, len(actions), ua_string)
89 # handle in background
90 if (
91 dsettings.API_ACTIONS_MAX_NONBG is not None
92 and 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": []})
106 try:
107 update_urls = update_episodes(request.user, actions, now, ua_string)
108 except ValidationError as e:
109 logger.warning(
110 "Validation Error while uploading episode actions " "for user %s: %s",
111 username,
112 str(e),
114 return HttpResponseBadRequest(str(e))
116 except InvalidEpisodeActionAttributes as e:
117 msg = (
118 "invalid episode action attributes while uploading episode actions for user %s"
119 % (username,)
121 logger.warning(msg, exc_info=True)
122 return HttpResponseBadRequest(str(e))
124 logger.info(
125 "done: user %s: %d actions from %s"
126 % (request.user, len(actions), ua_string)
128 return JsonResponse({"timestamp": now_, "update_urls": update_urls})
130 elif request.method == "GET":
131 podcast_url = request.GET.get("podcast", None)
132 device_uid = request.GET.get("device", None)
133 since_ = request.GET.get("since", None)
134 aggregated = parse_bool(request.GET.get("aggregated", False))
136 try:
137 since = int(since_) if since_ else None
138 if since is not None:
139 since = datetime.utcfromtimestamp(since)
140 except ValueError:
141 return HttpResponseBadRequest("since-value is not a valid timestamp")
143 if podcast_url:
144 podcast = get_object_or_404(Podcast, urls__url=podcast_url)
145 else:
146 podcast = None
148 if device_uid:
150 try:
151 user = request.user
152 device = user.client_set.get(uid=device_uid)
153 except Client.DoesNotExist as e:
154 return HttpResponseNotFound(str(e))
156 else:
157 device = None
159 changes = get_episode_changes(
160 request.user, podcast, device, since, now, aggregated, version
163 return JsonResponse(changes)
166 def convert_position(action):
167 """convert position parameter for API 1 compatibility"""
168 pos = getattr(action, "position", None)
169 if pos is not None:
170 action.position = format_time(pos)
171 return action
174 def get_episode_changes(user, podcast, device, since, until, aggregated, version):
176 history = EpisodeHistoryEntry.objects.filter(user=user, timestamp__lt=until)
178 # return the earlier entries first
179 history = history.order_by("timestamp")
181 if since:
182 history = history.filter(timestamp__gte=since)
184 if podcast is not None:
185 history = history.filter(episode__podcast=podcast)
187 if device is not None:
188 history = history.filter(client=device)
190 if version == 1:
191 history = map(convert_position, history)
193 # Limit number of returned episode actions
194 max_actions = dsettings.MAX_EPISODE_ACTIONS
195 history = history[:max_actions]
197 # evaluate query and turn into list, for negative indexing
198 history = list(history)
200 actions = [episode_action_json(a, user) for a in history]
202 if aggregated:
203 actions = list(dict((a["episode"], a) for a in actions).values())
205 if history:
206 ts = get_timestamp(history[-1].timestamp)
207 else:
208 ts = get_timestamp(until)
210 return {"actions": actions, "timestamp": ts}
213 def episode_action_json(history, user):
215 action = {
216 "podcast": history.podcast_ref_url or history.episode.podcast.url,
217 "episode": history.episode_ref_url or history.episode.url,
218 "guid": history.episode.guid,
219 "action": history.action,
220 "timestamp": history.timestamp.isoformat(),
223 if history.client:
224 action["device"] = history.client.uid
226 if history.action == EpisodeHistoryEntry.PLAY:
227 action["started"] = history.started
228 action["position"] = history.stopped # TODO: check "playmark"
229 action["total"] = history.total
231 return action
234 def update_episodes(user, actions, now, ua_string):
235 update_urls = []
237 # group all actions by their episode
238 for action in actions:
240 podcast_url = action.get("podcast", "")
241 podcast_url = sanitize_append(podcast_url, update_urls)
242 if not podcast_url:
243 continue
245 episode_url = action.get("episode", "")
246 episode_url = sanitize_append(episode_url, update_urls)
247 if not episode_url:
248 continue
250 podcast = Podcast.objects.get_or_create_for_url(podcast_url).object
251 episode = Episode.objects.get_or_create_for_url(podcast, episode_url).object
253 # parse_episode_action returns a EpisodeHistoryEntry obj
254 history = parse_episode_action(action, user, update_urls, now, ua_string)
256 EpisodeHistoryEntry.create_entry(
257 user,
258 episode,
259 history.action,
260 history.client,
261 history.timestamp,
262 history.started,
263 history.stopped,
264 history.total,
265 podcast_url,
266 episode_url,
269 return update_urls
272 def parse_episode_action(action, user, update_urls, now, ua_string):
273 action_str = action.get("action", None)
274 if not valid_episodeaction(action_str):
275 raise Exception("invalid action %s" % action_str)
277 history = EpisodeHistoryEntry()
279 history.action = action["action"]
281 if action.get("device", False):
282 client = get_device(user, action["device"], ua_string)
283 history.client = client
285 if action.get("timestamp", False):
286 history.timestamp = dateutil.parser.parse(action["timestamp"])
287 else:
288 history.timestamp = now
290 history.started = action.get("started", None)
291 history.stopped = action.get("position", None)
292 history.total = action.get("total", None)
294 return history
297 @csrf_exempt
298 @require_valid_user
299 @check_username
300 @never_cache
301 # Workaround for mygpoclient 1.0: It uses "PUT" requests
302 # instead of "POST" requests for uploading device settings
303 @allowed_methods(["POST", "PUT"])
304 @cors_origin()
305 def device(request, username, device_uid, version=None):
306 d = get_device(request.user, device_uid, request.META.get("HTTP_USER_AGENT", ""))
308 try:
309 data = parse_request_body(request)
310 except (UnicodeDecodeError, ValueError) as e:
311 msg = ("Could not decode device update POST data for " + "user %s: %s") % (
312 username,
313 request.body.decode("ascii", errors="replace"),
315 logger.warning(msg, exc_info=True)
316 return HttpResponseBadRequest(msg)
318 if "caption" in data:
319 if not data["caption"]:
320 return HttpResponseBadRequest("caption must not be empty")
321 d.name = data["caption"]
323 if "type" in data:
324 if not valid_devicetype(data["type"]):
325 return HttpResponseBadRequest("invalid device type %s" % data["type"])
326 d.type = data["type"]
328 d.save()
329 return HttpResponse()
332 def valid_devicetype(type):
333 for t in Client.TYPES:
334 if t[0] == type:
335 return True
336 return False
339 def valid_episodeaction(type):
340 for t in EPISODE_ACTION_TYPES:
341 if t[0] == type:
342 return True
343 return False
346 @csrf_exempt
347 @require_valid_user
348 @check_username
349 @never_cache
350 @allowed_methods(["GET"])
351 @cors_origin()
352 def devices(request, username, version=None):
353 user = request.user
354 clients = user.client_set.filter(deleted=False)
355 client_data = [get_client_data(user, client) for client in clients]
356 return JsonResponse(client_data)
359 def get_client_data(user, client):
360 return dict(
361 id=client.uid,
362 caption=client.name,
363 type=client.type,
364 subscriptions=Subscription.objects.filter(user=user, client=client).count(),
368 @require_valid_user
369 @check_username
370 @never_cache
371 @cors_origin()
372 def favorites(request, username):
373 favorites = FavoriteEpisode.episodes_for_user(request.user)
374 domain = RequestSite(request).domain
375 e_data = lambda e: episode_data(e, domain)
376 ret = list(map(e_data, favorites))
377 return JsonResponse(ret)
380 def sanitize_append(url, sanitized_list):
381 urls = normalize_feed_url(url)
382 if url != urls:
383 sanitized_list.append((url, urls or ""))
384 return urls