##// END OF EJS Templates
fixes fixes fixes ! optimized queries on journal...
marcink -
r1040:8e49b6ce beta
parent child Browse files
Show More
@@ -1,53 +1,60
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 rhodecode.controllers.admin.admin
4 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 5
6 6 Controller for Admin panel of Rhodecode
7 7
8 8 :created_on: Apr 7, 2010
9 9 :author: marcink
10 10 :copyright: (C) 2009-2011 Marcin Kuzminski <marcin@python-works.com>
11 11 :license: GPLv3, see COPYING for more details.
12 12 """
13 13 # This program is free software; you can redistribute it and/or
14 14 # modify it under the terms of the GNU General Public License
15 15 # as published by the Free Software Foundation; version 2
16 16 # of the License or (at your opinion) any later version of the license.
17 17 #
18 18 # This program is distributed in the hope that it will be useful,
19 19 # but WITHOUT ANY WARRANTY; without even the implied warranty of
20 20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 21 # GNU General Public License for more details.
22 22 #
23 23 # You should have received a copy of the GNU General Public License
24 24 # along with this program; if not, write to the Free Software
25 25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
26 26 # MA 02110-1301, USA.
27 27
28 28 import logging
29
29 30 from pylons import request, tmpl_context as c
31 from sqlalchemy.orm import joinedload
32 from webhelpers.paginate import Page
33
34 from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator
30 35 from rhodecode.lib.base import BaseController, render
31 36 from rhodecode.model.db import UserLog
32 from webhelpers.paginate import Page
33 from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator
34 37
35 38 log = logging.getLogger(__name__)
36 39
37 40 class AdminController(BaseController):
38 41
39 42 @LoginRequired()
40 43 def __before__(self):
41 44 super(AdminController, self).__before__()
42 45
43 46 @HasPermissionAllDecorator('hg.admin')
44 47 def index(self):
45 48
46 users_log = self.sa.query(UserLog).order_by(UserLog.action_date.desc())
49 users_log = self.sa.query(UserLog)\
50 .options(joinedload(UserLog.user))\
51 .options(joinedload(UserLog.repository))\
52 .order_by(UserLog.action_date.desc())
53
47 54 p = int(request.params.get('page', 1))
48 55 c.users_log = Page(users_log, page=p, items_per_page=10)
49 56 c.log_data = render('admin/admin_log.html')
50 57 if request.params.get('partial'):
51 58 return c.log_data
52 59 return render('admin/admin.html')
53 60
@@ -1,609 +1,609
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 rhodecode.lib.auth
4 4 ~~~~~~~~~~~~~~~~~~
5 5
6 6 authentication and permission libraries
7 7
8 8 :created_on: Apr 4, 2010
9 9 :copyright: (c) 2010 by marcink.
10 10 :license: LICENSE_NAME, see LICENSE_FILE for more details.
11 11 """
12 12 # This program is free software; you can redistribute it and/or
13 13 # modify it under the terms of the GNU General Public License
14 14 # as published by the Free Software Foundation; version 2
15 15 # of the License or (at your opinion) any later version of the license.
16 16 #
17 17 # This program is distributed in the hope that it will be useful,
18 18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 20 # GNU General Public License for more details.
21 21 #
22 22 # You should have received a copy of the GNU General Public License
23 23 # along with this program; if not, write to the Free Software
24 24 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
25 25 # MA 02110-1301, USA.
26 26
27 27 import bcrypt
28 28 import random
29 29 import logging
30 30 import traceback
31 31
32 32 from decorator import decorator
33 33
34 34 from pylons import config, session, url, request
35 35 from pylons.controllers.util import abort, redirect
36 36
37 37 from rhodecode.lib.exceptions import LdapPasswordError, LdapUsernameError
38 38 from rhodecode.lib.utils import get_repo_slug
39 39 from rhodecode.lib.auth_ldap import AuthLdap
40 40
41 41 from rhodecode.model import meta
42 42 from rhodecode.model.user import UserModel
43 43 from rhodecode.model.db import User, RepoToPerm, Repository, Permission, \
44 44 UserToPerm, UsersGroupToPerm, UsersGroupMember
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49
50 50 PERM_WEIGHTS = {'repository.none':0,
51 51 'repository.read':1,
52 52 'repository.write':3,
53 53 'repository.admin':3}
54 54
55 55
56 56 class PasswordGenerator(object):
57 57 """This is a simple class for generating password from
58 58 different sets of characters
59 59 usage:
60 60 passwd_gen = PasswordGenerator()
61 61 #print 8-letter password containing only big and small letters of alphabet
62 62 print passwd_gen.gen_password(8, passwd_gen.ALPHABETS_BIG_SMALL)
63 63 """
64 64 ALPHABETS_NUM = r'''1234567890'''#[0]
65 65 ALPHABETS_SMALL = r'''qwertyuiopasdfghjklzxcvbnm'''#[1]
66 66 ALPHABETS_BIG = r'''QWERTYUIOPASDFGHJKLZXCVBNM'''#[2]
67 67 ALPHABETS_SPECIAL = r'''`-=[]\;',./~!@#$%^&*()_+{}|:"<>?''' #[3]
68 68 ALPHABETS_FULL = ALPHABETS_BIG + ALPHABETS_SMALL + ALPHABETS_NUM + ALPHABETS_SPECIAL#[4]
69 69 ALPHABETS_ALPHANUM = ALPHABETS_BIG + ALPHABETS_SMALL + ALPHABETS_NUM#[5]
70 70 ALPHABETS_BIG_SMALL = ALPHABETS_BIG + ALPHABETS_SMALL
71 71 ALPHABETS_ALPHANUM_BIG = ALPHABETS_BIG + ALPHABETS_NUM#[6]
72 72 ALPHABETS_ALPHANUM_SMALL = ALPHABETS_SMALL + ALPHABETS_NUM#[7]
73 73
74 74 def __init__(self, passwd=''):
75 75 self.passwd = passwd
76 76
77 77 def gen_password(self, len, type):
78 78 self.passwd = ''.join([random.choice(type) for _ in xrange(len)])
79 79 return self.passwd
80 80
81 81
82 82 def get_crypt_password(password):
83 83 """Cryptographic function used for password hashing based on pybcrypt
84 84
85 85 :param password: password to hash
86 86 """
87 87 return bcrypt.hashpw(password, bcrypt.gensalt(10))
88 88
89 89 def check_password(password, hashed):
90 90 return bcrypt.hashpw(password, hashed) == hashed
91 91
92 92 def authfunc(environ, username, password):
93 93 """Dummy authentication function used in Mercurial/Git/ and access control,
94 94
95 95 :param environ: needed only for using in Basic auth
96 96 """
97 97 return authenticate(username, password)
98 98
99 99
100 100 def authenticate(username, password):
101 101 """Authentication function used for access control,
102 102 firstly checks for db authentication then if ldap is enabled for ldap
103 103 authentication, also creates ldap user if not in database
104 104
105 105 :param username: username
106 106 :param password: password
107 107 """
108 108 user_model = UserModel()
109 109 user = user_model.get_by_username(username, cache=False)
110 110
111 111 log.debug('Authenticating user using RhodeCode account')
112 112 if user is not None and not user.ldap_dn:
113 113 if user.active:
114 114
115 115 if user.username == 'default' and user.active:
116 116 log.info('user %s authenticated correctly as anonymous user',
117 117 username)
118 118 return True
119 119
120 120 elif user.username == username and check_password(password, user.password):
121 121 log.info('user %s authenticated correctly', username)
122 122 return True
123 123 else:
124 124 log.warning('user %s is disabled', username)
125 125
126 126 else:
127 127 log.debug('Regular authentication failed')
128 128 user_obj = user_model.get_by_username(username, cache=False,
129 129 case_insensitive=True)
130 130
131 131 if user_obj is not None and not user_obj.ldap_dn:
132 132 log.debug('this user already exists as non ldap')
133 133 return False
134 134
135 135 from rhodecode.model.settings import SettingsModel
136 136 ldap_settings = SettingsModel().get_ldap_settings()
137 137
138 138 #======================================================================
139 139 # FALLBACK TO LDAP AUTH IF ENABLE
140 140 #======================================================================
141 141 if ldap_settings.get('ldap_active', False):
142 142 log.debug("Authenticating user using ldap")
143 143 kwargs = {
144 144 'server':ldap_settings.get('ldap_host', ''),
145 145 'base_dn':ldap_settings.get('ldap_base_dn', ''),
146 146 'port':ldap_settings.get('ldap_port'),
147 147 'bind_dn':ldap_settings.get('ldap_dn_user'),
148 148 'bind_pass':ldap_settings.get('ldap_dn_pass'),
149 149 'use_ldaps':ldap_settings.get('ldap_ldaps'),
150 150 'tls_reqcert':ldap_settings.get('ldap_tls_reqcert'),
151 151 'ldap_filter':ldap_settings.get('ldap_filter'),
152 152 'search_scope':ldap_settings.get('ldap_search_scope'),
153 153 'attr_login':ldap_settings.get('ldap_attr_login'),
154 154 'ldap_version':3,
155 155 }
156 156 log.debug('Checking for ldap authentication')
157 157 try:
158 158 aldap = AuthLdap(**kwargs)
159 159 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
160 160 log.debug('Got ldap DN response %s', user_dn)
161 161
162 162 user_attrs = {
163 163 'name' : ldap_attrs[ldap_settings.get('ldap_attr_firstname')][0],
164 164 'lastname' : ldap_attrs[ldap_settings.get('ldap_attr_lastname')][0],
165 165 'email' : ldap_attrs[ldap_settings.get('ldap_attr_email')][0],
166 166 }
167 167
168 168 if user_model.create_ldap(username, password, user_dn, user_attrs):
169 169 log.info('created new ldap user %s', username)
170 170
171 171 return True
172 172 except (LdapUsernameError, LdapPasswordError,):
173 173 pass
174 174 except (Exception,):
175 175 log.error(traceback.format_exc())
176 176 pass
177 177 return False
178 178
179 179 class AuthUser(object):
180 180 """A simple object that handles a mercurial username for authentication
181 181 """
182 182
183 183 def __init__(self):
184 184 self.username = 'None'
185 185 self.name = ''
186 186 self.lastname = ''
187 187 self.email = ''
188 188 self.user_id = None
189 189 self.is_authenticated = False
190 190 self.is_admin = False
191 191 self.permissions = {}
192 192
193 193 def __repr__(self):
194 194 return "<AuthUser('id:%s:%s')>" % (self.user_id, self.username)
195 195
196 196 def set_available_permissions(config):
197 197 """This function will propagate pylons globals with all available defined
198 198 permission given in db. We don't want to check each time from db for new
199 199 permissions since adding a new permission also requires application restart
200 200 ie. to decorate new views with the newly created permission
201 201
202 202 :param config: current pylons config instance
203 203
204 204 """
205 205 log.info('getting information about all available permissions')
206 206 try:
207 207 sa = meta.Session()
208 208 all_perms = sa.query(Permission).all()
209 209 except:
210 210 pass
211 211 finally:
212 212 meta.Session.remove()
213 213
214 214 config['available_permissions'] = [x.permission_name for x in all_perms]
215 215
216 216 def fill_perms(user):
217 217 """Fills user permission attribute with permissions taken from database
218 218 works for permissions given for repositories, and for permissions that
219 219 as part of beeing group member
220 220
221 221 :param user: user instance to fill his perms
222 222 """
223 223
224 224 sa = meta.Session()
225 225 user.permissions['repositories'] = {}
226 226 user.permissions['global'] = set()
227 227
228 228 #===========================================================================
229 229 # fetch default permissions
230 230 #===========================================================================
231 231 default_user = UserModel().get_by_username('default', cache=True)
232 232
233 233 default_perms = sa.query(RepoToPerm, Repository, Permission)\
234 234 .join((Repository, RepoToPerm.repository_id == Repository.repo_id))\
235 235 .join((Permission, RepoToPerm.permission_id == Permission.permission_id))\
236 236 .filter(RepoToPerm.user == default_user).all()
237 237
238 238 if user.is_admin:
239 239 #=======================================================================
240 240 # #admin have all default rights set to admin
241 241 #=======================================================================
242 242 user.permissions['global'].add('hg.admin')
243 243
244 244 for perm in default_perms:
245 245 p = 'repository.admin'
246 246 user.permissions['repositories'][perm.RepoToPerm.repository.repo_name] = p
247 247
248 248 else:
249 249 #=======================================================================
250 250 # set default permissions
251 251 #=======================================================================
252 252
253 253 #default global
254 254 default_global_perms = sa.query(UserToPerm)\
255 255 .filter(UserToPerm.user == sa.query(User)\
256 256 .filter(User.username == 'default').one())
257 257
258 258 for perm in default_global_perms:
259 259 user.permissions['global'].add(perm.permission.permission_name)
260 260
261 261 #default for repositories
262 262 for perm in default_perms:
263 263 if perm.Repository.private and not perm.Repository.user_id == user.user_id:
264 264 #disable defaults for private repos,
265 265 p = 'repository.none'
266 266 elif perm.Repository.user_id == user.user_id:
267 267 #set admin if owner
268 268 p = 'repository.admin'
269 269 else:
270 270 p = perm.Permission.permission_name
271 271
272 272 user.permissions['repositories'][perm.RepoToPerm.repository.repo_name] = p
273 273
274 274 #=======================================================================
275 275 # overwrite default with user permissions if any
276 276 #=======================================================================
277 277 user_perms = sa.query(RepoToPerm, Permission, Repository)\
278 278 .join((Repository, RepoToPerm.repository_id == Repository.repo_id))\
279 279 .join((Permission, RepoToPerm.permission_id == Permission.permission_id))\
280 280 .filter(RepoToPerm.user_id == user.user_id).all()
281 281
282 282 for perm in user_perms:
283 283 if perm.Repository.user_id == user.user_id:#set admin if owner
284 284 p = 'repository.admin'
285 285 else:
286 286 p = perm.Permission.permission_name
287 287 user.permissions['repositories'][perm.RepoToPerm.repository.repo_name] = p
288 288
289 289
290 290 #=======================================================================
291 291 # check if user is part of groups for this repository and fill in
292 292 # (or replace with higher) permissions
293 293 #=======================================================================
294 294 user_perms_from_users_groups = sa.query(UsersGroupToPerm, Permission, Repository,)\
295 295 .join((Repository, UsersGroupToPerm.repository_id == Repository.repo_id))\
296 296 .join((Permission, UsersGroupToPerm.permission_id == Permission.permission_id))\
297 297 .join((UsersGroupMember, UsersGroupToPerm.users_group_id == UsersGroupMember.users_group_id))\
298 298 .filter(UsersGroupMember.user_id == user.user_id).all()
299 299
300 300 for perm in user_perms_from_users_groups:
301 301 p = perm.Permission.permission_name
302 302 cur_perm = user.permissions['repositories'][perm.UsersGroupToPerm.repository.repo_name]
303 303 #overwrite permission only if it's greater than permission given from other sources
304 304 if PERM_WEIGHTS[p] > PERM_WEIGHTS[cur_perm]:
305 305 user.permissions['repositories'][perm.UsersGroupToPerm.repository.repo_name] = p
306 306
307 307 meta.Session.remove()
308 308 return user
309 309
310 310 def get_user(session):
311 311 """Gets user from session, and wraps permissions into user
312 312
313 313 :param session:
314 314 """
315 315 user = session.get('rhodecode_user', AuthUser())
316 316 #if the user is not logged in we check for anonymous access
317 317 #if user is logged and it's a default user check if we still have anonymous
318 318 #access enabled
319 319 if user.user_id is None or user.username == 'default':
320 320 anonymous_user = UserModel().get_by_username('default', cache=True)
321 321 if anonymous_user.active is True:
322 322 #then we set this user is logged in
323 323 user.is_authenticated = True
324 324 user.user_id = anonymous_user.user_id
325 325 else:
326 326 user.is_authenticated = False
327 327
328 328 if user.is_authenticated:
329 329 user = UserModel().fill_data(user)
330 330
331 331 user = fill_perms(user)
332 332 session['rhodecode_user'] = user
333 333 session.save()
334 334 return user
335 335
336 336 #===============================================================================
337 337 # CHECK DECORATORS
338 338 #===============================================================================
339 339 class LoginRequired(object):
340 340 """Must be logged in to execute this function else
341 341 redirect to login page"""
342 342
343 343 def __call__(self, func):
344 344 return decorator(self.__wrapper, func)
345 345
346 346 def __wrapper(self, func, *fargs, **fkwargs):
347 347 user = session.get('rhodecode_user', AuthUser())
348 348 log.debug('Checking login required for user:%s', user.username)
349 349 if user.is_authenticated:
350 350 log.debug('user %s is authenticated', user.username)
351 351 return func(*fargs, **fkwargs)
352 352 else:
353 353 log.warn('user %s not authenticated', user.username)
354 354
355 355 p = ''
356 356 if request.environ.get('SCRIPT_NAME') != '/':
357 357 p += request.environ.get('SCRIPT_NAME')
358 358
359 359 p += request.environ.get('PATH_INFO')
360 360 if request.environ.get('QUERY_STRING'):
361 361 p += '?' + request.environ.get('QUERY_STRING')
362 362
363 363 log.debug('redirecting to login page with %s', p)
364 364 return redirect(url('login_home', came_from=p))
365 365
366 366 class NotAnonymous(object):
367 367 """Must be logged in to execute this function else
368 368 redirect to login page"""
369 369
370 370 def __call__(self, func):
371 371 return decorator(self.__wrapper, func)
372 372
373 373 def __wrapper(self, func, *fargs, **fkwargs):
374 374 user = session.get('rhodecode_user', AuthUser())
375 375 log.debug('Checking if user is not anonymous')
376 376
377 377 anonymous = user.username == 'default'
378 378
379 379 if anonymous:
380 380 p = ''
381 381 if request.environ.get('SCRIPT_NAME') != '/':
382 382 p += request.environ.get('SCRIPT_NAME')
383 383
384 384 p += request.environ.get('PATH_INFO')
385 385 if request.environ.get('QUERY_STRING'):
386 386 p += '?' + request.environ.get('QUERY_STRING')
387 387 return redirect(url('login_home', came_from=p))
388 388 else:
389 389 return func(*fargs, **fkwargs)
390 390
391 391 class PermsDecorator(object):
392 392 """Base class for decorators"""
393 393
394 394 def __init__(self, *required_perms):
395 395 available_perms = config['available_permissions']
396 396 for perm in required_perms:
397 397 if perm not in available_perms:
398 398 raise Exception("'%s' permission is not defined" % perm)
399 399 self.required_perms = set(required_perms)
400 400 self.user_perms = None
401 401
402 402 def __call__(self, func):
403 403 return decorator(self.__wrapper, func)
404 404
405 405
406 406 def __wrapper(self, func, *fargs, **fkwargs):
407 407 # _wrapper.__name__ = func.__name__
408 408 # _wrapper.__dict__.update(func.__dict__)
409 409 # _wrapper.__doc__ = func.__doc__
410 410 self.user = session.get('rhodecode_user', AuthUser())
411 411 self.user_perms = self.user.permissions
412 412 log.debug('checking %s permissions %s for %s %s',
413 413 self.__class__.__name__, self.required_perms, func.__name__,
414 414 self.user)
415 415
416 416 if self.check_permissions():
417 417 log.debug('Permission granted for %s %s', func.__name__, self.user)
418 418
419 419 return func(*fargs, **fkwargs)
420 420
421 421 else:
422 422 log.warning('Permission denied for %s %s', func.__name__, self.user)
423 423 #redirect with forbidden ret code
424 424 return abort(403)
425 425
426 426
427 427
428 428 def check_permissions(self):
429 429 """Dummy function for overriding"""
430 430 raise Exception('You have to write this function in child class')
431 431
432 432 class HasPermissionAllDecorator(PermsDecorator):
433 433 """Checks for access permission for all given predicates. All of them
434 434 have to be meet in order to fulfill the request
435 435 """
436 436
437 437 def check_permissions(self):
438 438 if self.required_perms.issubset(self.user_perms.get('global')):
439 439 return True
440 440 return False
441 441
442 442
443 443 class HasPermissionAnyDecorator(PermsDecorator):
444 444 """Checks for access permission for any of given predicates. In order to
445 445 fulfill the request any of predicates must be meet
446 446 """
447 447
448 448 def check_permissions(self):
449 449 if self.required_perms.intersection(self.user_perms.get('global')):
450 450 return True
451 451 return False
452 452
453 453 class HasRepoPermissionAllDecorator(PermsDecorator):
454 454 """Checks for access permission for all given predicates for specific
455 455 repository. All of them have to be meet in order to fulfill the request
456 456 """
457 457
458 458 def check_permissions(self):
459 459 repo_name = get_repo_slug(request)
460 460 try:
461 461 user_perms = set([self.user_perms['repositories'][repo_name]])
462 462 except KeyError:
463 463 return False
464 464 if self.required_perms.issubset(user_perms):
465 465 return True
466 466 return False
467 467
468 468
469 469 class HasRepoPermissionAnyDecorator(PermsDecorator):
470 470 """Checks for access permission for any of given predicates for specific
471 471 repository. In order to fulfill the request any of predicates must be meet
472 472 """
473 473
474 474 def check_permissions(self):
475 475 repo_name = get_repo_slug(request)
476 476
477 477 try:
478 478 user_perms = set([self.user_perms['repositories'][repo_name]])
479 479 except KeyError:
480 480 return False
481 481 if self.required_perms.intersection(user_perms):
482 482 return True
483 483 return False
484 484 #===============================================================================
485 485 # CHECK FUNCTIONS
486 486 #===============================================================================
487 487
488 488 class PermsFunction(object):
489 489 """Base function for other check functions"""
490 490
491 491 def __init__(self, *perms):
492 492 available_perms = config['available_permissions']
493 493
494 494 for perm in perms:
495 495 if perm not in available_perms:
496 496 raise Exception("'%s' permission in not defined" % perm)
497 497 self.required_perms = set(perms)
498 498 self.user_perms = None
499 499 self.granted_for = ''
500 500 self.repo_name = None
501 501
502 502 def __call__(self, check_Location=''):
503 503 user = session.get('rhodecode_user', False)
504 504 if not user:
505 505 return False
506 506 self.user_perms = user.permissions
507 507 self.granted_for = user.username
508 508 log.debug('checking %s %s %s', self.__class__.__name__,
509 509 self.required_perms, user)
510 510
511 511 if self.check_permissions():
512 512 log.debug('Permission granted for %s @ %s %s', self.granted_for,
513 513 check_Location, user)
514 514 return True
515 515
516 516 else:
517 517 log.warning('Permission denied for %s @ %s %s', self.granted_for,
518 518 check_Location, user)
519 519 return False
520 520
521 521 def check_permissions(self):
522 522 """Dummy function for overriding"""
523 523 raise Exception('You have to write this function in child class')
524 524
525 525 class HasPermissionAll(PermsFunction):
526 526 def check_permissions(self):
527 527 if self.required_perms.issubset(self.user_perms.get('global')):
528 528 return True
529 529 return False
530 530
531 531 class HasPermissionAny(PermsFunction):
532 532 def check_permissions(self):
533 533 if self.required_perms.intersection(self.user_perms.get('global')):
534 534 return True
535 535 return False
536 536
537 537 class HasRepoPermissionAll(PermsFunction):
538 538
539 539 def __call__(self, repo_name=None, check_Location=''):
540 540 self.repo_name = repo_name
541 541 return super(HasRepoPermissionAll, self).__call__(check_Location)
542 542
543 543 def check_permissions(self):
544 544 if not self.repo_name:
545 545 self.repo_name = get_repo_slug(request)
546 546
547 547 try:
548 548 self.user_perms = set([self.user_perms['repositories']\
549 549 [self.repo_name]])
550 550 except KeyError:
551 551 return False
552 552 self.granted_for = self.repo_name
553 553 if self.required_perms.issubset(self.user_perms):
554 554 return True
555 555 return False
556 556
557 557 class HasRepoPermissionAny(PermsFunction):
558 558
559 559 def __call__(self, repo_name=None, check_Location=''):
560 560 self.repo_name = repo_name
561 561 return super(HasRepoPermissionAny, self).__call__(check_Location)
562 562
563 563 def check_permissions(self):
564 564 if not self.repo_name:
565 565 self.repo_name = get_repo_slug(request)
566 566
567 567 try:
568 568 self.user_perms = set([self.user_perms['repositories']\
569 569 [self.repo_name]])
570 570 except KeyError:
571 571 return False
572 572 self.granted_for = self.repo_name
573 573 if self.required_perms.intersection(self.user_perms):
574 574 return True
575 575 return False
576 576
577 577 #===============================================================================
578 578 # SPECIAL VERSION TO HANDLE MIDDLEWARE AUTH
579 579 #===============================================================================
580 580
581 581 class HasPermissionAnyMiddleware(object):
582 582 def __init__(self, *perms):
583 583 self.required_perms = set(perms)
584 584
585 585 def __call__(self, user, repo_name):
586 586 usr = AuthUser()
587 587 usr.user_id = user.user_id
588 588 usr.username = user.username
589 589 usr.is_admin = user.admin
590 590
591 591 try:
592 592 self.user_perms = set([fill_perms(usr)\
593 593 .permissions['repositories'][repo_name]])
594 594 except:
595 595 self.user_perms = set()
596 596 self.granted_for = ''
597 597 self.username = user.username
598 598 self.repo_name = repo_name
599 599 return self.check_permissions()
600 600
601 601 def check_permissions(self):
602 602 log.debug('checking mercurial protocol '
603 'permissions for user:%s repository:%s',
603 'permissions %s for user:%s repository:%s', self.user_perms,
604 604 self.username, self.repo_name)
605 605 if self.required_perms.intersection(self.user_perms):
606 606 log.debug('permission granted')
607 607 return True
608 608 log.debug('permission denied')
609 609 return False
@@ -1,591 +1,590
1 1 """Helper functions
2 2
3 3 Consists of functions to typically be used within templates, but also
4 4 available to Controllers. This module is available to both as 'h'.
5 5 """
6 6 import random
7 7 import hashlib
8 8 import StringIO
9 9 from pygments.formatters import HtmlFormatter
10 10 from pygments import highlight as code_highlight
11 11 from pylons import url
12 12 from pylons.i18n.translation import _, ungettext
13 13 from vcs.utils.annotate import annotate_highlight
14 14 from rhodecode.lib.utils import repo_name_slug
15 15
16 16 from webhelpers.html import literal, HTML, escape
17 17 from webhelpers.html.tools import *
18 18 from webhelpers.html.builder import make_tag
19 19 from webhelpers.html.tags import auto_discovery_link, checkbox, css_classes, \
20 20 end_form, file, form, hidden, image, javascript_link, link_to, link_to_if, \
21 21 link_to_unless, ol, required_legend, select, stylesheet_link, submit, text, \
22 22 password, textarea, title, ul, xml_declaration, radio
23 23 from webhelpers.html.tools import auto_link, button_to, highlight, js_obfuscate, \
24 24 mail_to, strip_links, strip_tags, tag_re
25 25 from webhelpers.number import format_byte_size, format_bit_size
26 26 from webhelpers.pylonslib import Flash as _Flash
27 27 from webhelpers.pylonslib.secure_form import secure_form
28 28 from webhelpers.text import chop_at, collapse, convert_accented_entities, \
29 29 convert_misc_entities, lchop, plural, rchop, remove_formatting, \
30 30 replace_whitespace, urlify, truncate, wrap_paragraphs
31 31 from webhelpers.date import time_ago_in_words
32 32
33 33 from webhelpers.html.tags import _set_input_attrs, _set_id_attr, \
34 34 convert_boolean_attrs, NotGiven
35 35
36 36 def _reset(name, value=None, id=NotGiven, type="reset", **attrs):
37 37 """Reset button
38 38 """
39 39 _set_input_attrs(attrs, type, name, value)
40 40 _set_id_attr(attrs, id, name)
41 41 convert_boolean_attrs(attrs, ["disabled"])
42 42 return HTML.input(**attrs)
43 43
44 44 reset = _reset
45 45
46 46
47 47 def get_token():
48 48 """Return the current authentication token, creating one if one doesn't
49 49 already exist.
50 50 """
51 51 token_key = "_authentication_token"
52 52 from pylons import session
53 53 if not token_key in session:
54 54 try:
55 55 token = hashlib.sha1(str(random.getrandbits(128))).hexdigest()
56 56 except AttributeError: # Python < 2.4
57 57 token = hashlib.sha1(str(random.randrange(2 ** 128))).hexdigest()
58 58 session[token_key] = token
59 59 if hasattr(session, 'save'):
60 60 session.save()
61 61 return session[token_key]
62 62
63 63 class _GetError(object):
64 64 """Get error from form_errors, and represent it as span wrapped error
65 65 message
66 66
67 67 :param field_name: field to fetch errors for
68 68 :param form_errors: form errors dict
69 69 """
70 70
71 71 def __call__(self, field_name, form_errors):
72 72 tmpl = """<span class="error_msg">%s</span>"""
73 73 if form_errors and form_errors.has_key(field_name):
74 74 return literal(tmpl % form_errors.get(field_name))
75 75
76 76 get_error = _GetError()
77 77
78 78 class _ToolTip(object):
79 79
80 80 def __call__(self, tooltip_title, trim_at=50):
81 81 """Special function just to wrap our text into nice formatted
82 82 autowrapped text
83 83
84 84 :param tooltip_title:
85 85 """
86 86
87 87 return wrap_paragraphs(escape(tooltip_title), trim_at)\
88 88 .replace('\n', '<br/>')
89 89
90 90 def activate(self):
91 91 """Adds tooltip mechanism to the given Html all tooltips have to have
92 92 set class `tooltip` and set attribute `tooltip_title`.
93 93 Then a tooltip will be generated based on that. All with yui js tooltip
94 94 """
95 95
96 96 js = '''
97 97 YAHOO.util.Event.onDOMReady(function(){
98 98 function toolTipsId(){
99 99 var ids = [];
100 100 var tts = YAHOO.util.Dom.getElementsByClassName('tooltip');
101 101
102 102 for (var i = 0; i < tts.length; i++) {
103 103 //if element doesn't not have and id autogenerate one for tooltip
104 104
105 105 if (!tts[i].id){
106 106 tts[i].id='tt'+i*100;
107 107 }
108 108 ids.push(tts[i].id);
109 109 }
110 110 return ids
111 111 };
112 112 var myToolTips = new YAHOO.widget.Tooltip("tooltip", {
113 113 context: toolTipsId(),
114 114 monitorresize:false,
115 115 xyoffset :[0,0],
116 116 autodismissdelay:300000,
117 117 hidedelay:5,
118 118 showdelay:20,
119 119 });
120 120
121 121 // Set the text for the tooltip just before we display it. Lazy method
122 122 myToolTips.contextTriggerEvent.subscribe(
123 123 function(type, args) {
124 124
125 125 var context = args[0];
126 126
127 127 //positioning of tooltip
128 128 var tt_w = this.element.clientWidth;//tooltip width
129 129 var tt_h = this.element.clientHeight;//tooltip height
130 130
131 131 var context_w = context.offsetWidth;
132 132 var context_h = context.offsetHeight;
133 133
134 134 var pos_x = YAHOO.util.Dom.getX(context);
135 135 var pos_y = YAHOO.util.Dom.getY(context);
136 136
137 137 var display_strategy = 'right';
138 138 var xy_pos = [0,0];
139 139 switch (display_strategy){
140 140
141 141 case 'top':
142 142 var cur_x = (pos_x+context_w/2)-(tt_w/2);
143 143 var cur_y = (pos_y-tt_h-4);
144 144 xy_pos = [cur_x,cur_y];
145 145 break;
146 146 case 'bottom':
147 147 var cur_x = (pos_x+context_w/2)-(tt_w/2);
148 148 var cur_y = pos_y+context_h+4;
149 149 xy_pos = [cur_x,cur_y];
150 150 break;
151 151 case 'left':
152 152 var cur_x = (pos_x-tt_w-4);
153 153 var cur_y = pos_y-((tt_h/2)-context_h/2);
154 154 xy_pos = [cur_x,cur_y];
155 155 break;
156 156 case 'right':
157 157 var cur_x = (pos_x+context_w+4);
158 158 var cur_y = pos_y-((tt_h/2)-context_h/2);
159 159 xy_pos = [cur_x,cur_y];
160 160 break;
161 161 default:
162 162 var cur_x = (pos_x+context_w/2)-(tt_w/2);
163 163 var cur_y = pos_y-tt_h-4;
164 164 xy_pos = [cur_x,cur_y];
165 165 break;
166 166
167 167 }
168 168
169 169 this.cfg.setProperty("xy",xy_pos);
170 170
171 171 });
172 172
173 173 //Mouse out
174 174 myToolTips.contextMouseOutEvent.subscribe(
175 175 function(type, args) {
176 176 var context = args[0];
177 177
178 178 });
179 179 });
180 180 '''
181 181 return literal(js)
182 182
183 183 tooltip = _ToolTip()
184 184
185 185 class _FilesBreadCrumbs(object):
186 186
187 187 def __call__(self, repo_name, rev, paths):
188 188 if isinstance(paths, str):
189 189 paths = paths.decode('utf-8')
190 190 url_l = [link_to(repo_name, url('files_home',
191 191 repo_name=repo_name,
192 192 revision=rev, f_path=''))]
193 193 paths_l = paths.split('/')
194 194 for cnt, p in enumerate(paths_l):
195 195 if p != '':
196 196 url_l.append(link_to(p, url('files_home',
197 197 repo_name=repo_name,
198 198 revision=rev,
199 199 f_path='/'.join(paths_l[:cnt + 1]))))
200 200
201 201 return literal('/'.join(url_l))
202 202
203 203 files_breadcrumbs = _FilesBreadCrumbs()
204 204
205 205 class CodeHtmlFormatter(HtmlFormatter):
206 206 """My code Html Formatter for source codes
207 207 """
208 208
209 209 def wrap(self, source, outfile):
210 210 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
211 211
212 212 def _wrap_code(self, source):
213 213 for cnt, it in enumerate(source):
214 214 i, t = it
215 215 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
216 216 yield i, t
217 217
218 218 def _wrap_tablelinenos(self, inner):
219 219 dummyoutfile = StringIO.StringIO()
220 220 lncount = 0
221 221 for t, line in inner:
222 222 if t:
223 223 lncount += 1
224 224 dummyoutfile.write(line)
225 225
226 226 fl = self.linenostart
227 227 mw = len(str(lncount + fl - 1))
228 228 sp = self.linenospecial
229 229 st = self.linenostep
230 230 la = self.lineanchors
231 231 aln = self.anchorlinenos
232 232 nocls = self.noclasses
233 233 if sp:
234 234 lines = []
235 235
236 236 for i in range(fl, fl + lncount):
237 237 if i % st == 0:
238 238 if i % sp == 0:
239 239 if aln:
240 240 lines.append('<a href="#%s%d" class="special">%*d</a>' %
241 241 (la, i, mw, i))
242 242 else:
243 243 lines.append('<span class="special">%*d</span>' % (mw, i))
244 244 else:
245 245 if aln:
246 246 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
247 247 else:
248 248 lines.append('%*d' % (mw, i))
249 249 else:
250 250 lines.append('')
251 251 ls = '\n'.join(lines)
252 252 else:
253 253 lines = []
254 254 for i in range(fl, fl + lncount):
255 255 if i % st == 0:
256 256 if aln:
257 257 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
258 258 else:
259 259 lines.append('%*d' % (mw, i))
260 260 else:
261 261 lines.append('')
262 262 ls = '\n'.join(lines)
263 263
264 264 # in case you wonder about the seemingly redundant <div> here: since the
265 265 # content in the other cell also is wrapped in a div, some browsers in
266 266 # some configurations seem to mess up the formatting...
267 267 if nocls:
268 268 yield 0, ('<table class="%stable">' % self.cssclass +
269 269 '<tr><td><div class="linenodiv" '
270 270 'style="background-color: #f0f0f0; padding-right: 10px">'
271 271 '<pre style="line-height: 125%">' +
272 272 ls + '</pre></div></td><td class="code">')
273 273 else:
274 274 yield 0, ('<table class="%stable">' % self.cssclass +
275 275 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
276 276 ls + '</pre></div></td><td class="code">')
277 277 yield 0, dummyoutfile.getvalue()
278 278 yield 0, '</td></tr></table>'
279 279
280 280
281 281 def pygmentize(filenode, **kwargs):
282 282 """pygmentize function using pygments
283 283
284 284 :param filenode:
285 285 """
286 286
287 287 return literal(code_highlight(filenode.content,
288 288 filenode.lexer, CodeHtmlFormatter(**kwargs)))
289 289
290 290 def pygmentize_annotation(filenode, **kwargs):
291 291 """pygmentize function for annotation
292 292
293 293 :param filenode:
294 294 """
295 295
296 296 color_dict = {}
297 297 def gen_color(n=10000):
298 298 """generator for getting n of evenly distributed colors using
299 299 hsv color and golden ratio. It always return same order of colors
300 300
301 301 :returns: RGB tuple
302 302 """
303 303 import colorsys
304 304 golden_ratio = 0.618033988749895
305 305 h = 0.22717784590367374
306 306
307 307 for c in xrange(n):
308 308 h += golden_ratio
309 309 h %= 1
310 310 HSV_tuple = [h, 0.95, 0.95]
311 311 RGB_tuple = colorsys.hsv_to_rgb(*HSV_tuple)
312 312 yield map(lambda x:str(int(x * 256)), RGB_tuple)
313 313
314 314 cgenerator = gen_color()
315 315
316 316 def get_color_string(cs):
317 317 if color_dict.has_key(cs):
318 318 col = color_dict[cs]
319 319 else:
320 320 col = color_dict[cs] = cgenerator.next()
321 321 return "color: rgb(%s)! important;" % (', '.join(col))
322 322
323 323 def url_func(changeset):
324 324 tooltip_html = "<div style='font-size:0.8em'><b>Author:</b>" + \
325 325 " %s<br/><b>Date:</b> %s</b><br/><b>Message:</b> %s<br/></div>"
326 326
327 327 tooltip_html = tooltip_html % (changeset.author,
328 328 changeset.date,
329 329 tooltip(changeset.message))
330 330 lnk_format = '%5s:%s' % ('r%s' % changeset.revision,
331 331 short_id(changeset.raw_id))
332 332 uri = link_to(
333 333 lnk_format,
334 334 url('changeset_home', repo_name=changeset.repository.name,
335 335 revision=changeset.raw_id),
336 336 style=get_color_string(changeset.raw_id),
337 337 class_='tooltip',
338 338 title=tooltip_html
339 339 )
340 340
341 341 uri += '\n'
342 342 return uri
343 343 return literal(annotate_highlight(filenode, url_func, **kwargs))
344 344
345 345 def get_changeset_safe(repo, rev):
346 346 from vcs.backends.base import BaseRepository
347 347 from vcs.exceptions import RepositoryError
348 348 if not isinstance(repo, BaseRepository):
349 349 raise Exception('You must pass an Repository '
350 350 'object as first argument got %s', type(repo))
351 351
352 352 try:
353 353 cs = repo.get_changeset(rev)
354 354 except RepositoryError:
355 355 from rhodecode.lib.utils import EmptyChangeset
356 356 cs = EmptyChangeset()
357 357 return cs
358 358
359 359
360 360 def is_following_repo(repo_name, user_id):
361 361 from rhodecode.model.scm import ScmModel
362 362 return ScmModel().is_following_repo(repo_name, user_id)
363 363
364 364 flash = _Flash()
365 365
366 366
367 367 #==============================================================================
368 368 # MERCURIAL FILTERS available via h.
369 369 #==============================================================================
370 370 from mercurial import util
371 371 from mercurial.templatefilters import person as _person
372 372
373 373 def _age(curdate):
374 374 """turns a datetime into an age string."""
375 375
376 376 if not curdate:
377 377 return ''
378 378
379 379 from datetime import timedelta, datetime
380 380
381 381 agescales = [("year", 3600 * 24 * 365),
382 382 ("month", 3600 * 24 * 30),
383 383 ("day", 3600 * 24),
384 384 ("hour", 3600),
385 385 ("minute", 60),
386 386 ("second", 1), ]
387 387
388 388 age = datetime.now() - curdate
389 389 age_seconds = (age.days * agescales[2][1]) + age.seconds
390 390 pos = 1
391 391 for scale in agescales:
392 392 if scale[1] <= age_seconds:
393 393 if pos == 6:pos = 5
394 394 return time_ago_in_words(curdate, agescales[pos][0]) + ' ' + _('ago')
395 395 pos += 1
396 396
397 397 return _('just now')
398 398
399 399 age = lambda x:_age(x)
400 400 capitalize = lambda x: x.capitalize()
401 401 email = util.email
402 402 email_or_none = lambda x: util.email(x) if util.email(x) != x else None
403 403 person = lambda x: _person(x)
404 404 short_id = lambda x: x[:12]
405 405
406 406
407 407 def bool2icon(value):
408 408 """Returns True/False values represented as small html image of true/false
409 409 icons
410 410
411 411 :param value: bool value
412 412 """
413 413
414 414 if value is True:
415 415 return HTML.tag('img', src="/images/icons/accept.png", alt=_('True'))
416 416
417 417 if value is False:
418 418 return HTML.tag('img', src="/images/icons/cancel.png", alt=_('False'))
419 419
420 420 return value
421 421
422 422
423 423 def action_parser(user_log):
424 424 """This helper will map the specified string action into translated
425 425 fancy names with icons and links
426 426
427 427 :param user_log: user log instance
428 428 """
429 429
430 430 action = user_log.action
431 431 action_params = ' '
432 432
433 433 x = action.split(':')
434 434
435 435 if len(x) > 1:
436 436 action, action_params = x
437 437
438 438 def get_cs_links():
439 439 revs_limit = 5 #display this amount always
440 440 revs_top_limit = 50 #show upto this amount of changesets hidden
441 441 revs = action_params.split(',')
442 442 repo_name = user_log.repository.repo_name
443 443 from rhodecode.model.scm import ScmModel
444
445 message = lambda rev: get_changeset_safe(ScmModel().get(repo_name),
446 rev).message
444 repo, dbrepo = ScmModel().get(repo_name, retval='repo')
445 message = lambda rev: get_changeset_safe(repo, rev).message
447 446
448 447 cs_links = " " + ', '.join ([link_to(rev,
449 448 url('changeset_home',
450 449 repo_name=repo_name,
451 450 revision=rev), title=tooltip(message(rev)),
452 451 class_='tooltip') for rev in revs[:revs_limit] ])
453 452
454 453 compare_view = (' <div class="compare_view tooltip" title="%s">'
455 454 '<a href="%s">%s</a> '
456 455 '</div>' % (_('Show all combined changesets %s->%s' \
457 456 % (revs[0], revs[-1])),
458 457 url('changeset_home', repo_name=repo_name,
459 458 revision='%s...%s' % (revs[0], revs[-1])
460 459 ),
461 460 _('compare view'))
462 461 )
463 462
464 463 if len(revs) > revs_limit:
465 464 uniq_id = revs[0]
466 465 html_tmpl = ('<span> %s '
467 466 '<a class="show_more" id="_%s" href="#more">%s</a> '
468 467 '%s</span>')
469 468 cs_links += html_tmpl % (_('and'), uniq_id, _('%s more') \
470 469 % (len(revs) - revs_limit),
471 470 _('revisions'))
472 471
473 472 html_tmpl = '<span id="%s" style="display:none"> %s </span>'
474 473 cs_links += html_tmpl % (uniq_id, ', '.join([link_to(rev,
475 474 url('changeset_home',
476 475 repo_name=repo_name, revision=rev),
477 476 title=message(rev), class_='tooltip')
478 477 for rev in revs[revs_limit:revs_top_limit]]))
479 478 if len(revs) > 1:
480 479 cs_links += compare_view
481 480 return cs_links
482 481
483 482 def get_fork_name():
484 483 from rhodecode.model.scm import ScmModel
485 484 repo_name = action_params
486 485 repo, dbrepo = ScmModel().get(repo_name)
487 486 if repo is None:
488 487 return repo_name
489 488 return link_to(action_params, url('summary_home',
490 489 repo_name=repo.name,),
491 490 title=dbrepo.description)
492 491
493 492 map = {'user_deleted_repo':(_('User [deleted] repository'), None),
494 493 'user_created_repo':(_('User [created] repository'), None),
495 494 'user_forked_repo':(_('User [forked] repository as:'), get_fork_name),
496 495 'user_updated_repo':(_('User [updated] repository'), None),
497 496 'admin_deleted_repo':(_('Admin [delete] repository'), None),
498 497 'admin_created_repo':(_('Admin [created] repository'), None),
499 498 'admin_forked_repo':(_('Admin [forked] repository'), None),
500 499 'admin_updated_repo':(_('Admin [updated] repository'), None),
501 500 'push':(_('[Pushed]'), get_cs_links),
502 501 'pull':(_('[Pulled]'), None),
503 502 'started_following_repo':(_('User [started following] repository'), None),
504 503 'stopped_following_repo':(_('User [stopped following] repository'), None),
505 504 }
506 505
507 506 action_str = map.get(action, action)
508 507 action = action_str[0].replace('[', '<span class="journal_highlight">')\
509 508 .replace(']', '</span>')
510 509 if action_str[1] is not None:
511 510 action = action + " " + action_str[1]()
512 511
513 512 return literal(action)
514 513
515 514 def action_parser_icon(user_log):
516 515 action = user_log.action
517 516 action_params = None
518 517 x = action.split(':')
519 518
520 519 if len(x) > 1:
521 520 action, action_params = x
522 521
523 522 tmpl = """<img src="/images/icons/%s" alt="%s"/>"""
524 523 map = {'user_deleted_repo':'database_delete.png',
525 524 'user_created_repo':'database_add.png',
526 525 'user_forked_repo':'arrow_divide.png',
527 526 'user_updated_repo':'database_edit.png',
528 527 'admin_deleted_repo':'database_delete.png',
529 528 'admin_created_repo':'database_add.png',
530 529 'admin_forked_repo':'arrow_divide.png',
531 530 'admin_updated_repo':'database_edit.png',
532 531 'push':'script_add.png',
533 532 'pull':'down_16.png',
534 533 'started_following_repo':'heart_add.png',
535 534 'stopped_following_repo':'heart_delete.png',
536 535 }
537 536 return literal(tmpl % (map.get(action, action), action))
538 537
539 538
540 539 #==============================================================================
541 540 # PERMS
542 541 #==============================================================================
543 542 from rhodecode.lib.auth import HasPermissionAny, HasPermissionAll, \
544 543 HasRepoPermissionAny, HasRepoPermissionAll
545 544
546 545 #==============================================================================
547 546 # GRAVATAR URL
548 547 #==============================================================================
549 548 import hashlib
550 549 import urllib
551 550 from pylons import request
552 551
553 552 def gravatar_url(email_address, size=30):
554 553 ssl_enabled = 'https' == request.environ.get('wsgi.url_scheme')
555 554 default = 'identicon'
556 555 baseurl_nossl = "http://www.gravatar.com/avatar/"
557 556 baseurl_ssl = "https://secure.gravatar.com/avatar/"
558 557 baseurl = baseurl_ssl if ssl_enabled else baseurl_nossl
559 558
560 559
561 560 # construct the url
562 561 gravatar_url = baseurl + hashlib.md5(email_address.lower()).hexdigest() + "?"
563 562 gravatar_url += urllib.urlencode({'d':default, 's':str(size)})
564 563
565 564 return gravatar_url
566 565
567 566 def safe_unicode(str):
568 567 """safe unicode function. In case of UnicodeDecode error we try to return
569 568 unicode with errors replace, if this failes we return unicode with
570 569 string_escape decoding """
571 570
572 571 try:
573 572 u_str = unicode(str)
574 573 except UnicodeDecodeError:
575 574 try:
576 575 u_str = unicode(str, 'utf-8', 'replace')
577 576 except UnicodeDecodeError:
578 577 #incase we have a decode error just represent as byte string
579 578 u_str = unicode(str(str).encode('string_escape'))
580 579
581 580 return u_str
582 581
583 582 def changed_tooltip(nodes):
584 583 if nodes:
585 584 pref = ': <br/> '
586 585 suf = ''
587 586 if len(nodes) > 30:
588 587 suf = '<br/>' + _(' and %s more') % (len(nodes) - 30)
589 588 return literal(pref + '<br/> '.join([x.path for x in nodes[:30]]) + suf)
590 589 else:
591 590 return ': ' + _('No Files')
General Comments 0
You need to be logged in to leave comments. Login now