Package omeroweb :: Package webadmin :: Package tests :: Module request_factory
[hide private]
[frames] | no frames]

Source Code for Module omeroweb.webadmin.tests.request_factory

  1  #!/usr/bin/env python 
  2  # 
  3  # 
  4  # 
  5  # Copyright (c) 2008 University of Dundee.  
  6  # 
  7  # This program is free software: you can redistribute it and/or modify 
  8  # it under the terms of the GNU Affero General Public License as 
  9  # published by the Free Software Foundation, either version 3 of the 
 10  # License, or (at your option) any later version. 
 11  # 
 12  # This program is distributed in the hope that it will be useful, 
 13  # but WITHOUT ANY WARRANTY; without even the implied warranty of 
 14  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
 15  # GNU Affero General Public License for more details. 
 16  # 
 17  # You should have received a copy of the GNU Affero General Public License 
 18  # along with this program.  If not, see <http://www.gnu.org/licenses/>. 
 19  # 
 20   
 21  import urllib 
 22  from urlparse import urlparse, urlunparse, urlsplit 
 23  import sys 
 24  import os 
 25  import re 
 26  import mimetypes 
 27  import warnings 
 28  try: 
 29      from cStringIO import StringIO 
 30  except ImportError: 
 31      from StringIO import StringIO 
 32       
 33  from django.conf import settings 
 34  from django.contrib.auth import authenticate, login 
 35  from django.core.handlers.base import BaseHandler 
 36  from django.core.handlers.wsgi import WSGIRequest 
 37  from django.core.signals import got_request_exception 
 38  from django.http import SimpleCookie, HttpRequest, QueryDict 
 39  from django.template import TemplateDoesNotExist 
 40  from django.test import signals 
 41  from django.utils.functional import curry 
 42  from django.utils.encoding import smart_str 
 43  from django.utils.http import urlencode 
 44  from django.utils.importlib import import_module 
 45  from django.utils.itercompat import is_iterable 
 46  from django.db import transaction, close_connection 
 47  from django.test.utils import ContextList 
 48   
 49  from omeroweb.webgateway import views as webgateway_views 
 50   
 51  __all__ = ('Client', 'RequestFactory', 'encode_file', 'encode_multipart') 
 52   
 53   
 54  BOUNDARY = 'BoUnDaRyStRiNg' 
 55  MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY 
 56  CONTENT_TYPE_RE = re.compile('.*; charset=([\w\d-]+);?') 
 57   
