fixes #854: republish a book with parent correctly
[wolnelektury.git] / apps / piston / utils.py
1 import time
2 from django.http import HttpResponseNotAllowed, HttpResponseForbidden, HttpResponse, HttpResponseBadRequest
3 from django.core.urlresolvers import reverse
4 from django.core.cache import cache
5 from django import get_version as django_version
6 from django.core.mail import send_mail, mail_admins
7 from django.conf import settings
8 from django.utils.translation import ugettext as _
9 from django.template import loader, TemplateDoesNotExist
10 from django.contrib.sites.models import Site
11 from decorator import decorator
12
13 from datetime import datetime, timedelta
14
15 __version__ = '0.2.3rc1'
16
17 def get_version():
18     return __version__
19
20 def format_error(error):
21     return u"Piston/%s (Django %s) crash report:\n\n%s" % \
22         (get_version(), django_version(), error)
23
24 class rc_factory(object):
25     """
26     Status codes.
27     """
28     CODES = dict(ALL_OK = ('OK', 200),
29                  CREATED = ('Created', 201),
30                  DELETED = ('', 204), # 204 says "Don't send a body!"
31                  BAD_REQUEST = ('Bad Request', 400),
32                  FORBIDDEN = ('Forbidden', 401),
33                  NOT_FOUND = ('Not Found', 404),
34                  DUPLICATE_ENTRY = ('Conflict/Duplicate', 409),
35                  NOT_HERE = ('Gone', 410),
36                  INTERNAL_ERROR = ('Internal Error', 500),
37                  NOT_IMPLEMENTED = ('Not Implemented', 501),
38                  THROTTLED = ('Throttled', 503))
39
40     def __getattr__(self, attr):
41         """
42         Returns a fresh `HttpResponse` when getting
43         an "attribute". This is backwards compatible
44         with 0.2, which is important.
45         """
46         try:
47             (r, c) = self.CODES.get(attr)
48         except TypeError:
49             raise AttributeError(attr)
50
51         return HttpResponse(r, content_type='text/plain', status=c)
52
53 rc = rc_factory()
54
55 class FormValidationError(Exception):
56     def __init__(self, form):
57         self.form = form
58
59 class HttpStatusCode(Exception):
60     def __init__(self, response):
61         self.response = response
62
63 def validate(v_form, operation='POST'):
64     @decorator
65     def wrap(f, self, request, *a, **kwa):
66         form = v_form(getattr(request, operation))
67
68         if form.is_valid():
69             return f(self, request, *a, **kwa)
70         else:
71             raise FormValidationError(form)
72     return wrap
73
74 def throttle(max_requests, timeout=60*60, extra=''):
75     """
76     Simple throttling decorator, caches
77     the amount of requests made in cache.
78
79     If used on a view where users are required to
80     log in, the username is used, otherwise the
81     IP address of the originating request is used.
82
83     Parameters::
84      - `max_requests`: The maximum number of requests
85      - `timeout`: The timeout for the cache entry (default: 1 hour)
86     """
87     @decorator
88     def wrap(f, self, request, *args, **kwargs):
89         if request.user.is_authenticated():
90             ident = request.user.username
91         else:
92             ident = request.META.get('REMOTE_ADDR', None)
93
94         if hasattr(request, 'throttle_extra'):
95             """
96             Since we want to be able to throttle on a per-
97             application basis, it's important that we realize
98             that `throttle_extra` might be set on the request
99             object. If so, append the identifier name with it.
100             """
101             ident += ':%s' % str(request.throttle_extra)
102
103         if ident:
104             """
105             Preferrably we'd use incr/decr here, since they're
106             atomic in memcached, but it's in django-trunk so we
107             can't use it yet. If someone sees this after it's in
108             stable, you can change it here.
109             """
110             ident += ':%s' % extra
111
112             now = time.time()
113             count, expiration = cache.get(ident, (1, None))
114
115             if expiration is None:
116                 expiration = now + timeout
117
118             if count >= max_requests and expiration > now:
119                 t = rc.THROTTLED
120                 wait = int(expiration - now)
121                 t.content = 'Throttled, wait %d seconds.' % wait
122                 t['Retry-After'] = wait
123                 return t
124
125             cache.set(ident, (count+1, expiration), (expiration - now))
126
127         return f(self, request, *args, **kwargs)
128     return wrap
129
130 def coerce_put_post(request):
131     """
132     Django doesn't particularly understand REST.
133     In case we send data over PUT, Django won't
134     actually look at the data and load it. We need
135     to twist its arm here.
136
137     The try/except abominiation here is due to a bug
138     in mod_python. This should fix it.
139     """
140     if request.method == "PUT":
141         try:
142             request.method = "POST"
143             request._load_post_and_files()
144             request.method = "PUT"
145         except AttributeError:
146             request.META['REQUEST_METHOD'] = 'POST'
147             request._load_post_and_files()
148             request.META['REQUEST_METHOD'] = 'PUT'
149
150         request.PUT = request.POST
151
152
153 class MimerDataException(Exception):
154     """
155     Raised if the content_type and data don't match
156     """
157     pass
158
159 class Mimer(object):
160     TYPES = dict()
161
162     def __init__(self, request):
163         self.request = request
164
165     def is_multipart(self):
166         content_type = self.content_type()
167
168         if content_type is not None:
169             return content_type.lstrip().startswith('multipart')
170
171         return False
172
173     def loader_for_type(self, ctype):
174         """
175         Gets a function ref to deserialize content
176         for a certain mimetype.
177         """
178         for loadee, mimes in Mimer.TYPES.iteritems():
179             for mime in mimes:
180                 if ctype.startswith(mime):
181                     return loadee
182
183     def content_type(self):
184         """
185         Returns the content type of the request in all cases where it is
186         different than a submitted form - application/x-www-form-urlencoded
187         """
188         type_formencoded = "application/x-www-form-urlencoded"
189
190         ctype = self.request.META.get('CONTENT_TYPE', type_formencoded)
191
192         if type_formencoded in ctype:
193             return None
194
195         return ctype
196
197     def translate(self):
198         """
199         Will look at the `Content-type` sent by the client, and maybe
200         deserialize the contents into the format they sent. This will
201         work for JSON, YAML, XML and Pickle. Since the data is not just
202         key-value (and maybe just a list), the data will be placed on
203         `request.data` instead, and the handler will have to read from
204         there.
205
206         It will also set `request.content_type` so the handler has an easy
207         way to tell what's going on. `request.content_type` will always be
208         None for form-encoded and/or multipart form data (what your browser sends.)
209         """
210         ctype = self.content_type()
211         self.request.content_type = ctype
212
213         if not self.is_multipart() and ctype:
214             loadee = self.loader_for_type(ctype)
215
216             if loadee:
217                 try:
218                     self.request.data = loadee(self.request.raw_post_data)
219
220                     # Reset both POST and PUT from request, as its
221                     # misleading having their presence around.
222                     self.request.POST = self.request.PUT = dict()
223                 except (TypeError, ValueError):
224                     # This also catches if loadee is None.
225                     raise MimerDataException
226             else:
227                 self.request.data = None
228
229         return self.request
230
231     @classmethod
232     def register(cls, loadee, types):
233         cls.TYPES[loadee] = types
234
235     @classmethod
236     def unregister(cls, loadee):
237         return cls.TYPES.pop(loadee)
238
239 def translate_mime(request):
240     request = Mimer(request).translate()
241
242 def require_mime(*mimes):
243     """
244     Decorator requiring a certain mimetype. There's a nifty
245     helper called `require_extended` below which requires everything
246     we support except for post-data via form.
247     """
248     @decorator
249     def wrap(f, self, request, *args, **kwargs):
250         m = Mimer(request)
251         realmimes = set()
252
253         rewrite = { 'json':   'application/json',
254                     'yaml':   'application/x-yaml',
255                     'xml':    'text/xml',
256                     'pickle': 'application/python-pickle' }
257
258         for idx, mime in enumerate(mimes):
259             realmimes.add(rewrite.get(mime, mime))
260
261         if not m.content_type() in realmimes:
262             return rc.BAD_REQUEST
263
264         return f(self, request, *args, **kwargs)
265     return wrap
266
267 require_extended = require_mime('json', 'yaml', 'xml', 'pickle')
268
269 def send_consumer_mail(consumer):
270     """
271     Send a consumer an email depending on what their status is.
272     """
273     try:
274         subject = settings.PISTON_OAUTH_EMAIL_SUBJECTS[consumer.status]
275     except AttributeError:
276         subject = "Your API Consumer for %s " % Site.objects.get_current().name
277         if consumer.status == "accepted":
278             subject += "was accepted!"
279         elif consumer.status == "canceled":
280             subject += "has been canceled."
281         elif consumer.status == "rejected":
282             subject += "has been rejected."
283         else:
284             subject += "is awaiting approval."
285
286     template = "piston/mails/consumer_%s.txt" % consumer.status
287
288     try:
289         body = loader.render_to_string(template,
290             { 'consumer' : consumer, 'user' : consumer.user })
291     except TemplateDoesNotExist:
292         """
293         They haven't set up the templates, which means they might not want
294         these emails sent.
295         """
296         return
297
298     try:
299         sender = settings.PISTON_FROM_EMAIL
300     except AttributeError:
301         sender = settings.DEFAULT_FROM_EMAIL
302
303     if consumer.user:
304         send_mail(_(subject), body, sender, [consumer.user.email], fail_silently=True)
305
306     if consumer.status == 'pending' and len(settings.ADMINS):
307         mail_admins(_(subject), body, fail_silently=True)
308
309     if settings.DEBUG and consumer.user:
310         print "Mail being sent, to=%s" % consumer.user.email
311         print "Subject: %s" % _(subject)
312         print body
313