[Tests] Check number of queries on Podcast / Episode page
[mygpo.git] / mygpo / api / subscriptions.py
blob255fe709462c3e22d2425c2ba35994d40f22114a
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 datetime import datetime
20 from django.http import HttpResponseBadRequest, HttpResponseNotFound
21 from django.views.decorators.csrf import csrf_exempt
22 from django.views.decorators.cache import never_cache
23 from django.utils.decorators import method_decorator
24 from django.views.generic.base import View
26 from mygpo.api.httpresponse import JsonResponse
27 from mygpo.api.backend import get_device, BulkSubscribe
28 from mygpo.utils import get_timestamp, \
29 parse_request_body, normalize_feed_url, intersect
30 from mygpo.decorators import cors_origin
31 from mygpo.users.models import DeviceDoesNotExist
32 from mygpo.core.json import JSONDecodeError
33 from mygpo.api.basic_auth import require_valid_user, check_username
34 from mygpo.db.couchdb import BulkException
37 import logging
38 logger = logging.getLogger(__name__)
41 class RequestException(Exception):
42 """ Raised if the request is malfored or otherwise invalid """
45 class APIView(View):
47 @method_decorator(csrf_exempt)
48 @method_decorator(require_valid_user)
49 @method_decorator(check_username)
50 @method_decorator(never_cache)
51 @method_decorator(cors_origin())
52 def dispatch(self, *args, **kwargs):
53 """ Dispatches request and does generic error handling """
54 try:
55 return super(APIView, self).dispatch(*args, **kwargs)
57 except DeviceDoesNotExist as e:
58 return HttpResponseNotFound(str(e))
60 except RequestException as e:
61 return HttpResponseBadRequest(str(e))
63 def parsed_body(self, request):
64 """ Returns the object parsed from the JSON request body """
66 if not request.body:
67 raise RequestException('POST data must not be empty')
69 try:
70 # TODO: implementation of parse_request_body can be moved here
71 # after all views using it have been refactored
72 return parse_request_body(request)
73 except (JSONDecodeError, UnicodeDecodeError, ValueError) as e:
74 msg = u'Could not decode request body for user {}: {}'.format(
75 username, request.body.decode('ascii', errors='replace'))
76 logger.warn(msg, exc_info=True)
77 raise RequestException(msg)
79 def get_since(self, request):
80 """ Returns parsed "since" GET parameter """
81 since_ = request.GET.get('since', None)
83 if since_ is None:
84 raise RequestException("parameter 'since' missing")
86 try:
87 since = datetime.fromtimestamp(int(since_))
88 except ValueError:
89 raise RequestException("'since' is not a valid timestamp")
91 if since_ < 0:
92 raise RequestException("'since' must be a non-negative number")
94 return since
97 class SubscriptionsAPI(APIView):
98 """ API for sending and retrieving podcast subscription updates """
100 def get(self, request, version, username, device_uid):
101 """ Client retrieves subscription updates """
102 now = datetime.utcnow()
103 device = request.user.get_device_by_uid(device_uid)
104 since = self.get_since(request)
105 add, rem, until = self.get_changes(device, since, now)
106 return JsonResponse({
107 'add': add,
108 'remove': rem,
109 'timestamp': until
112 def post(self, request, version, username, device_uid):
113 """ Client sends subscription updates """
114 now = get_timestamp(datetime.utcnow())
116 d = get_device(request.user, device_uid,
117 request.META.get('HTTP_USER_AGENT', ''))
119 actions = self.parsed_body(request)
121 add = filter(None, actions.get('add', []))
122 rem = filter(None, actions.get('remove', []))
124 update_urls = self.update_subscriptions(request.user, d, add, rem)
126 return JsonResponse({
127 'timestamp': now,
128 'update_urls': update_urls,
131 def update_subscriptions(self, user, device, add, remove):
133 conflicts = intersect(add, remove)
134 if conflicts:
135 msg = "can not add and remove '{}' at the same time".format(
136 str(conflicts))
137 raise RequestException(msg)
139 add_s = map(normalize_feed_url, add)
140 rem_s = map(normalize_feed_url, remove)
142 assert len(add) == len(add_s) and len(remove) == len(rem_s)
144 pairs = zip(add + remove, add_s + rem_s)
145 updated_urls = filter(lambda (a, b): a != b, pairs)
147 add_s = filter(None, add_s)
148 rem_s = filter(None, rem_s)
150 # If two different URLs (in add and remove) have
151 # been sanitized to the same, we ignore the removal
152 rem_s = filter(lambda x: x not in add_s, rem_s)
154 subscriber = BulkSubscribe(user, device)
156 for a in add_s:
157 subscriber.add_action(a, 'subscribe')
159 for r in rem_s:
160 subscriber.add_action(r, 'unsubscribe')
162 try:
163 subscriber.execute()
164 except BulkException as be:
165 for err in be.errors:
166 msg = 'Advanced API: {user}: Updating subscription for ' \
167 '{podcast} on {device} failed: {err} {reason}'.format(
168 user=user.username, podcast=err.doc,
169 device=device.uid, err=err.error, reason=err.reason)
170 loger.error(msg)
172 return updated_urls
174 def get_changes(self, device, since, until):
175 """ Returns subscription changes for the given device """
176 add_urls, rem_urls = device.get_subscription_changes(since, until)
177 until_ = get_timestamp(until)
178 return (add_urls, rem_urls, until_)