1 from functools
import partial
3 from collections
import defaultdict
4 from datetime
import datetime
5 from importlib
import import_module
9 from django
.http
import (
11 HttpResponseBadRequest
,
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 (
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
44 logger
= logging
.getLogger(__name__
)
47 # keys that are allowed in episode actions
48 EPISODE_ACTION_KEYS
= (
64 @allowed_methods(["GET", "POST"])
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":
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") % (
79 request
.body
.decode("ascii", errors
="replace"),
81 logger
.warning(msg
, exc_info
=True)
82 return HttpResponseBadRequest(msg
)
85 "start: user %s: %d actions from %s"
86 % (request
.user
, len(actions
), ua_string
)
89 # handle in background
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": []})
107 update_urls
= update_episodes(request
.user
, actions
, now
, ua_string
)
108 except ValidationError
as e
:
110 "Validation Error while uploading episode actions " "for user %s: %s",
114 return HttpResponseBadRequest(str(e
))
116 except InvalidEpisodeActionAttributes
as e
:
118 "invalid episode action attributes while uploading episode actions for user %s"
121 logger
.warning(msg
, exc_info
=True)
122 return HttpResponseBadRequest(str(e
))
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))
137 since
= int(since_
) if since_
else None
138 if since
is not None:
139 since
= datetime
.utcfromtimestamp(since
)
141 return HttpResponseBadRequest("since-value is not a valid timestamp")
144 podcast
= get_object_or_404(Podcast
, urls__url
=podcast_url
)
152 device
= user
.client_set
.get(uid
=device_uid
)
153 except Client
.DoesNotExist
as e
:
154 return HttpResponseNotFound(str(e
))
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)
170 action
.position
= format_time(pos
)
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")
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
)
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
]
203 actions
= list(dict((a
["episode"], a
) for a
in actions
).values())
206 ts
= get_timestamp(history
[-1].timestamp
)
208 ts
= get_timestamp(until
)
210 return {"actions": actions
, "timestamp": ts
}
213 def episode_action_json(history
, user
):
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(),
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
234 def update_episodes(user
, actions
, now
, ua_string
):
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
)
245 episode_url
= action
.get("episode", "")
246 episode_url
= sanitize_append(episode_url
, update_urls
)
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(
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"])
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)
301 # Workaround for mygpoclient 1.0: It uses "PUT" requests
302 # instead of "POST" requests for uploading device settings
303 @allowed_methods(["POST", "PUT"])
305 def device(request
, username
, device_uid
, version
=None):
306 d
= get_device(request
.user
, device_uid
, request
.META
.get("HTTP_USER_AGENT", ""))
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") % (
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"]
324 if not valid_devicetype(data
["type"]):
325 return HttpResponseBadRequest("invalid device type %s" % data
["type"])
326 d
.type = data
["type"]
329 return HttpResponse()
332 def valid_devicetype(type):
333 for t
in Client
.TYPES
:
339 def valid_episodeaction(type):
340 for t
in EPISODE_ACTION_TYPES
:
350 @allowed_methods(["GET"])
352 def devices(request
, username
, version
=None):
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
):
364 subscriptions
=Subscription
.objects
.filter(user
=user
, client
=client
).count(),
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
)
383 sanitized_list
.append((url
, urls
or ""))