##// END OF EJS Templates
core: use a custom filter for rendering all mako templates....
marcink -
r1949:8462771d default
parent child Browse files
Show More
@@ -1,529 +1,534 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Pylons middleware initialization
23 23 """
24 24 import logging
25 25 import traceback
26 26 from collections import OrderedDict
27 27
28 28 from paste.registry import RegistryManager
29 29 from paste.gzipper import make_gzip_middleware
30 30 from pylons.wsgiapp import PylonsApp
31 31 from pyramid.authorization import ACLAuthorizationPolicy
32 32 from pyramid.config import Configurator
33 33 from pyramid.settings import asbool, aslist
34 34 from pyramid.wsgi import wsgiapp
35 35 from pyramid.httpexceptions import (
36 36 HTTPException, HTTPError, HTTPInternalServerError, HTTPFound)
37 37 from pyramid.events import ApplicationCreated
38 38 from pyramid.renderers import render_to_response
39 39 from routes.middleware import RoutesMiddleware
40 40 import rhodecode
41 41
42 42 from rhodecode.model import meta
43 43 from rhodecode.config import patches
44 44 from rhodecode.config.routing import STATIC_FILE_PREFIX
45 45 from rhodecode.config.environment import (
46 46 load_environment, load_pyramid_environment)
47 47
48 48 from rhodecode.lib.vcs import VCSCommunicationError
49 49 from rhodecode.lib.exceptions import VCSServerUnavailable
50 50 from rhodecode.lib.middleware.appenlight import wrap_in_appenlight_if_enabled
51 51 from rhodecode.lib.middleware.error_handling import (
52 52 PylonsErrorHandlingMiddleware)
53 53 from rhodecode.lib.middleware.https_fixup import HttpsFixup
54 54 from rhodecode.lib.middleware.vcs import VCSMiddleware
55 55 from rhodecode.lib.plugins.utils import register_rhodecode_plugin
56 56 from rhodecode.lib.utils2 import aslist as rhodecode_aslist, AttributeDict
57 57 from rhodecode.subscribers import (
58 58 scan_repositories_if_enabled, write_js_routes_if_enabled,
59 59 write_metadata_if_needed)
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # this is used to avoid avoid the route lookup overhead in routesmiddleware
66 66 # for certain routes which won't go to pylons to - eg. static files, debugger
67 67 # it is only needed for the pylons migration and can be removed once complete
68 68 class SkippableRoutesMiddleware(RoutesMiddleware):
69 69 """ Routes middleware that allows you to skip prefixes """
70 70
71 71 def __init__(self, *args, **kw):
72 72 self.skip_prefixes = kw.pop('skip_prefixes', [])
73 73 super(SkippableRoutesMiddleware, self).__init__(*args, **kw)
74 74
75 75 def __call__(self, environ, start_response):
76 76 for prefix in self.skip_prefixes:
77 77 if environ['PATH_INFO'].startswith(prefix):
78 78 # added to avoid the case when a missing /_static route falls
79 79 # through to pylons and causes an exception as pylons is
80 80 # expecting wsgiorg.routingargs to be set in the environ
81 81 # by RoutesMiddleware.
82 82 if 'wsgiorg.routing_args' not in environ:
83 83 environ['wsgiorg.routing_args'] = (None, {})
84 84 return self.app(environ, start_response)
85 85
86 86 return super(SkippableRoutesMiddleware, self).__call__(
87 87 environ, start_response)
88 88
89 89
90 90 def make_app(global_conf, static_files=True, **app_conf):
91 91 """Create a Pylons WSGI application and return it
92 92
93 93 ``global_conf``
94 94 The inherited configuration for this application. Normally from
95 95 the [DEFAULT] section of the Paste ini file.
96 96
97 97 ``app_conf``
98 98 The application's local configuration. Normally specified in
99 99 the [app:<name>] section of the Paste ini file (where <name>
100 100 defaults to main).
101 101
102 102 """
103 103 # Apply compatibility patches
104 104 patches.kombu_1_5_1_python_2_7_11()
105 105 patches.inspect_getargspec()
106 106
107 107 # Configure the Pylons environment
108 108 config = load_environment(global_conf, app_conf)
109 109
110 110 # The Pylons WSGI app
111 111 app = PylonsApp(config=config)
112 112
113 113 # Establish the Registry for this application
114 114 app = RegistryManager(app)
115 115
116 116 app.config = config
117 117
118 118 return app
119 119
120 120
121 121 def make_pyramid_app(global_config, **settings):
122 122 """
123 123 Constructs the WSGI application based on Pyramid and wraps the Pylons based
124 124 application.
125 125
126 126 Specials:
127 127
128 128 * We migrate from Pylons to Pyramid. While doing this, we keep both
129 129 frameworks functional. This involves moving some WSGI middlewares around
130 130 and providing access to some data internals, so that the old code is
131 131 still functional.
132 132
133 133 * The application can also be integrated like a plugin via the call to
134 134 `includeme`. This is accompanied with the other utility functions which
135 135 are called. Changing this should be done with great care to not break
136 136 cases when these fragments are assembled from another place.
137 137
138 138 """
139 139 # The edition string should be available in pylons too, so we add it here
140 140 # before copying the settings.
141 141 settings.setdefault('rhodecode.edition', 'Community Edition')
142 142
143 143 # As long as our Pylons application does expect "unprepared" settings, make
144 144 # sure that we keep an unmodified copy. This avoids unintentional change of
145 145 # behavior in the old application.
146 146 settings_pylons = settings.copy()
147 147
148 148 sanitize_settings_and_apply_defaults(settings)
149 149 config = Configurator(settings=settings)
150 150 add_pylons_compat_data(config.registry, global_config, settings_pylons)
151 151
152 152 load_pyramid_environment(global_config, settings)
153 153
154 154 includeme_first(config)
155 155 includeme(config)
156 156
157 157 pyramid_app = config.make_wsgi_app()
158 158 pyramid_app = wrap_app_in_wsgi_middlewares(pyramid_app, config)
159 159 pyramid_app.config = config
160 160
161 161 # creating the app uses a connection - return it after we are done
162 162 meta.Session.remove()
163 163
164 164 return pyramid_app
165 165
166 166
167 167 def make_not_found_view(config):
168 168 """
169 169 This creates the view which should be registered as not-found-view to
170 170 pyramid. Basically it contains of the old pylons app, converted to a view.
171 171 Additionally it is wrapped by some other middlewares.
172 172 """
173 173 settings = config.registry.settings
174 174 vcs_server_enabled = settings['vcs.server.enable']
175 175
176 176 # Make pylons app from unprepared settings.
177 177 pylons_app = make_app(
178 178 config.registry._pylons_compat_global_config,
179 179 **config.registry._pylons_compat_settings)
180 180 config.registry._pylons_compat_config = pylons_app.config
181 181
182 182 # Appenlight monitoring.
183 183 pylons_app, appenlight_client = wrap_in_appenlight_if_enabled(
184 184 pylons_app, settings)
185 185
186 186 # The pylons app is executed inside of the pyramid 404 exception handler.
187 187 # Exceptions which are raised inside of it are not handled by pyramid
188 188 # again. Therefore we add a middleware that invokes the error handler in
189 189 # case of an exception or error response. This way we return proper error
190 190 # HTML pages in case of an error.
191 191 reraise = (settings.get('debugtoolbar.enabled', False) or
192 192 rhodecode.disable_error_handler)
193 193 pylons_app = PylonsErrorHandlingMiddleware(
194 194 pylons_app, error_handler, reraise)
195 195
196 196 # The VCSMiddleware shall operate like a fallback if pyramid doesn't find a
197 197 # view to handle the request. Therefore it is wrapped around the pylons
198 198 # app. It has to be outside of the error handling otherwise error responses
199 199 # from the vcsserver are converted to HTML error pages. This confuses the
200 200 # command line tools and the user won't get a meaningful error message.
201 201 if vcs_server_enabled:
202 202 pylons_app = VCSMiddleware(
203 203 pylons_app, settings, appenlight_client, registry=config.registry)
204 204
205 205 # Convert WSGI app to pyramid view and return it.
206 206 return wsgiapp(pylons_app)
207 207
208 208
209 209 def add_pylons_compat_data(registry, global_config, settings):
210 210 """
211 211 Attach data to the registry to support the Pylons integration.
212 212 """
213 213 registry._pylons_compat_global_config = global_config
214 214 registry._pylons_compat_settings = settings
215 215
216 216
217 217 def error_handler(exception, request):
218 218 import rhodecode
219 219 from rhodecode.lib import helpers
220 220
221 221 rhodecode_title = rhodecode.CONFIG.get('rhodecode_title') or 'RhodeCode'
222 222
223 223 base_response = HTTPInternalServerError()
224 224 # prefer original exception for the response since it may have headers set
225 225 if isinstance(exception, HTTPException):
226 226 base_response = exception
227 227 elif isinstance(exception, VCSCommunicationError):
228 228 base_response = VCSServerUnavailable()
229 229
230 230 def is_http_error(response):
231 231 # error which should have traceback
232 232 return response.status_code > 499
233 233
234 234 if is_http_error(base_response):
235 235 log.exception(
236 236 'error occurred handling this request for path: %s', request.path)
237 237
238 238 c = AttributeDict()
239 239 c.error_message = base_response.status
240 240 c.error_explanation = base_response.explanation or str(base_response)
241 241 c.visual = AttributeDict()
242 242
243 243 c.visual.rhodecode_support_url = (
244 244 request.registry.settings.get('rhodecode_support_url') or
245 245 request.route_url('rhodecode_support')
246 246 )
247 247 c.redirect_time = 0
248 248 c.rhodecode_name = rhodecode_title
249 249 if not c.rhodecode_name:
250 250 c.rhodecode_name = 'Rhodecode'
251 251
252 252 c.causes = []
253 253 if hasattr(base_response, 'causes'):
254 254 c.causes = base_response.causes
255 255 c.messages = helpers.flash.pop_messages(request=request)
256 256 c.traceback = traceback.format_exc()
257 257 response = render_to_response(
258 258 '/errors/error_document.mako', {'c': c, 'h': helpers}, request=request,
259 259 response=base_response)
260 260
261 261 return response
262 262
263 263
264 264 def includeme(config):
265 265 settings = config.registry.settings
266 266
267 267 # plugin information
268 268 config.registry.rhodecode_plugins = OrderedDict()
269 269
270 270 config.add_directive(
271 271 'register_rhodecode_plugin', register_rhodecode_plugin)
272 272
273 273 if asbool(settings.get('appenlight', 'false')):
274 274 config.include('appenlight_client.ext.pyramid_tween')
275 275
276 if not 'mako.default_filters' in settings:
277 # set custom default filters if we don't have it defined
278 settings['mako.imports'] = 'from rhodecode.lib.base import h_filter'
279 settings['mako.default_filters'] = 'h_filter'
280
276 281 # Includes which are required. The application would fail without them.
277 282 config.include('pyramid_mako')
278 283 config.include('pyramid_beaker')
279 284
280 285 config.include('rhodecode.authentication')
281 286 config.include('rhodecode.integrations')
282 287
283 288 # apps
284 289 config.include('rhodecode.apps._base')
285 290 config.include('rhodecode.apps.ops')
286 291
287 292 config.include('rhodecode.apps.admin')
288 293 config.include('rhodecode.apps.channelstream')
289 294 config.include('rhodecode.apps.login')
290 295 config.include('rhodecode.apps.home')
291 296 config.include('rhodecode.apps.journal')
292 297 config.include('rhodecode.apps.repository')
293 298 config.include('rhodecode.apps.repo_group')
294 299 config.include('rhodecode.apps.search')
295 300 config.include('rhodecode.apps.user_profile')
296 301 config.include('rhodecode.apps.my_account')
297 302 config.include('rhodecode.apps.svn_support')
298 303 config.include('rhodecode.apps.gist')
299 304
300 305 config.include('rhodecode.apps.debug_style')
301 306 config.include('rhodecode.tweens')
302 307 config.include('rhodecode.api')
303 308
304 309 config.add_route(
305 310 'rhodecode_support', 'https://rhodecode.com/help/', static=True)
306 311
307 312 config.add_translation_dirs('rhodecode:i18n/')
308 313 settings['default_locale_name'] = settings.get('lang', 'en')
309 314
310 315 # Add subscribers.
311 316 config.add_subscriber(scan_repositories_if_enabled, ApplicationCreated)
312 317 config.add_subscriber(write_metadata_if_needed, ApplicationCreated)
313 318 config.add_subscriber(write_js_routes_if_enabled, ApplicationCreated)
314 319
315 320 config.add_request_method(
316 321 'rhodecode.lib.partial_renderer.get_partial_renderer',
317 322 'get_partial_renderer')
318 323
319 324 # events
320 325 # TODO(marcink): this should be done when pyramid migration is finished
321 326 # config.add_subscriber(
322 327 # 'rhodecode.integrations.integrations_event_handler',
323 328 # 'rhodecode.events.RhodecodeEvent')
324 329
325 330 # Set the authorization policy.
326 331 authz_policy = ACLAuthorizationPolicy()
327 332 config.set_authorization_policy(authz_policy)
328 333
329 334 # Set the default renderer for HTML templates to mako.
330 335 config.add_mako_renderer('.html')
331 336
332 337 config.add_renderer(
333 338 name='json_ext',
334 339 factory='rhodecode.lib.ext_json_renderer.pyramid_ext_json')
335 340
336 341 # include RhodeCode plugins
337 342 includes = aslist(settings.get('rhodecode.includes', []))
338 343 for inc in includes:
339 344 config.include(inc)
340 345
341 346 # This is the glue which allows us to migrate in chunks. By registering the
342 347 # pylons based application as the "Not Found" view in Pyramid, we will
343 348 # fallback to the old application each time the new one does not yet know
344 349 # how to handle a request.
345 350 config.add_notfound_view(make_not_found_view(config))
346 351
347 352 if not settings.get('debugtoolbar.enabled', False):
348 353 # disabled debugtoolbar handle all exceptions via the error_handlers
349 354 config.add_view(error_handler, context=Exception)
350 355
351 356 config.add_view(error_handler, context=HTTPError)
352 357
353 358
354 359 def includeme_first(config):
355 360 # redirect automatic browser favicon.ico requests to correct place
356 361 def favicon_redirect(context, request):
357 362 return HTTPFound(
358 363 request.static_path('rhodecode:public/images/favicon.ico'))
359 364
360 365 config.add_view(favicon_redirect, route_name='favicon')
361 366 config.add_route('favicon', '/favicon.ico')
362 367
363 368 def robots_redirect(context, request):
364 369 return HTTPFound(
365 370 request.static_path('rhodecode:public/robots.txt'))
366 371
367 372 config.add_view(robots_redirect, route_name='robots')
368 373 config.add_route('robots', '/robots.txt')
369 374
370 375 config.add_static_view(
371 376 '_static/deform', 'deform:static')
372 377 config.add_static_view(
373 378 '_static/rhodecode', path='rhodecode:public', cache_max_age=3600 * 24)
374 379
375 380
376 381 def wrap_app_in_wsgi_middlewares(pyramid_app, config):
377 382 """
378 383 Apply outer WSGI middlewares around the application.
379 384
380 385 Part of this has been moved up from the Pylons layer, so that the
381 386 data is also available if old Pylons code is hit through an already ported
382 387 view.
383 388 """
384 389 settings = config.registry.settings
385 390
386 391 # enable https redirects based on HTTP_X_URL_SCHEME set by proxy
387 392 pyramid_app = HttpsFixup(pyramid_app, settings)
388 393
389 394 # Add RoutesMiddleware to support the pylons compatibility tween during
390 395 # migration to pyramid.
391 396
392 397 # TODO(marcink): remove after migration to pyramid
393 398 if hasattr(config.registry, '_pylons_compat_config'):
394 399 routes_map = config.registry._pylons_compat_config['routes.map']
395 400 pyramid_app = SkippableRoutesMiddleware(
396 401 pyramid_app, routes_map,
397 402 skip_prefixes=(STATIC_FILE_PREFIX, '/_debug_toolbar'))
398 403
399 404 pyramid_app, _ = wrap_in_appenlight_if_enabled(pyramid_app, settings)
400 405
401 406 if settings['gzip_responses']:
402 407 pyramid_app = make_gzip_middleware(
403 408 pyramid_app, settings, compress_level=1)
404 409
405 410 # this should be the outer most middleware in the wsgi stack since
406 411 # middleware like Routes make database calls
407 412 def pyramid_app_with_cleanup(environ, start_response):
408 413 try:
409 414 return pyramid_app(environ, start_response)
410 415 finally:
411 416 # Dispose current database session and rollback uncommitted
412 417 # transactions.
413 418 meta.Session.remove()
414 419
415 420 # In a single threaded mode server, on non sqlite db we should have
416 421 # '0 Current Checked out connections' at the end of a request,
417 422 # if not, then something, somewhere is leaving a connection open
418 423 pool = meta.Base.metadata.bind.engine.pool
419 424 log.debug('sa pool status: %s', pool.status())
420 425
421 426 return pyramid_app_with_cleanup
422 427
423 428
424 429 def sanitize_settings_and_apply_defaults(settings):
425 430 """
426 431 Applies settings defaults and does all type conversion.
427 432
428 433 We would move all settings parsing and preparation into this place, so that
429 434 we have only one place left which deals with this part. The remaining parts
430 435 of the application would start to rely fully on well prepared settings.
431 436
432 437 This piece would later be split up per topic to avoid a big fat monster
433 438 function.
434 439 """
435 440
436 441 # Pyramid's mako renderer has to search in the templates folder so that the
437 442 # old templates still work. Ported and new templates are expected to use
438 443 # real asset specifications for the includes.
439 444 mako_directories = settings.setdefault('mako.directories', [
440 445 # Base templates of the original Pylons application
441 446 'rhodecode:templates',
442 447 ])
443 448 log.debug(
444 449 "Using the following Mako template directories: %s",
445 450 mako_directories)
446 451
447 452 # Default includes, possible to change as a user
448 453 pyramid_includes = settings.setdefault('pyramid.includes', [
449 454 'rhodecode.lib.middleware.request_wrapper',
450 455 ])
451 456 log.debug(
452 457 "Using the following pyramid.includes: %s",
453 458 pyramid_includes)
454 459
455 460 # TODO: johbo: Re-think this, usually the call to config.include
456 461 # should allow to pass in a prefix.
457 462 settings.setdefault('rhodecode.api.url', '/_admin/api')
458 463
459 464 # Sanitize generic settings.
460 465 _list_setting(settings, 'default_encoding', 'UTF-8')
461 466 _bool_setting(settings, 'is_test', 'false')
462 467 _bool_setting(settings, 'gzip_responses', 'false')
463 468
464 469 # Call split out functions that sanitize settings for each topic.
465 470 _sanitize_appenlight_settings(settings)
466 471 _sanitize_vcs_settings(settings)
467 472
468 473 return settings
469 474
470 475
471 476 def _sanitize_appenlight_settings(settings):
472 477 _bool_setting(settings, 'appenlight', 'false')
473 478
474 479
475 480 def _sanitize_vcs_settings(settings):
476 481 """
477 482 Applies settings defaults and does type conversion for all VCS related
478 483 settings.
479 484 """
480 485 _string_setting(settings, 'vcs.svn.compatible_version', '')
481 486 _string_setting(settings, 'git_rev_filter', '--all')
482 487 _string_setting(settings, 'vcs.hooks.protocol', 'http')
483 488 _string_setting(settings, 'vcs.scm_app_implementation', 'http')
484 489 _string_setting(settings, 'vcs.server', '')
485 490 _string_setting(settings, 'vcs.server.log_level', 'debug')
486 491 _string_setting(settings, 'vcs.server.protocol', 'http')
487 492 _bool_setting(settings, 'startup.import_repos', 'false')
488 493 _bool_setting(settings, 'vcs.hooks.direct_calls', 'false')
489 494 _bool_setting(settings, 'vcs.server.enable', 'true')
490 495 _bool_setting(settings, 'vcs.start_server', 'false')
491 496 _list_setting(settings, 'vcs.backends', 'hg, git, svn')
492 497 _int_setting(settings, 'vcs.connection_timeout', 3600)
493 498
494 499 # Support legacy values of vcs.scm_app_implementation. Legacy
495 500 # configurations may use 'rhodecode.lib.middleware.utils.scm_app_http'
496 501 # which is now mapped to 'http'.
497 502 scm_app_impl = settings['vcs.scm_app_implementation']
498 503 if scm_app_impl == 'rhodecode.lib.middleware.utils.scm_app_http':
499 504 settings['vcs.scm_app_implementation'] = 'http'
500 505
501 506
502 507 def _int_setting(settings, name, default):
503 508 settings[name] = int(settings.get(name, default))
504 509
505 510
506 511 def _bool_setting(settings, name, default):
507 512 input = settings.get(name, default)
508 513 if isinstance(input, unicode):
509 514 input = input.encode('utf8')
510 515 settings[name] = asbool(input)
511 516
512 517
513 518 def _list_setting(settings, name, default):
514 519 raw_value = settings.get(name, default)
515 520
516 521 old_separator = ','
517 522 if old_separator in raw_value:
518 523 # If we get a comma separated list, pass it to our own function.
519 524 settings[name] = rhodecode_aslist(raw_value, sep=old_separator)
520 525 else:
521 526 # Otherwise we assume it uses pyramids space/newline separation.
522 527 settings[name] = aslist(raw_value)
523 528
524 529
525 530 def _string_setting(settings, name, default, lower=True):
526 531 value = settings.get(name, default)
527 532 if lower:
528 533 value = value.lower()
529 534 settings[name] = value
@@ -1,660 +1,672 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 import markupsafe
30 31 import ipaddress
31 32 import pyramid.threadlocal
32 33
33 34 from paste.auth.basic import AuthBasicAuthenticator
34 35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
35 36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
36 37 from pylons import config, tmpl_context as c, request, url
37 38 from pylons.controllers import WSGIController
38 39 from pylons.controllers.util import redirect
39 40 from pylons.i18n import translation
40 41 # marcink: don't remove this import
41 42 from pylons.templating import render_mako, pylons_globals, literal, cached_template
42 43 from pylons.i18n.translation import _
43 44 from webob.exc import HTTPFound
44 45
45 46
46 47 import rhodecode
47 48 from rhodecode.authentication.base import VCS_TYPE
48 49 from rhodecode.lib import auth, utils2
49 50 from rhodecode.lib import helpers as h
50 51 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
51 52 from rhodecode.lib.exceptions import UserCreationError
52 53 from rhodecode.lib.utils import (
53 54 get_repo_slug, set_rhodecode_config, password_changed,
54 55 get_enabled_hook_classes)
55 56 from rhodecode.lib.utils2 import (
56 57 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
57 58 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
58 59 from rhodecode.model import meta
59 60 from rhodecode.model.db import Repository, User, ChangesetComment
60 61 from rhodecode.model.notification import NotificationModel
61 62 from rhodecode.model.scm import ScmModel
62 63 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
63 64
64 65
65 66 log = logging.getLogger(__name__)
66 67
67 68
68 69 # hack to make the migration to pyramid easier
69 70 def render(template_name, extra_vars=None, cache_key=None,
70 71 cache_type=None, cache_expire=None):
71 72 """Render a template with Mako
72 73
73 74 Accepts the cache options ``cache_key``, ``cache_type``, and
74 75 ``cache_expire``.
75 76
76 77 """
77 78 # Create a render callable for the cache function
78 79 def render_template():
79 80 # Pull in extra vars if needed
80 81 globs = extra_vars or {}
81 82
82 83 # Second, get the globals
83 84 globs.update(pylons_globals())
84 85
85 86 globs['_ungettext'] = globs['ungettext']
86 87 # Grab a template reference
87 88 template = globs['app_globals'].mako_lookup.get_template(template_name)
88 89
89 90 return literal(template.render_unicode(**globs))
90 91
91 92 return cached_template(template_name, render_template, cache_key=cache_key,
92 93 cache_type=cache_type, cache_expire=cache_expire)
93 94
94 95 def _filter_proxy(ip):
95 96 """
96 97 Passed in IP addresses in HEADERS can be in a special format of multiple
97 98 ips. Those comma separated IPs are passed from various proxies in the
98 99 chain of request processing. The left-most being the original client.
99 100 We only care about the first IP which came from the org. client.
100 101
101 102 :param ip: ip string from headers
102 103 """
103 104 if ',' in ip:
104 105 _ips = ip.split(',')
105 106 _first_ip = _ips[0].strip()
106 107 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
107 108 return _first_ip
108 109 return ip
109 110
110 111
111 112 def _filter_port(ip):
112 113 """
113 114 Removes a port from ip, there are 4 main cases to handle here.
114 115 - ipv4 eg. 127.0.0.1
115 116 - ipv6 eg. ::1
116 117 - ipv4+port eg. 127.0.0.1:8080
117 118 - ipv6+port eg. [::1]:8080
118 119
119 120 :param ip:
120 121 """
121 122 def is_ipv6(ip_addr):
122 123 if hasattr(socket, 'inet_pton'):
123 124 try:
124 125 socket.inet_pton(socket.AF_INET6, ip_addr)
125 126 except socket.error:
126 127 return False
127 128 else:
128 129 # fallback to ipaddress
129 130 try:
130 131 ipaddress.IPv6Address(safe_unicode(ip_addr))
131 132 except Exception:
132 133 return False
133 134 return True
134 135
135 136 if ':' not in ip: # must be ipv4 pure ip
136 137 return ip
137 138
138 139 if '[' in ip and ']' in ip: # ipv6 with port
139 140 return ip.split(']')[0][1:].lower()
140 141
141 142 # must be ipv6 or ipv4 with port
142 143 if is_ipv6(ip):
143 144 return ip
144 145 else:
145 146 ip, _port = ip.split(':')[:2] # means ipv4+port
146 147 return ip
147 148
148 149
149 150 def get_ip_addr(environ):
150 151 proxy_key = 'HTTP_X_REAL_IP'
151 152 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
152 153 def_key = 'REMOTE_ADDR'
153 154 _filters = lambda x: _filter_port(_filter_proxy(x))
154 155
155 156 ip = environ.get(proxy_key)
156 157 if ip:
157 158 return _filters(ip)
158 159
159 160 ip = environ.get(proxy_key2)
160 161 if ip:
161 162 return _filters(ip)
162 163
163 164 ip = environ.get(def_key, '0.0.0.0')
164 165 return _filters(ip)
165 166
166 167
167 168 def get_server_ip_addr(environ, log_errors=True):
168 169 hostname = environ.get('SERVER_NAME')
169 170 try:
170 171 return socket.gethostbyname(hostname)
171 172 except Exception as e:
172 173 if log_errors:
173 174 # in some cases this lookup is not possible, and we don't want to
174 175 # make it an exception in logs
175 176 log.exception('Could not retrieve server ip address: %s', e)
176 177 return hostname
177 178
178 179
179 180 def get_server_port(environ):
180 181 return environ.get('SERVER_PORT')
181 182
182 183
183 184 def get_access_path(environ):
184 185 path = environ.get('PATH_INFO')
185 186 org_req = environ.get('pylons.original_request')
186 187 if org_req:
187 188 path = org_req.environ.get('PATH_INFO')
188 189 return path
189 190
190 191
191 192 def get_user_agent(environ):
192 193 return environ.get('HTTP_USER_AGENT')
193 194
194 195
195 196 def vcs_operation_context(
196 197 environ, repo_name, username, action, scm, check_locking=True,
197 198 is_shadow_repo=False):
198 199 """
199 200 Generate the context for a vcs operation, e.g. push or pull.
200 201
201 202 This context is passed over the layers so that hooks triggered by the
202 203 vcs operation know details like the user, the user's IP address etc.
203 204
204 205 :param check_locking: Allows to switch of the computation of the locking
205 206 data. This serves mainly the need of the simplevcs middleware to be
206 207 able to disable this for certain operations.
207 208
208 209 """
209 210 # Tri-state value: False: unlock, None: nothing, True: lock
210 211 make_lock = None
211 212 locked_by = [None, None, None]
212 213 is_anonymous = username == User.DEFAULT_USER
213 214 if not is_anonymous and check_locking:
214 215 log.debug('Checking locking on repository "%s"', repo_name)
215 216 user = User.get_by_username(username)
216 217 repo = Repository.get_by_repo_name(repo_name)
217 218 make_lock, __, locked_by = repo.get_locking_state(
218 219 action, user.user_id)
219 220
220 221 settings_model = VcsSettingsModel(repo=repo_name)
221 222 ui_settings = settings_model.get_ui_settings()
222 223
223 224 extras = {
224 225 'ip': get_ip_addr(environ),
225 226 'username': username,
226 227 'action': action,
227 228 'repository': repo_name,
228 229 'scm': scm,
229 230 'config': rhodecode.CONFIG['__file__'],
230 231 'make_lock': make_lock,
231 232 'locked_by': locked_by,
232 233 'server_url': utils2.get_server_url(environ),
233 234 'user_agent': get_user_agent(environ),
234 235 'hooks': get_enabled_hook_classes(ui_settings),
235 236 'is_shadow_repo': is_shadow_repo,
236 237 }
237 238 return extras
238 239
239 240
240 241 class BasicAuth(AuthBasicAuthenticator):
241 242
242 243 def __init__(self, realm, authfunc, registry, auth_http_code=None,
243 244 initial_call_detection=False, acl_repo_name=None):
244 245 self.realm = realm
245 246 self.initial_call = initial_call_detection
246 247 self.authfunc = authfunc
247 248 self.registry = registry
248 249 self.acl_repo_name = acl_repo_name
249 250 self._rc_auth_http_code = auth_http_code
250 251
251 252 def _get_response_from_code(self, http_code):
252 253 try:
253 254 return get_exception(safe_int(http_code))
254 255 except Exception:
255 256 log.exception('Failed to fetch response for code %s' % http_code)
256 257 return HTTPForbidden
257 258
258 259 def build_authentication(self):
259 260 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
260 261 if self._rc_auth_http_code and not self.initial_call:
261 262 # return alternative HTTP code if alternative http return code
262 263 # is specified in RhodeCode config, but ONLY if it's not the
263 264 # FIRST call
264 265 custom_response_klass = self._get_response_from_code(
265 266 self._rc_auth_http_code)
266 267 return custom_response_klass(headers=head)
267 268 return HTTPUnauthorized(headers=head)
268 269
269 270 def authenticate(self, environ):
270 271 authorization = AUTHORIZATION(environ)
271 272 if not authorization:
272 273 return self.build_authentication()
273 274 (authmeth, auth) = authorization.split(' ', 1)
274 275 if 'basic' != authmeth.lower():
275 276 return self.build_authentication()
276 277 auth = auth.strip().decode('base64')
277 278 _parts = auth.split(':', 1)
278 279 if len(_parts) == 2:
279 280 username, password = _parts
280 281 if self.authfunc(
281 282 username, password, environ, VCS_TYPE,
282 283 registry=self.registry, acl_repo_name=self.acl_repo_name):
283 284 return username
284 285 if username and password:
285 286 # we mark that we actually executed authentication once, at
286 287 # that point we can use the alternative auth code
287 288 self.initial_call = False
288 289
289 290 return self.build_authentication()
290 291
291 292 __call__ = authenticate
292 293
293 294
294 295 def calculate_version_hash():
295 296 return md5(
296 297 config.get('beaker.session.secret', '') +
297 298 rhodecode.__version__)[:8]
298 299
299 300
300 301 def get_current_lang(request):
301 302 # NOTE(marcink): remove after pyramid move
302 303 try:
303 304 return translation.get_lang()[0]
304 305 except:
305 306 pass
306 307
307 308 return getattr(request, '_LOCALE_', request.locale_name)
308 309
309 310
310 311 def attach_context_attributes(context, request, user_id):
311 312 """
312 313 Attach variables into template context called `c`, please note that
313 314 request could be pylons or pyramid request in here.
314 315 """
315 316
316 317 rc_config = SettingsModel().get_all_settings(cache=True)
317 318
318 319 context.rhodecode_version = rhodecode.__version__
319 320 context.rhodecode_edition = config.get('rhodecode.edition')
320 321 # unique secret + version does not leak the version but keep consistency
321 322 context.rhodecode_version_hash = calculate_version_hash()
322 323
323 324 # Default language set for the incoming request
324 325 context.language = get_current_lang(request)
325 326
326 327 # Visual options
327 328 context.visual = AttributeDict({})
328 329
329 330 # DB stored Visual Items
330 331 context.visual.show_public_icon = str2bool(
331 332 rc_config.get('rhodecode_show_public_icon'))
332 333 context.visual.show_private_icon = str2bool(
333 334 rc_config.get('rhodecode_show_private_icon'))
334 335 context.visual.stylify_metatags = str2bool(
335 336 rc_config.get('rhodecode_stylify_metatags'))
336 337 context.visual.dashboard_items = safe_int(
337 338 rc_config.get('rhodecode_dashboard_items', 100))
338 339 context.visual.admin_grid_items = safe_int(
339 340 rc_config.get('rhodecode_admin_grid_items', 100))
340 341 context.visual.repository_fields = str2bool(
341 342 rc_config.get('rhodecode_repository_fields'))
342 343 context.visual.show_version = str2bool(
343 344 rc_config.get('rhodecode_show_version'))
344 345 context.visual.use_gravatar = str2bool(
345 346 rc_config.get('rhodecode_use_gravatar'))
346 347 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
347 348 context.visual.default_renderer = rc_config.get(
348 349 'rhodecode_markup_renderer', 'rst')
349 350 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
350 351 context.visual.rhodecode_support_url = \
351 352 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
352 353
353 354 context.visual.affected_files_cut_off = 60
354 355
355 356 context.pre_code = rc_config.get('rhodecode_pre_code')
356 357 context.post_code = rc_config.get('rhodecode_post_code')
357 358 context.rhodecode_name = rc_config.get('rhodecode_title')
358 359 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
359 360 # if we have specified default_encoding in the request, it has more
360 361 # priority
361 362 if request.GET.get('default_encoding'):
362 363 context.default_encodings.insert(0, request.GET.get('default_encoding'))
363 364 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
364 365
365 366 # INI stored
366 367 context.labs_active = str2bool(
367 368 config.get('labs_settings_active', 'false'))
368 369 context.visual.allow_repo_location_change = str2bool(
369 370 config.get('allow_repo_location_change', True))
370 371 context.visual.allow_custom_hooks_settings = str2bool(
371 372 config.get('allow_custom_hooks_settings', True))
372 373 context.debug_style = str2bool(config.get('debug_style', False))
373 374
374 375 context.rhodecode_instanceid = config.get('instance_id')
375 376
376 377 context.visual.cut_off_limit_diff = safe_int(
377 378 config.get('cut_off_limit_diff'))
378 379 context.visual.cut_off_limit_file = safe_int(
379 380 config.get('cut_off_limit_file'))
380 381
381 382 # AppEnlight
382 383 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
383 384 context.appenlight_api_public_key = config.get(
384 385 'appenlight.api_public_key', '')
385 386 context.appenlight_server_url = config.get('appenlight.server_url', '')
386 387
387 388 # JS template context
388 389 context.template_context = {
389 390 'repo_name': None,
390 391 'repo_type': None,
391 392 'repo_landing_commit': None,
392 393 'rhodecode_user': {
393 394 'username': None,
394 395 'email': None,
395 396 'notification_status': False
396 397 },
397 398 'visual': {
398 399 'default_renderer': None
399 400 },
400 401 'commit_data': {
401 402 'commit_id': None
402 403 },
403 404 'pull_request_data': {'pull_request_id': None},
404 405 'timeago': {
405 406 'refresh_time': 120 * 1000,
406 407 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
407 408 },
408 409 'pylons_dispatch': {
409 410 # 'controller': request.environ['pylons.routes_dict']['controller'],
410 411 # 'action': request.environ['pylons.routes_dict']['action'],
411 412 },
412 413 'pyramid_dispatch': {
413 414
414 415 },
415 416 'extra': {'plugins': {}}
416 417 }
417 418 # END CONFIG VARS
418 419
419 420 # TODO: This dosn't work when called from pylons compatibility tween.
420 421 # Fix this and remove it from base controller.
421 422 # context.repo_name = get_repo_slug(request) # can be empty
422 423
423 424 diffmode = 'sideside'
424 425 if request.GET.get('diffmode'):
425 426 if request.GET['diffmode'] == 'unified':
426 427 diffmode = 'unified'
427 428 elif request.session.get('diffmode'):
428 429 diffmode = request.session['diffmode']
429 430
430 431 context.diffmode = diffmode
431 432
432 433 if request.session.get('diffmode') != diffmode:
433 434 request.session['diffmode'] = diffmode
434 435
435 436 context.csrf_token = auth.get_csrf_token(session=request.session)
436 437 context.backends = rhodecode.BACKENDS.keys()
437 438 context.backends.sort()
438 439 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
439 440
440 441 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
441 442 # given request will ALWAYS be pyramid one
442 443 pyramid_request = pyramid.threadlocal.get_current_request()
443 444 context.pyramid_request = pyramid_request
444 445
445 446 # web case
446 447 if hasattr(pyramid_request, 'user'):
447 448 context.auth_user = pyramid_request.user
448 449 context.rhodecode_user = pyramid_request.user
449 450
450 451 # api case
451 452 if hasattr(pyramid_request, 'rpc_user'):
452 453 context.auth_user = pyramid_request.rpc_user
453 454 context.rhodecode_user = pyramid_request.rpc_user
454 455
455 456 # attach the whole call context to the request
456 457 request.call_context = context
457 458
458 459
459 460 def get_auth_user(request):
460 461 environ = request.environ
461 462 session = request.session
462 463
463 464 ip_addr = get_ip_addr(environ)
464 465 # make sure that we update permissions each time we call controller
465 466 _auth_token = (request.GET.get('auth_token', '') or
466 467 request.GET.get('api_key', ''))
467 468
468 469 if _auth_token:
469 470 # when using API_KEY we assume user exists, and
470 471 # doesn't need auth based on cookies.
471 472 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
472 473 authenticated = False
473 474 else:
474 475 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
475 476 try:
476 477 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
477 478 ip_addr=ip_addr)
478 479 except UserCreationError as e:
479 480 h.flash(e, 'error')
480 481 # container auth or other auth functions that create users
481 482 # on the fly can throw this exception signaling that there's
482 483 # issue with user creation, explanation should be provided
483 484 # in Exception itself. We then create a simple blank
484 485 # AuthUser
485 486 auth_user = AuthUser(ip_addr=ip_addr)
486 487
487 488 if password_changed(auth_user, session):
488 489 session.invalidate()
489 490 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
490 491 auth_user = AuthUser(ip_addr=ip_addr)
491 492
492 493 authenticated = cookie_store.get('is_authenticated')
493 494
494 495 if not auth_user.is_authenticated and auth_user.is_user_object:
495 496 # user is not authenticated and not empty
496 497 auth_user.set_authenticated(authenticated)
497 498
498 499 return auth_user
499 500
500 501
501 502 class BaseController(WSGIController):
502 503
503 504 def __before__(self):
504 505 """
505 506 __before__ is called before controller methods and after __call__
506 507 """
507 508 # on each call propagate settings calls into global settings.
508 509 set_rhodecode_config(config)
509 510 attach_context_attributes(c, request, self._rhodecode_user.user_id)
510 511
511 512 # TODO: Remove this when fixed in attach_context_attributes()
512 513 c.repo_name = get_repo_slug(request) # can be empty
513 514
514 515 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
515 516 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
516 517 self.sa = meta.Session
517 518 self.scm_model = ScmModel(self.sa)
518 519
519 520 # set user language
520 521 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
521 522 if user_lang:
522 523 translation.set_lang(user_lang)
523 524 log.debug('set language to %s for user %s',
524 525 user_lang, self._rhodecode_user)
525 526
526 527 def _dispatch_redirect(self, with_url, environ, start_response):
527 528 resp = HTTPFound(with_url)
528 529 environ['SCRIPT_NAME'] = '' # handle prefix middleware
529 530 environ['PATH_INFO'] = with_url
530 531 return resp(environ, start_response)
531 532
532 533 def __call__(self, environ, start_response):
533 534 """Invoke the Controller"""
534 535 # WSGIController.__call__ dispatches to the Controller method
535 536 # the request is routed to. This routing information is
536 537 # available in environ['pylons.routes_dict']
537 538 from rhodecode.lib import helpers as h
538 539
539 540 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
540 541 if environ.get('debugtoolbar.wants_pylons_context', False):
541 542 environ['debugtoolbar.pylons_context'] = c._current_obj()
542 543
543 544 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
544 545 environ['pylons.routes_dict']['action']])
545 546
546 547 self.rc_config = SettingsModel().get_all_settings(cache=True)
547 548 self.ip_addr = get_ip_addr(environ)
548 549
549 550 # The rhodecode auth user is looked up and passed through the
550 551 # environ by the pylons compatibility tween in pyramid.
551 552 # So we can just grab it from there.
552 553 auth_user = environ['rc_auth_user']
553 554
554 555 # set globals for auth user
555 556 request.user = auth_user
556 557 self._rhodecode_user = auth_user
557 558
558 559 log.info('IP: %s User: %s accessed %s [%s]' % (
559 560 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
560 561 _route_name)
561 562 )
562 563
563 564 user_obj = auth_user.get_instance()
564 565 if user_obj and user_obj.user_data.get('force_password_change'):
565 566 h.flash('You are required to change your password', 'warning',
566 567 ignore_duplicate=True)
567 568 return self._dispatch_redirect(
568 569 url('my_account_password'), environ, start_response)
569 570
570 571 return WSGIController.__call__(self, environ, start_response)
571 572
572 573
574 def h_filter(s):
575 """
576 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
577 we wrap this with additional functionality that converts None to empty
578 strings
579 """
580 if s is None:
581 return markupsafe.Markup()
582 return markupsafe.escape(s)
583
584
573 585 class BaseRepoController(BaseController):
574 586 """
575 587 Base class for controllers responsible for loading all needed data for
576 588 repository loaded items are
577 589
578 590 c.rhodecode_repo: instance of scm repository
579 591 c.rhodecode_db_repo: instance of db
580 592 c.repository_requirements_missing: shows that repository specific data
581 593 could not be displayed due to the missing requirements
582 594 c.repository_pull_requests: show number of open pull requests
583 595 """
584 596
585 597 def __before__(self):
586 598 super(BaseRepoController, self).__before__()
587 599 if c.repo_name: # extracted from routes
588 600 db_repo = Repository.get_by_repo_name(c.repo_name)
589 601 if not db_repo:
590 602 return
591 603
592 604 log.debug(
593 605 'Found repository in database %s with state `%s`',
594 606 safe_unicode(db_repo), safe_unicode(db_repo.repo_state))
595 607 route = getattr(request.environ.get('routes.route'), 'name', '')
596 608
597 609 # allow to delete repos that are somehow damages in filesystem
598 610 if route in ['delete_repo']:
599 611 return
600 612
601 613 if db_repo.repo_state in [Repository.STATE_PENDING]:
602 614 if route in ['repo_creating_home']:
603 615 return
604 616 check_url = url('repo_creating_home', repo_name=c.repo_name)
605 617 return redirect(check_url)
606 618
607 619 self.rhodecode_db_repo = db_repo
608 620
609 621 missing_requirements = False
610 622 try:
611 623 self.rhodecode_repo = self.rhodecode_db_repo.scm_instance()
612 624 except RepositoryRequirementError as e:
613 625 missing_requirements = True
614 626 self._handle_missing_requirements(e)
615 627
616 628 if self.rhodecode_repo is None and not missing_requirements:
617 629 log.error('%s this repository is present in database but it '
618 630 'cannot be created as an scm instance', c.repo_name)
619 631
620 632 h.flash(_(
621 633 "The repository at %(repo_name)s cannot be located.") %
622 634 {'repo_name': c.repo_name},
623 635 category='error', ignore_duplicate=True)
624 636 redirect(h.route_path('home'))
625 637
626 638 # update last change according to VCS data
627 639 if not missing_requirements:
628 640 commit = db_repo.get_commit(
629 641 pre_load=["author", "date", "message", "parents"])
630 642 db_repo.update_commit_cache(commit)
631 643
632 644 # Prepare context
633 645 c.rhodecode_db_repo = db_repo
634 646 c.rhodecode_repo = self.rhodecode_repo
635 647 c.repository_requirements_missing = missing_requirements
636 648
637 649 self._update_global_counters(self.scm_model, db_repo)
638 650
639 651 def _update_global_counters(self, scm_model, db_repo):
640 652 """
641 653 Base variables that are exposed to every page of repository
642 654 """
643 655 c.repository_pull_requests = scm_model.get_pull_requests(db_repo)
644 656
645 657 def _handle_missing_requirements(self, error):
646 658 self.rhodecode_repo = None
647 659 log.error(
648 660 'Requirements are missing for repository %s: %s',
649 661 c.repo_name, error.message)
650 662
651 663 summary_url = h.route_path('repo_summary', repo_name=c.repo_name)
652 664 statistics_url = url('edit_repo_statistics', repo_name=c.repo_name)
653 665 settings_update_url = url('repo', repo_name=c.repo_name)
654 666 path = request.path
655 667 should_redirect = (
656 668 path not in (summary_url, settings_update_url)
657 669 and '/settings' not in path or path == statistics_url
658 670 )
659 671 if should_redirect:
660 672 redirect(summary_url)
General Comments 0
You need to be logged in to leave comments. Login now