58 -def fakeRequest (method, path="/", params={}, **kwargs):
59 def bogus_request(self, **request): 60 """ 61 Usage: 62 rf = RequestFactory() 63 get_request = rf.get('/hello/') 64 post_request = rf.post('/submit/', {'foo': 'bar'}) 65 """ 66 if not method.lower() in ('post', 'get'): 67 raise AttributeError("Method must be 'get' or 'post'") 68 if not isinstance(params, dict): 69 raise AttributeError("Params must be a dictionary") 70 71 rf = RequestFactory() 72 r = getattr(rf, method.lower())(path, params) 73 if 'django.contrib.sessions' in settings.INSTALLED_APPS: 74 engine = __import__(settings.SESSION_ENGINE, {}, {}, ['']) 75 r.session = engine.SessionStore() 76 return r
77 Client.bogus_request = bogus_request 78 c = Client() 79 return c.bogus_request(**kwargs) 80 81
82 -class FakePayload(object):
83 """ 84 A wrapper around StringIO that restricts what can be read since data from 85 the network can't be seeked and cannot be read outside of its content 86 length. This makes sure that views can't do anything under the test client 87 that wouldn't work in Real Life. 88 """
89 - def __init__(self, content):
90 self.__content = StringIO(content) 91 self.__len = len(content)
92
93 - def read(self, num_bytes=None):
94 if num_bytes is None: 95 num_bytes = self.__len or 1 96 assert self.__len >= num_bytes, "Cannot read more than the available bytes from the HTTP incoming data." 97 content = self.__content.read(num_bytes) 98 self.__len -= num_bytes 99 return content
100 101
102 -class ClientHandler(BaseHandler):
103 """ 104 A HTTP Handler that can be used for testing purposes. 105 Uses the WSGI interface to compose requests, but returns 106 the raw HttpResponse object 107 """
108 - def __init__(self, enforce_csrf_checks=True, *args, **kwargs):
109 self.enforce_csrf_checks = enforce_csrf_checks 110 super(ClientHandler, self).__init__(*args, **kwargs)
111
112 - def __call__(self, environ):
113 from django.conf import settings 114 from django.core import signals 115 116 # Set up middleware if needed. We couldn't do this earlier, because 117 # settings weren't available. 118 if self._request_middleware is None: 119 self.load_middleware() 120 121 signals.request_started.send(sender=self.__class__) 122 try: 123 request = WSGIRequest(environ) 124 # sneaky little hack so that we can easily get round 125 # CsrfViewMiddleware. This makes life easier, and is probably 126 # required for backwards compatibility with external tests against 127 # admin views. 128 request._dont_enforce_csrf_checks = not self.enforce_csrf_checks 129 response = self.get_response(request) 130 finally: 131 signals.request_finished.disconnect(close_connection) 132 signals.request_finished.send(sender=self.__class__) 133 signals.request_finished.connect(close_connection) 134 135 return response
136
137 -def store_rendered_templates(store, signal, sender, template, context, **kwargs):
138 """ 139 Stores templates and contexts that are rendered. 140 """ 141 store.setdefault('templates', []).append(template) 142 store.setdefault('context', ContextList()).append(context)
143
144 -def encode_multipart(boundary, data):
145 """ 146 Encodes multipart POST data from a dictionary of form values. 147 148 The key will be used as the form data name; the value will be transmitted 149 as content. If the value is a file, the contents of the file will be sent 150 as an application/octet-stream; otherwise, str(value) will be sent. 151 """ 152 lines = [] 153 to_str = lambda s: smart_str(s, settings.DEFAULT_CHARSET) 154 155 # Not by any means perfect, but good enough for our purposes. 156 is_file = lambda thing: hasattr(thing, "read") and callable(thing.read) 157 158 # Each bit of the multipart form data could be either a form value or a 159 # file, or a *list* of form values and/or files. Remember that HTTP field 160 # names can be duplicated! 161 for (key, value) in data.items(): 162 if is_file(value): 163 lines.extend(encode_file(boundary, key, value)) 164 elif not isinstance(value, basestring) and is_iterable(value): 165 for item in value: 166 if is_file(item): 167 lines.extend(encode_file(boundary, key, item)) 168 else: 169 lines.extend([ 170 '--' + boundary, 171 'Content-Disposition: form-data; name="%s"' % to_str(key), 172 '', 173 to_str(item) 174 ]) 175 else: 176 lines.extend([ 177 '--' + boundary, 178 'Content-Disposition: form-data; name="%s"' % to_str(key), 179 '', 180 to_str(value) 181 ]) 182 183 lines.extend([ 184 '--' + boundary + '--', 185 '', 186 ]) 187 return '\r\n'.join(lines)
188
189 -def encode_file(boundary, key, file):
190 to_str = lambda s: smart_str(s, settings.DEFAULT_CHARSET) 191 content_type = mimetypes.guess_type(file.name)[0] 192 if content_type is None: 193 content_type = 'application/octet-stream' 194 return [ 195 '--' + boundary, 196 'Content-Disposition: form-data; name="%s"; filename="%s"' \ 197 % (to_str(key), to_str(os.path.basename(file.name))), 198 'Content-Type: %s' % content_type, 199 '', 200 file.read() 201 ]
202 203 204
205 -class RequestFactory(object):
206 """ 207 Class that lets you create mock Request objects for use in testing. 208 209 Usage: 210 211 rf = RequestFactory() 212 get_request = rf.get('/hello/') 213 post_request = rf.post('/submit/', {'foo': 'bar'}) 214 215 Once you have a request object you can pass it to any view function, 216 just as if that view had been hooked up using a URLconf. 217 """
218 - def __init__(self, **defaults):
219 self.defaults = defaults 220 self.cookies = SimpleCookie() 221 self.errors = StringIO()
222
223 - def _base_environ(self, **request):
224 """ 225 The base environment for a request. 226 """ 227 environ = { 228 'HTTP_COOKIE': self.cookies.output(header='', sep='; '), 229 'PATH_INFO': '/', 230 'QUERY_STRING': '', 231 'REMOTE_ADDR': '127.0.0.1', 232 'REQUEST_METHOD': 'GET', 233 'SCRIPT_NAME': '', 234 'SERVER_NAME': 'testserver', 235 'SERVER_PORT': '80', 236 'SERVER_PROTOCOL': 'HTTP/1.1', 237 'wsgi.version': (1,0), 238 'wsgi.url_scheme': 'http', 239 'wsgi.errors': self.errors, 240 'wsgi.multiprocess': True, 241 'wsgi.multithread': False, 242 'wsgi.run_once': False, 243 } 244 environ.update(self.defaults) 245 environ.update(request) 246 return environ
247
248 - def request(self, **request):
249 "Construct a generic request object with session." 250 return WSGIRequest(self._base_environ(**request))
251
252 - def get(self, path, data={}, **extra):
253 "Construct a GET request" 254 255 parsed = urlparse(path) 256 r = { 257 'CONTENT_TYPE': 'text/html; charset=utf-8', 258 'PATH_INFO': urllib.unquote(parsed[2]), 259 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4], 260 'REQUEST_METHOD': 'GET', 261 'wsgi.input': FakePayload('') 262 } 263 r.update(extra) 264 return self.request(**r)
265
266 - def post(self, path, data={}, content_type=MULTIPART_CONTENT, 267 **extra):
268 "Construct a POST request." 269 270 if content_type is MULTIPART_CONTENT: 271 post_data = encode_multipart(BOUNDARY, data) 272 else: 273 # Encode the content so that the byte representation is correct. 274 match = CONTENT_TYPE_RE.match(content_type) 275 if match: 276 charset = match.group(1) 277 else: 278 charset = settings.DEFAULT_CHARSET 279 post_data = smart_str(data, encoding=charset) 280 281 parsed = urlparse(path) 282 r = { 283 'CONTENT_LENGTH': len(post_data), 284 'CONTENT_TYPE': content_type, 285 'PATH_INFO': urllib.unquote(parsed[2]), 286 'QUERY_STRING': parsed[4], 287 'REQUEST_METHOD': 'POST', 288 'wsgi.input': FakePayload(post_data), 289 } 290 r.update(extra) 291 return self.request(**r)
292
293 - def head(self, path, data={}, **extra):
294 "Construct a HEAD request." 295 296 parsed = urlparse(path) 297 r = { 298 'CONTENT_TYPE': 'text/html; charset=utf-8', 299 'PATH_INFO': urllib.unquote(parsed[2]), 300 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4], 301 'REQUEST_METHOD': 'HEAD', 302 'wsgi.input': FakePayload('') 303 } 304 r.update(extra) 305 return self.request(**r)
306
307 - def options(self, path, data={}, **extra):
308 "Constrict an OPTIONS request" 309 310 parsed = urlparse(path) 311 r = { 312 'PATH_INFO': urllib.unquote(parsed[2]), 313 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4], 314 'REQUEST_METHOD': 'OPTIONS', 315 'wsgi.input': FakePayload('') 316 } 317 r.update(extra) 318 return self.request(**r)
319
320 - def put(self, path, data={}, content_type=MULTIPART_CONTENT, 321 **extra):
322 "Construct a PUT request." 323 324 if content_type is MULTIPART_CONTENT: 325 post_data = encode_multipart(BOUNDARY, data) 326 else: 327 post_data = data 328 329 # Make `data` into a querystring only if it's not already a string. If 330 # it is a string, we'll assume that the caller has already encoded it. 331 query_string = None 332 if not isinstance(data, basestring): 333 query_string = urlencode(data, doseq=True) 334 335 parsed = urlparse(path) 336 r = { 337 'CONTENT_LENGTH': len(post_data), 338 'CONTENT_TYPE': content_type, 339 'PATH_INFO': urllib.unquote(parsed[2]), 340 'QUERY_STRING': query_string or parsed[4], 341 'REQUEST_METHOD': 'PUT', 342 'wsgi.input': FakePayload(post_data), 343 } 344 r.update(extra) 345 return self.request(**r)
346
347 - def delete(self, path, data={}, **extra):
348 "Construct a DELETE request." 349 350 parsed = urlparse(path) 351 r = { 352 'PATH_INFO': urllib.unquote(parsed[2]), 353 'QUERY_STRING': urlencode(data, doseq=True) or parsed[4], 354 'REQUEST_METHOD': 'DELETE', 355 'wsgi.input': FakePayload('') 356 } 357 r.update(extra) 358 return self.request(**r)
359 360
361 -class Client(RequestFactory):
362 """ 363 A class that can act as a client for testing purposes. 364 365 It allows the user to compose GET and POST requests, and 366 obtain the response that the server gave to those requests. 367 The server Response objects are annotated with the details 368 of the contexts and templates that were rendered during the 369 process of serving the request. 370 371 Client objects are stateful - they will retain cookie (and 372 thus session) details for the lifetime of the Client instance. 373 374 This is not intended as a replacement for Twill/Selenium or 375 the like - it is here to allow testing against the 376 contexts and templates produced by a view, rather than the 377 HTML rendered to the end-user. 378 """
379 - def __init__(self, enforce_csrf_checks=False, **defaults):
380 super(Client, self).__init__(**defaults) 381 self.handler = ClientHandler(enforce_csrf_checks) 382 self.exc_info = None
383
384 - def store_exc_info(self, **kwargs):
385 """ 386 Stores exceptions when they are generated by a view. 387 """ 388 self.exc_info = sys.exc_info()
389
390 - def _session(self):
391 """ 392 Obtains the current session variables. 393 """ 394 if 'django.contrib.sessions' in settings.INSTALLED_APPS: 395 engine = import_module(settings.SESSION_ENGINE) 396 cookie = self.cookies.get(settings.SESSION_COOKIE_NAME, None) 397 if cookie: 398 return engine.SessionStore(cookie.value) 399 return {}
400 session = property(_session) 401 402
403 - def request(self, **request):
404 """ 405 The master request method. Composes the environment dictionary 406 and passes to the handler, returning the result of the handler. 407 Assumes defaults for the query environment, which can be overridden 408 using the arguments to the request. 409 """ 410 environ = self._base_environ(**request) 411 412 # Curry a data dictionary into an instance of the template renderer 413 # callback function. 414 data = {} 415 on_template_render = curry(store_rendered_templates, data) 416 signals.template_rendered.connect(on_template_render, dispatch_uid="template-render") 417 # Capture exceptions created by the handler. 418 got_request_exception.connect(self.store_exc_info, dispatch_uid="request-exception") 419 try: 420 421 try: 422 response = self.handler(environ) 423 except TemplateDoesNotExist, e: 424 # If the view raises an exception, Django will attempt to show 425 # the 500.html template. If that template is not available, 426 # we should ignore the error in favor of re-raising the 427 # underlying exception that caused the 500 error. Any other 428 # template found to be missing during view error handling 429 # should be reported as-is. 430 if e.args != ('500.html',): 431 raise 432 433 # Look for a signalled exception, clear the current context 434 # exception data, then re-raise the signalled exception. 435 # Also make sure that the signalled exception is cleared from 436 # the local cache! 437 if self.exc_info: 438 exc_info = self.exc_info 439 self.exc_info = None 440 raise exc_info[1], None, exc_info[2] 441 442 # Save the client and request that stimulated the response. 443 response.client = self 444 response.request = request 445 446 # Add any rendered template detail to the response. 447 response.templates = data.get("templates", []) 448 response.context = data.get("context") 449 450 # Flatten a single context. Not really necessary anymore thanks to 451 # the __getattr__ flattening in ContextList, but has some edge-case 452 # backwards-compatibility implications. 453 if response.context and len(response.context) == 1: 454 response.context = response.context[0] 455 456 # Provide a backwards-compatible (but pending deprecation) response.template 457 def _get_template(self): 458 warnings.warn("response.template is deprecated; use response.templates instead (which is always a list)", 459 PendingDeprecationWarning, stacklevel=2) 460 if not self.templates: 461 return None 462 elif len(self.templates) == 1: 463 return self.templates[0] 464 return self.templates
465 response.__class__.template = property(_get_template) 466 467 # Update persistent cookie data. 468 if response.cookies: 469 self.cookies.update(response.cookies) 470 471 return response 472 finally: 473 signals.template_rendered.disconnect(dispatch_uid="template-render") 474 got_request_exception.disconnect(dispatch_uid="request-exception")
475
476 - def get(self, path, data={}, follow=False, **extra):
477 """ 478 Requests a response from the server using GET. 479 """ 480 response = super(Client, self).get(path, data=data, **extra) 481 if follow: 482 response = self._handle_redirects(response, **extra) 483 return response
484
485 - def post(self, path, data={}, content_type=MULTIPART_CONTENT, 486 follow=False, **extra):
487 """ 488 Requests a response from the server using POST. 489 """ 490 response = super(Client, self).post(path, data=data, content_type=content_type, **extra) 491 if follow: 492 response = self._handle_redirects(response, **extra) 493 return response
494
495 - def head(self, path, data={}, follow=False, **extra):
496 """ 497 Request a response from the server using HEAD. 498 """ 499 response = super(Client, self).head(path, data=data, **extra) 500 if follow: 501 response = self._handle_redirects(response, **extra) 502 return response
503
504 - def options(self, path, data={}, follow=False, **extra):
505 """ 506 Request a response from the server using OPTIONS. 507 """ 508 response = super(Client, self).options(path, data=data, **extra) 509 if follow: 510 response = self._handle_redirects(response, **extra) 511 return response
512
513 - def put(self, path, data={}, content_type=MULTIPART_CONTENT, 514 follow=False, **extra):
515 """ 516 Send a resource to the server using PUT. 517 """ 518 response = super(Client, self).put(path, data=data, content_type=content_type, **extra) 519 if follow: 520 response = self._handle_redirects(response, **extra) 521 return response
522
523 - def delete(self, path, data={}, follow=False, **extra):
524 """ 525 Send a DELETE request to the server. 526 """ 527 response = super(Client, self).delete(path, data=data, **extra) 528 if follow: 529 response = self._handle_redirects(response, **extra) 530 return response
531
532 - def login(self, login,password, server_id=1, secure=True):
533 """ 534 Sets the Factory to appear as if it has successfully logged into a site. 535 536 Returns True if login is possible; False if the provided credentials 537 are incorrect, or the user is inactive, or if the sessions framework is 538 not available. 539 """ 540 engine = import_module(settings.SESSION_ENGINE) 541 542 params = { 543 'username': login, 544 'password': password, 545 'server':server_id, 546 'ssl':'on' 547 } 548 request = fakeRequest(method="post", path="/webadmin/login/", params=params) 549 550 if self.session: 551 request.session = self.session 552 else: 553 request.session = engine.SessionStore() 554 555 # Set the cookie to represent the session. 556 session_cookie = settings.SESSION_COOKIE_NAME 557 self.cookies[session_cookie] = request.session.session_key 558 cookie_data = { 559 'max-age': None, 560 'path': '/', 561 'domain': settings.SESSION_COOKIE_DOMAIN, 562 'secure': settings.SESSION_COOKIE_SECURE or None, 563 'expires': None, 564 } 565 self.cookies[session_cookie].update(cookie_data) 566 567 blitz = settings.SERVER_LIST.get(pk=request.REQUEST.get('server')) 568 request.session['server'] = blitz.id 569 request.session['host'] = blitz.host 570 request.session['port'] = blitz.port 571 request.session['username'] = request.REQUEST.get('username').encode('utf-8').strip() 572 request.session['password'] = request.REQUEST.get('password').encode('utf-8').strip() 573 request.session['ssl'] = (True, False)[request.REQUEST.get('ssl') is None] 574 575 conn = webgateway_views.getBlitzConnection(request, useragent="TEST.webadmin") 576 if conn is not None and conn.isConnected() and conn.keepAlive(): 577 request.session.save() 578 return True 579 else: 580 webgateway_views._session_logout(request, request.session.get('server')) 581 return False
582
583 - def logout(self):
584 """ 585 Removes the authenticated user's cookies and session object. 586 587 Causes the authenticated user to be logged out. 588 """ 589 request = fakeRequest(method="get", path="/webadmin/logout/") 590 webgateway_views._session_logout(request, request.session.get('server')) 591 592 session = import_module(settings.SESSION_ENGINE).SessionStore() 593 session_cookie = self.cookies.get(settings.SESSION_COOKIE_NAME) 594 if session_cookie: 595 session.delete(session_key=session_cookie.value) 596 self.cookies = SimpleCookie()
597
598 - def _handle_redirects(self, response, **extra):
599 "Follows any redirects by requesting responses from the server using GET." 600 601 response.redirect_chain = [] 602 while response.status_code in (301, 302, 303, 307): 603 url = response['Location'] 604 scheme, netloc, path, query, fragment = urlsplit(url) 605 606 redirect_chain = response.redirect_chain 607 redirect_chain.append((url, response.status_code)) 608 609 if scheme: 610 extra['wsgi.url_scheme'] = scheme 611 612 # The test client doesn't handle external links, 613 # but since the situation is simulated in test_client, 614 # we fake things here by ignoring the netloc portion of the 615 # redirected URL. 616 response = self.get(path, QueryDict(query), follow=False, **extra) 617 response.redirect_chain = redirect_chain 618 619 # Prevent loops 620 if response.redirect_chain[-1] in response.redirect_chain[0:-1]: 621 break 622 return response
623