Removed 1-form limit for new instance forms.
[philo.git] / contrib / gilbert / extdirect / core.py
1 from django.db.models import Q
2 from django.http import HttpResponse
3 from django.utils import simplejson as json
4 from django.utils.encoding import smart_str
5 from django.views.debug import ExceptionReporter
6 from inspect import isclass, ismethod, isfunction, getmembers, getargspec
7 from traceback import format_tb
8 from abc import ABCMeta, abstractproperty
9 from collections import Callable, Sized, Mapping
10 import sys, datetime
11
12
13 # __all__ = ('ext_action', 'ext_method', 'is_ext_action', 'is_ext_method', 'ExtAction', 'ExtMethod')
14
15
16 class ExtRequest(object):
17         """
18         Represents a single Ext.Direct request along with the :class:`django.http.HttpRequest` it originates from.
19         
20         .. note::
21                 
22                 Passes undefined attribute accesses through to the underlying :class:`django.http.HttpRequest`.
23         
24         """
25         
26         @classmethod
27         def parse(cls, request, object_hook=None):
28                 """
29                 Parses Ext.Direct request(s) from the originating HTTP request.
30                 
31                 :arg request: the originating HTTP request
32                 :type request: :class:`django.http.HttpRequest`
33                 :returns: list of :class:`ExtRequest` instances
34                 
35                 """
36                 
37                 requests = []
38                 
39                 if request.META['CONTENT_TYPE'].startswith('application/x-www-form-urlencoded') or request.META['CONTENT_TYPE'].startswith('multipart/form-data'):
40                         requests.append(cls(request,
41                                 type = request.POST.get('extType'),
42                                 tid = request.POST.get('extTID'),
43                                 action = request.POST.get('extAction'),
44                                 method = request.POST.get('extMethod'),
45                                 data = request.POST.get('extData', None),
46                                 upload = True if request.POST.get('extUpload', False) in (True, 'true', 'True') else False,
47                                 form_request = True,
48                         ))
49                 else:
50                         decoded_requests = json.loads(request.raw_post_data, object_hook=object_hook)
51                         if type(decoded_requests) is dict:
52                                 decoded_requests = [decoded_requests]
53                         for inner_request in decoded_requests:
54                                 requests.append(cls(request,
55                                         type = inner_request.get('type'),
56                                         tid = inner_request.get('tid'),
57                                         action = inner_request.get('action'),
58                                         method = inner_request.get('method'),
59                                         data = inner_request.get('data', None),
60                                 ))
61                 
62                 return requests
63         
64         def __init__(self, request, type, tid, action, method, data, upload=False, form_request=False):
65                 """
66                 :arg request: the originating HTTP request
67                 :type request: :class:`django.http.HttpRequest`
68                 :arg type: Ext.Direct request type
69                 :type type: str
70                 :arg tid: Ext.Direct transaction identifier
71                 :type tid: str
72                 :arg action: Ext.Direct action name
73                 :type action: str
74                 :arg method: Ext.Direct method name
75                 :type method: str
76                 :arg data: Ext.Direct method arguments
77                 :type data: list
78                 :arg upload: request includes uploaded file(s)
79                 :type upload: bool
80                 :arg form_request: request made by form submission
81                 :type form_request: bool
82                 
83                 """
84                 
85                 self.type = type
86                 self.tid = tid
87                 self.request = request
88                 self.action = action
89                 self.method = method
90                 self.data = data if data is not None else []
91                 self.upload = upload
92                 self.form_request = form_request
93         
94         def __getattr__(self, key):
95                 try:
96                         return getattr(self.request, key)
97                 except:
98                         raise AttributeError
99
100
101 class ExtMethod(Callable, Sized):
102         """
103         Wraps a (previously :meth:`decorated <ExtMethod.decorate>`) function as an Ext.Direct method.
104         
105         """
106         
107         @classmethod
108         def decorate(cls, function=None, name=None, form_handler=False):
109                 """
110                 Applies metadata to function identifying it as wrappable, or returns a decorator for doing the same::
111                 
112                         @ExtMethod.decorate
113                         def method(self, request):
114                                 pass
115                         
116                         @ExtMethod.decorate(name='custom_name', form_handler=True)
117                         def form_handler_with_custom_name(self, request):
118                                 pass
119                 
120                 Intended for use on methods of classes already decorated by :meth:`ExtAction.decorate`.
121                 
122                 :arg name: custom Ext.Direct method name
123                 :type name: str
124                 :arg form_handler: function handles form submissions
125                 :type form_handler: bool
126                 :returns: function with metadata applied
127                 
128                 """
129                 
130                 def setter(function):
131                         setattr(function, '_ext_method', True)
132                         setattr(function, '_ext_method_form_handler', form_handler)
133                         if name is not None:
134                                 setattr(function, '_ext_method_name', name)
135                         return function
136                 if function is not None:
137                         return setter(function)
138                 return setter
139         
140         @classmethod
141         def validate(cls, function):
142                 """
143                 Validates that function has been :meth:`decorated <ExtMethod.decorate>` and is therefore wrappable.
144                 
145                 """
146                 
147                 return getattr(function, '_ext_method', False)
148         
149         def __init__(self, function):
150                 """
151                 :arg function: function to wrap
152                 :type function: callable
153                 
154                 If the function accepts variable positional arguments, the Ext.Direct method argument count will be increased by one for acceptance of a list.
155                 
156                 Similarly, if the function accepts variable keyword arguments, the argument count will be increased by one for acceptance of a dictionary.
157                 
158                 .. warning::
159                         
160                         Wrapped functions **must** accept at least one positional argument (in addition to self if function is a method): the :class:`ExtRequest` that caused the invocation.
161                 
162                 .. warning::
163                         
164                         Wrapped functions identified as handling form submissions **must** return a tuple containing:
165                         
166                         * a boolean indicating success or failure
167                         * a dictionary of fields mapped to errors, if any, or None
168                 
169                 """
170                 self.function = function
171                 self.form_handler = getattr(function, '_ext_method_form_handler', False)
172                 self.name = getattr(function, '_ext_method_name', function.__name__)
173                 
174                 argspec = getargspec(self.function)
175                 len_ = len(argspec.args)
176                 
177                 if len_ >= 2 and ismethod(self.function):
178                         len_ -= 2
179                 elif len_ >= 1 and not ismethod(self.function):
180                         len_ -= 1
181                 else:
182                         raise TypeError('%s cannot be wrapped as an Ext.Direct method as it does not take an ExtRequest as its first positional argument')
183                 
184                 if argspec.varargs is not None:
185                         len_ += 1
186                         self.accepts_varargs = True
187                 else:
188                         self.accepts_varargs = False
189                 
190                 if argspec.keywords is not None:
191                         len_ += 1
192                         self.accepts_keywords = True
193                 else:
194                         self.accepts_keywords = False
195                 
196                 self.len = len_
197         
198         @property
199         def spec(self):
200                 return {
201                         'name': self.name,
202                         'len': self.len,
203                         'formHandler': self.form_handler
204                 }
205         
206         def __len__(self):
207                 return self.len
208         
209         def __call__(self, request):
210                 """
211                 Invoke the wrapped function using the provided :class:`ExtRequest` and return the raw result.
212                 
213                 :arg request: the :class:`ExtRequest`
214                 
215                 :raises TypeError: the request did not provide the required number of arguments
216                 :raises Exception: the (form handling) function did not return a valid result for a form submission request
217                 
218                 """
219                 
220                 args = request.data
221                 args_len = len(args)
222                 
223                 if args_len != self.len:
224                         raise TypeError('%s takes exactly %i arguments (%i given)' % (self.name, self.len, args_len))
225                 
226                 keywords = {}
227                 if self.accepts_keywords:
228                         keywords = dict([(smart_str(k, 'ascii'), v) for k,v in args.pop().items()])
229                 
230                 varargs = []
231                 if self.accepts_varargs:
232                         varargs = args.pop()
233                 
234                 result = self.function(request, *(args + varargs), **keywords)
235                 
236                 if self.form_handler:
237                         try:
238                                 new_result = {
239                                         'success': result[0],
240                                         'errors': result[1],
241                                 }
242                                 if len(result) > 2:
243                                         new_result['pk'] = result[2]
244                                 
245                                 if new_result['success']:
246                                         del new_result['errors']
247                                 
248                                 result = new_result
249                         except:
250                                 raise Exception # pick a better one
251                 
252                 return result
253
254
255 ext_method = ExtMethod.decorate
256 """
257 Convenience alias for :meth:`ExtMethod.decorate`.
258
259 """
260
261
262 is_ext_method = ExtMethod.validate
263 """
264 Convenience alias for :meth:`ExtMethod.validate`.
265
266 """
267
268
269 class ExtAction(Callable, Mapping):
270         """
271         Wraps a (previously :meth:`decorated <ExtAction.decorate>`) object as an Ext.Direct action.
272         
273         """
274         
275         method_class = ExtMethod
276         """
277         The :class:`ExtMethod` subclass used when wrapping the wrapped object's members.
278         
279         """
280         
281         @classmethod
282         def decorate(cls, obj=None, name=None):
283                 """
284                 Applies metadata to obj identifying it as wrappable, or returns a decorator for doing the same::
285                 
286                         @ExtAction.decorate
287                         class MyAction(object):
288                                 pass
289                         
290                         @ExtAction.decorate(name='GoodAction')
291                         class BadAction(object):
292                                 pass
293                 
294                 Intended for use on classes with member functions (methods) already decorated with :meth:`ExtMethod.decorate`.
295                 
296                 :arg name: custom Ext.Direct action name
297                 :type name: str
298                 :returns: obj with metadata applied
299                 
300                 """
301                 
302                 def setter(obj):
303                         setattr(obj, '_ext_action', True)
304                         if name is not None:
305                                 setattr(obj, '_ext_action_name', name)
306                         return obj
307                 if obj is not None:
308                         return setter(obj)
309                 return setter
310         
311         @classmethod
312         def validate(cls, obj):
313                 """
314                 Validates that obj has been :meth:`decorated <ExtAction.decorate>` and is therefore wrappable.
315                 
316                 """
317                 
318                 return getattr(obj, '_ext_action', False)
319         
320         def __init__(self, obj):
321                 self.obj = obj
322                 self.name = getattr(obj, '_ext_action_name', obj.__name__ if isclass(obj) else obj.__class__.__name__)
323                 self._methods = None
324         
325         @property
326         def methods(self):
327                 if not self._methods:
328                         self._methods = dict((method.name, method) for method in (self.method_class(member) for name, member in getmembers(self.obj, self.method_class.validate)))
329                 return self._methods
330         
331         def __len__(self):
332                 return len(self.methods)
333         
334         def __iter__(self):
335                 return iter(self.methods)
336         
337         def __getitem__(self, name):
338                 return self.methods[name]
339                 
340         @property
341         def spec(self):
342                 """
343                 Returns a tuple containing:
344                         
345                         * the action name
346                         * a list of :class:`method specs <ExtMethod.spec>`
347                 
348                 Used internally by :class:`providers <ExtProvider>` to construct an Ext.Direct provider spec.
349                 
350                 """
351                 
352                 return self.name, list(method.spec for method in self.itervalues())
353         
354         def __call__(self, request):
355                 return self[request.method](request)
356
357
358 ext_action = ExtAction.decorate
359 """
360 Convenience alias for :meth:`ExtAction.decorate`.
361
362 """
363
364
365 is_ext_action = ExtAction.validate
366 """
367 Convenience alias for :meth:`ExtAction.validate`.
368
369 """
370
371
372 class ExtResponse(object):
373         """
374         Abstract base class for responses to :class:`requests <ExtRequest>`.
375         
376         """
377         __metaclass__ = ABCMeta
378         
379         @abstractproperty
380         def as_ext(self):
381                 raise NotImplementedError
382
383
384 class ExtResult(ExtResponse):
385         """
386         Represents a successful response to a :class:`request <ExtRequest>`.
387         
388         """
389         
390         def __init__(self, request, result):
391                 """
392                 :arg request: the originating Ext.Direct request
393                 :type request: :class:`ExtRequest`
394                 :arg result: the raw result
395                 
396                 """
397                 self.request = request
398                 self.result = result
399         
400         @property
401         def as_ext(self):
402                 return {
403                         'type': self.request.type,
404                         'tid': self.request.tid,
405                         'action': self.request.action,
406                         'method': self.request.method,
407                         'result': self.result
408                 }
409
410
411 class ExtException(ExtResponse):
412         """
413         Represents an exception raised by an unsuccessful response to a :class:`request <ExtRequest>`.
414         
415         .. warning::
416                 
417                 If :data:`django.conf.settings.DEBUG` is True, information about which exception was raised and where it was raised (including a traceback in both plain text and HTML) will be provided to the client.
418         
419         """
420         
421         def __init__(self, request, exc_info):
422                 """
423                 
424                 """
425                 self.request = request
426                 self.exc_info = exc_info
427         
428         @property
429         def as_ext(self):
430                 from django.conf import settings
431                 if settings.DEBUG:
432                         reporter = ExceptionReporter(self.request.request, *self.exc_info)
433                         return {
434                                 'type': 'exception',
435                                 'tid': self.request.tid,
436                                 'message': '%s: %s' % (self.exc_info[0], self.exc_info[1]),
437                                 'where': format_tb(self.exc_info[2])[0],
438                                 'identifier': '%s.%s' % (self.exc_info[0].__module__, self.exc_info[0].__name__),
439                                 'html': reporter.get_traceback_html()
440                         }
441                 else:
442                         return {
443                                 'type': 'exception',
444                                 'tid': self.request.tid
445                         }
446
447
448 class ExtProvider(Callable, Mapping):
449         """
450         Abstract base class for Ext.Direct provider implementations.
451         
452         """
453         
454         __metaclass__ = ABCMeta
455         
456         result_class = ExtResult
457         """
458         The :class:`ExtResponse` subclass used to represent the results of a successful Ext.Direct method invocation.
459         
460         """
461         
462         exception_class = ExtException
463         """
464         The :class:`ExtResponse` subclass used to represent the exception raised during an unsuccessful Ext.Direct method invocation.
465         
466         """
467         
468         @abstractproperty
469         def namespace(self):
470                 """
471                 The Ext.Direct provider namespace.
472                 
473                 """
474                 raise NotImplementedError
475         
476         @abstractproperty
477         def url(self):
478                 """
479                 The Ext.Direct provider url.
480                 
481                 """
482                 raise NotImplementedError
483         
484         @abstractproperty
485         def type(self):
486                 """
487                 The Ext.Direct provider type.
488                 
489                 """
490                 raise NotImplementedError
491         
492         @abstractproperty
493         def actions(self):
494                 """
495                 The dictionary of action names and :class:`ExtAction` instances handled by the provider.
496                 
497                 """
498                 raise NotImplementedError
499         
500         def __len__(self):
501                 return len(self.actions)
502         
503         def __iter__(self):
504                 return iter(self.actions)
505         
506         def __getitem__(self, name):
507                 return self.actions[name]
508         
509         @property
510         def spec(self):
511                 return {
512                         'namespace': self.namespace,
513                         'url': self.url,
514                         'type': self.type,
515                         'actions': dict(action.spec for action in self.itervalues())
516                 }
517         
518         def __call__(self, request):
519                 """
520                 Returns a :class:`response <ExtResponse>` to the :class:`request <ExtRequest>`.
521                 
522                 """
523                 try:
524                         return self.result_class(request=request, result=self[request.action](request))
525                 except Exception:
526                         return self.exception_class(request=request, exc_info=sys.exc_info())
527
528
529 class ExtRouter(ExtProvider):
530         """
531         A :class:`provider <ExtProvider>` base class with an implementation capable of handling the complete round-trip from a :class:`django.http.HttpRequest` to a :class:`django.http.HttpResponse`.
532         
533         """
534         
535         __metaclass__ = ABCMeta
536         
537         request_class = ExtRequest
538         """
539         The :class:`ExtRequest` subclass used parse and to represent the individual Ext.Direct requests within a :class:`django.http.HttpRequest`.
540         
541         """
542         
543         @classmethod
544         def json_object_hook(cls, obj):
545                 if obj.get('q_object', False):
546                         return Q._new_instance(obj['children'], obj['connector'], obj['negated'])
547                 return obj
548         
549         @classmethod
550         def json_default(cls, obj):
551                 from django.forms.models import ModelChoiceIterator
552                 from django.db.models.query import ValuesListQuerySet
553                 from django.utils.functional import Promise
554                 
555                 if isinstance(obj, ExtResponse):
556                         return obj.as_ext
557                 elif isinstance(obj, datetime.datetime):
558                         obj = obj.replace(microsecond=0)
559                         return obj.isoformat(' ')
560                 elif isinstance(obj, ModelChoiceIterator) or isinstance(obj, ValuesListQuerySet):
561                         return list(obj)
562                 elif isinstance(obj, Promise):
563                         return unicode(obj)
564                 elif isinstance(obj, Q):
565                         return {
566                                 'q_object': True,
567                                 'connector': obj.connector,
568                                 'negated': obj.negated,
569                                 'children': obj.children
570                         }
571                 else:
572                         raise TypeError, 'Object of type %s with value of %s is not JSON serializable' % (type(obj), repr(obj))
573         
574         def render_to_response(self, request):
575                 """
576                 Returns a :class:`django.http.HttpResponse` containing the :class:`response(s) <ExtResponse>` to the :class:`request(s) <ExtRequest>` in the provided :class:`django.http.HttpRequest`.
577                 
578                 """
579                 
580                 from django.conf import settings
581                 
582                 requests = self.request_class.parse(request, object_hook=self.json_object_hook)
583                 responses = []
584                 html_response = False
585                 
586                 for request in requests:
587                         if request.form_request and request.upload:
588                                 html_response = True
589                         responses.append(self(request))
590                 
591                 response = responses[0] if len(responses) == 1 else responses
592                 json_response = json.dumps(responses, default=self.json_default)
593                 
594                 if html_response:
595                         return HttpResponse('<html><body><textarea>%s</textarea></body></html>' % json_response)
596                 return HttpResponse(json_response, content_type='application/json; charset=%s' % settings.DEFAULT_CHARSET)
597
598
599 class SimpleExtRouter(ExtRouter):
600         """
601         A simple concrete :class:`router <ExtRouter>` implementation.
602         
603         """
604         
605         def __init__(self, namespace, url, actions=None, type='remoting'):
606                 """
607                 :arg namespace: the Ext.Direct provider namespace
608                 :type namespace: str
609                 :arg url: the Ext.Direct provider url
610                 :type url: str
611                 :arg actions: the dictionary of action names and :class:`ExtAction` instances handled by the provider
612                 :type actions: dict
613                 :arg type: the Ext.Direct provider type
614                 :type type: str
615                 
616                 """
617                 
618                 self._type = type
619                 self._namespace = namespace
620                 self._url = url
621                 self._actions = actions if actions is not None else {}
622         
623         @property
624         def namespace(self):
625                 return self._namespace
626         
627         @property
628         def url(self):
629                 return self._url
630         
631         @property
632         def type(self):
633                 return self._type
634         
635         @property
636         def actions(self):
637                 return self._actions