##// END OF EJS Templates
issue-trackers: cache the fetched issue tracker patterns in changelog page before loop iteration
marcink -
r2445:a90945a8 default
parent child Browse files
Show More
@@ -1,762 +1,763 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 import logging
23 23 import collections
24 24
25 25 import datetime
26 26 import formencode
27 27 import formencode.htmlfill
28 28
29 29 import rhodecode
30 30 from pyramid.view import view_config
31 31 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
32 32 from pyramid.renderers import render
33 33 from pyramid.response import Response
34 34
35 35 from rhodecode.apps._base import BaseAppView
36 36 from rhodecode.apps.admin.navigation import navigation_list
37 37 from rhodecode.apps.svn_support.config_keys import generate_config
38 38 from rhodecode.lib import helpers as h
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasPermissionAllDecorator, CSRFRequired)
41 41 from rhodecode.lib.celerylib import tasks, run_task
42 42 from rhodecode.lib.utils import repo2db_mapper
43 43 from rhodecode.lib.utils2 import str2bool, safe_unicode, AttributeDict
44 44 from rhodecode.lib.index import searcher_from_config
45 45
46 46 from rhodecode.model.db import RhodeCodeUi, Repository
47 47 from rhodecode.model.forms import (ApplicationSettingsForm,
48 48 ApplicationUiSettingsForm, ApplicationVisualisationForm,
49 49 LabsSettingsForm, IssueTrackerPatternsForm)
50 50 from rhodecode.model.repo_group import RepoGroupModel
51 51
52 52 from rhodecode.model.scm import ScmModel
53 53 from rhodecode.model.notification import EmailNotificationModel
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.settings import (
56 56 IssueTrackerSettingsModel, VcsSettingsModel, SettingNotFound,
57 57 SettingsModel)
58 58
59 59
60 60 log = logging.getLogger(__name__)
61 61
62 62
63 63 class AdminSettingsView(BaseAppView):
64 64
65 65 def load_default_context(self):
66 66 c = self._get_local_tmpl_context()
67 67 c.labs_active = str2bool(
68 68 rhodecode.CONFIG.get('labs_settings_active', 'true'))
69 69 c.navlist = navigation_list(self.request)
70 70
71 71 return c
72 72
73 73 @classmethod
74 74 def _get_ui_settings(cls):
75 75 ret = RhodeCodeUi.query().all()
76 76
77 77 if not ret:
78 78 raise Exception('Could not get application ui settings !')
79 79 settings = {}
80 80 for each in ret:
81 81 k = each.ui_key
82 82 v = each.ui_value
83 83 if k == '/':
84 84 k = 'root_path'
85 85
86 86 if k in ['push_ssl', 'publish', 'enabled']:
87 87 v = str2bool(v)
88 88
89 89 if k.find('.') != -1:
90 90 k = k.replace('.', '_')
91 91
92 92 if each.ui_section in ['hooks', 'extensions']:
93 93 v = each.ui_active
94 94
95 95 settings[each.ui_section + '_' + k] = v
96 96 return settings
97 97
98 98 @classmethod
99 99 def _form_defaults(cls):
100 100 defaults = SettingsModel().get_all_settings()
101 101 defaults.update(cls._get_ui_settings())
102 102
103 103 defaults.update({
104 104 'new_svn_branch': '',
105 105 'new_svn_tag': '',
106 106 })
107 107 return defaults
108 108
109 109 @LoginRequired()
110 110 @HasPermissionAllDecorator('hg.admin')
111 111 @view_config(
112 112 route_name='admin_settings_vcs', request_method='GET',
113 113 renderer='rhodecode:templates/admin/settings/settings.mako')
114 114 def settings_vcs(self):
115 115 c = self.load_default_context()
116 116 c.active = 'vcs'
117 117 model = VcsSettingsModel()
118 118 c.svn_branch_patterns = model.get_global_svn_branch_patterns()
119 119 c.svn_tag_patterns = model.get_global_svn_tag_patterns()
120 120
121 121 settings = self.request.registry.settings
122 122 c.svn_proxy_generate_config = settings[generate_config]
123 123
124 124 defaults = self._form_defaults()
125 125
126 126 model.create_largeobjects_dirs_if_needed(defaults['paths_root_path'])
127 127
128 128 data = render('rhodecode:templates/admin/settings/settings.mako',
129 129 self._get_template_context(c), self.request)
130 130 html = formencode.htmlfill.render(
131 131 data,
132 132 defaults=defaults,
133 133 encoding="UTF-8",
134 134 force_defaults=False
135 135 )
136 136 return Response(html)
137 137
138 138 @LoginRequired()
139 139 @HasPermissionAllDecorator('hg.admin')
140 140 @CSRFRequired()
141 141 @view_config(
142 142 route_name='admin_settings_vcs_update', request_method='POST',
143 143 renderer='rhodecode:templates/admin/settings/settings.mako')
144 144 def settings_vcs_update(self):
145 145 _ = self.request.translate
146 146 c = self.load_default_context()
147 147 c.active = 'vcs'
148 148
149 149 model = VcsSettingsModel()
150 150 c.svn_branch_patterns = model.get_global_svn_branch_patterns()
151 151 c.svn_tag_patterns = model.get_global_svn_tag_patterns()
152 152
153 153 settings = self.request.registry.settings
154 154 c.svn_proxy_generate_config = settings[generate_config]
155 155
156 156 application_form = ApplicationUiSettingsForm(self.request.translate)()
157 157
158 158 try:
159 159 form_result = application_form.to_python(dict(self.request.POST))
160 160 except formencode.Invalid as errors:
161 161 h.flash(
162 162 _("Some form inputs contain invalid data."),
163 163 category='error')
164 164 data = render('rhodecode:templates/admin/settings/settings.mako',
165 165 self._get_template_context(c), self.request)
166 166 html = formencode.htmlfill.render(
167 167 data,
168 168 defaults=errors.value,
169 169 errors=errors.error_dict or {},
170 170 prefix_error=False,
171 171 encoding="UTF-8",
172 172 force_defaults=False
173 173 )
174 174 return Response(html)
175 175
176 176 try:
177 177 if c.visual.allow_repo_location_change:
178 178 model.update_global_path_setting(
179 179 form_result['paths_root_path'])
180 180
181 181 model.update_global_ssl_setting(form_result['web_push_ssl'])
182 182 model.update_global_hook_settings(form_result)
183 183
184 184 model.create_or_update_global_svn_settings(form_result)
185 185 model.create_or_update_global_hg_settings(form_result)
186 186 model.create_or_update_global_git_settings(form_result)
187 187 model.create_or_update_global_pr_settings(form_result)
188 188 except Exception:
189 189 log.exception("Exception while updating settings")
190 190 h.flash(_('Error occurred during updating '
191 191 'application settings'), category='error')
192 192 else:
193 193 Session().commit()
194 194 h.flash(_('Updated VCS settings'), category='success')
195 195 raise HTTPFound(h.route_path('admin_settings_vcs'))
196 196
197 197 data = render('rhodecode:templates/admin/settings/settings.mako',
198 198 self._get_template_context(c), self.request)
199 199 html = formencode.htmlfill.render(
200 200 data,
201 201 defaults=self._form_defaults(),
202 202 encoding="UTF-8",
203 203 force_defaults=False
204 204 )
205 205 return Response(html)
206 206
207 207 @LoginRequired()
208 208 @HasPermissionAllDecorator('hg.admin')
209 209 @CSRFRequired()
210 210 @view_config(
211 211 route_name='admin_settings_vcs_svn_pattern_delete', request_method='POST',
212 212 renderer='json_ext', xhr=True)
213 213 def settings_vcs_delete_svn_pattern(self):
214 214 delete_pattern_id = self.request.POST.get('delete_svn_pattern')
215 215 model = VcsSettingsModel()
216 216 try:
217 217 model.delete_global_svn_pattern(delete_pattern_id)
218 218 except SettingNotFound:
219 219 log.exception(
220 220 'Failed to delete svn_pattern with id %s', delete_pattern_id)
221 221 raise HTTPNotFound()
222 222
223 223 Session().commit()
224 224 return True
225 225
226 226 @LoginRequired()
227 227 @HasPermissionAllDecorator('hg.admin')
228 228 @view_config(
229 229 route_name='admin_settings_mapping', request_method='GET',
230 230 renderer='rhodecode:templates/admin/settings/settings.mako')
231 231 def settings_mapping(self):
232 232 c = self.load_default_context()
233 233 c.active = 'mapping'
234 234
235 235 data = render('rhodecode:templates/admin/settings/settings.mako',
236 236 self._get_template_context(c), self.request)
237 237 html = formencode.htmlfill.render(
238 238 data,
239 239 defaults=self._form_defaults(),
240 240 encoding="UTF-8",
241 241 force_defaults=False
242 242 )
243 243 return Response(html)
244 244
245 245 @LoginRequired()
246 246 @HasPermissionAllDecorator('hg.admin')
247 247 @CSRFRequired()
248 248 @view_config(
249 249 route_name='admin_settings_mapping_update', request_method='POST',
250 250 renderer='rhodecode:templates/admin/settings/settings.mako')
251 251 def settings_mapping_update(self):
252 252 _ = self.request.translate
253 253 c = self.load_default_context()
254 254 c.active = 'mapping'
255 255 rm_obsolete = self.request.POST.get('destroy', False)
256 256 invalidate_cache = self.request.POST.get('invalidate', False)
257 257 log.debug(
258 258 'rescanning repo location with destroy obsolete=%s', rm_obsolete)
259 259
260 260 if invalidate_cache:
261 261 log.debug('invalidating all repositories cache')
262 262 for repo in Repository.get_all():
263 263 ScmModel().mark_for_invalidation(repo.repo_name, delete=True)
264 264
265 265 filesystem_repos = ScmModel().repo_scan()
266 266 added, removed = repo2db_mapper(filesystem_repos, rm_obsolete)
267 267 _repr = lambda l: ', '.join(map(safe_unicode, l)) or '-'
268 268 h.flash(_('Repositories successfully '
269 269 'rescanned added: %s ; removed: %s') %
270 270 (_repr(added), _repr(removed)),
271 271 category='success')
272 272 raise HTTPFound(h.route_path('admin_settings_mapping'))
273 273
274 274 @LoginRequired()
275 275 @HasPermissionAllDecorator('hg.admin')
276 276 @view_config(
277 277 route_name='admin_settings', request_method='GET',
278 278 renderer='rhodecode:templates/admin/settings/settings.mako')
279 279 @view_config(
280 280 route_name='admin_settings_global', request_method='GET',
281 281 renderer='rhodecode:templates/admin/settings/settings.mako')
282 282 def settings_global(self):
283 283 c = self.load_default_context()
284 284 c.active = 'global'
285 285 c.personal_repo_group_default_pattern = RepoGroupModel()\
286 286 .get_personal_group_name_pattern()
287 287
288 288 data = render('rhodecode:templates/admin/settings/settings.mako',
289 289 self._get_template_context(c), self.request)
290 290 html = formencode.htmlfill.render(
291 291 data,
292 292 defaults=self._form_defaults(),
293 293 encoding="UTF-8",
294 294 force_defaults=False
295 295 )
296 296 return Response(html)
297 297
298 298 @LoginRequired()
299 299 @HasPermissionAllDecorator('hg.admin')
300 300 @CSRFRequired()
301 301 @view_config(
302 302 route_name='admin_settings_update', request_method='POST',
303 303 renderer='rhodecode:templates/admin/settings/settings.mako')
304 304 @view_config(
305 305 route_name='admin_settings_global_update', request_method='POST',
306 306 renderer='rhodecode:templates/admin/settings/settings.mako')
307 307 def settings_global_update(self):
308 308 _ = self.request.translate
309 309 c = self.load_default_context()
310 310 c.active = 'global'
311 311 c.personal_repo_group_default_pattern = RepoGroupModel()\
312 312 .get_personal_group_name_pattern()
313 313 application_form = ApplicationSettingsForm(self.request.translate)()
314 314 try:
315 315 form_result = application_form.to_python(dict(self.request.POST))
316 316 except formencode.Invalid as errors:
317 317 data = render('rhodecode:templates/admin/settings/settings.mako',
318 318 self._get_template_context(c), self.request)
319 319 html = formencode.htmlfill.render(
320 320 data,
321 321 defaults=errors.value,
322 322 errors=errors.error_dict or {},
323 323 prefix_error=False,
324 324 encoding="UTF-8",
325 325 force_defaults=False
326 326 )
327 327 return Response(html)
328 328
329 329 settings = [
330 330 ('title', 'rhodecode_title', 'unicode'),
331 331 ('realm', 'rhodecode_realm', 'unicode'),
332 332 ('pre_code', 'rhodecode_pre_code', 'unicode'),
333 333 ('post_code', 'rhodecode_post_code', 'unicode'),
334 334 ('captcha_public_key', 'rhodecode_captcha_public_key', 'unicode'),
335 335 ('captcha_private_key', 'rhodecode_captcha_private_key', 'unicode'),
336 336 ('create_personal_repo_group', 'rhodecode_create_personal_repo_group', 'bool'),
337 337 ('personal_repo_group_pattern', 'rhodecode_personal_repo_group_pattern', 'unicode'),
338 338 ]
339 339 try:
340 340 for setting, form_key, type_ in settings:
341 341 sett = SettingsModel().create_or_update_setting(
342 342 setting, form_result[form_key], type_)
343 343 Session().add(sett)
344 344
345 345 Session().commit()
346 346 SettingsModel().invalidate_settings_cache()
347 347 h.flash(_('Updated application settings'), category='success')
348 348 except Exception:
349 349 log.exception("Exception while updating application settings")
350 350 h.flash(
351 351 _('Error occurred during updating application settings'),
352 352 category='error')
353 353
354 354 raise HTTPFound(h.route_path('admin_settings_global'))
355 355
356 356 @LoginRequired()
357 357 @HasPermissionAllDecorator('hg.admin')
358 358 @view_config(
359 359 route_name='admin_settings_visual', request_method='GET',
360 360 renderer='rhodecode:templates/admin/settings/settings.mako')
361 361 def settings_visual(self):
362 362 c = self.load_default_context()
363 363 c.active = 'visual'
364 364
365 365 data = render('rhodecode:templates/admin/settings/settings.mako',
366 366 self._get_template_context(c), self.request)
367 367 html = formencode.htmlfill.render(
368 368 data,
369 369 defaults=self._form_defaults(),
370 370 encoding="UTF-8",
371 371 force_defaults=False
372 372 )
373 373 return Response(html)
374 374
375 375 @LoginRequired()
376 376 @HasPermissionAllDecorator('hg.admin')
377 377 @CSRFRequired()
378 378 @view_config(
379 379 route_name='admin_settings_visual_update', request_method='POST',
380 380 renderer='rhodecode:templates/admin/settings/settings.mako')
381 381 def settings_visual_update(self):
382 382 _ = self.request.translate
383 383 c = self.load_default_context()
384 384 c.active = 'visual'
385 385 application_form = ApplicationVisualisationForm(self.request.translate)()
386 386 try:
387 387 form_result = application_form.to_python(dict(self.request.POST))
388 388 except formencode.Invalid as errors:
389 389 data = render('rhodecode:templates/admin/settings/settings.mako',
390 390 self._get_template_context(c), self.request)
391 391 html = formencode.htmlfill.render(
392 392 data,
393 393 defaults=errors.value,
394 394 errors=errors.error_dict or {},
395 395 prefix_error=False,
396 396 encoding="UTF-8",
397 397 force_defaults=False
398 398 )
399 399 return Response(html)
400 400
401 401 try:
402 402 settings = [
403 403 ('show_public_icon', 'rhodecode_show_public_icon', 'bool'),
404 404 ('show_private_icon', 'rhodecode_show_private_icon', 'bool'),
405 405 ('stylify_metatags', 'rhodecode_stylify_metatags', 'bool'),
406 406 ('repository_fields', 'rhodecode_repository_fields', 'bool'),
407 407 ('dashboard_items', 'rhodecode_dashboard_items', 'int'),
408 408 ('admin_grid_items', 'rhodecode_admin_grid_items', 'int'),
409 409 ('show_version', 'rhodecode_show_version', 'bool'),
410 410 ('use_gravatar', 'rhodecode_use_gravatar', 'bool'),
411 411 ('markup_renderer', 'rhodecode_markup_renderer', 'unicode'),
412 412 ('gravatar_url', 'rhodecode_gravatar_url', 'unicode'),
413 413 ('clone_uri_tmpl', 'rhodecode_clone_uri_tmpl', 'unicode'),
414 414 ('support_url', 'rhodecode_support_url', 'unicode'),
415 415 ('show_revision_number', 'rhodecode_show_revision_number', 'bool'),
416 416 ('show_sha_length', 'rhodecode_show_sha_length', 'int'),
417 417 ]
418 418 for setting, form_key, type_ in settings:
419 419 sett = SettingsModel().create_or_update_setting(
420 420 setting, form_result[form_key], type_)
421 421 Session().add(sett)
422 422
423 423 Session().commit()
424 424 SettingsModel().invalidate_settings_cache()
425 425 h.flash(_('Updated visualisation settings'), category='success')
426 426 except Exception:
427 427 log.exception("Exception updating visualization settings")
428 428 h.flash(_('Error occurred during updating '
429 429 'visualisation settings'),
430 430 category='error')
431 431
432 432 raise HTTPFound(h.route_path('admin_settings_visual'))
433 433
434 434 @LoginRequired()
435 435 @HasPermissionAllDecorator('hg.admin')
436 436 @view_config(
437 437 route_name='admin_settings_issuetracker', request_method='GET',
438 438 renderer='rhodecode:templates/admin/settings/settings.mako')
439 439 def settings_issuetracker(self):
440 440 c = self.load_default_context()
441 441 c.active = 'issuetracker'
442 442 defaults = SettingsModel().get_all_settings()
443 443
444 444 entry_key = 'rhodecode_issuetracker_pat_'
445 445
446 446 c.issuetracker_entries = {}
447 447 for k, v in defaults.items():
448 448 if k.startswith(entry_key):
449 449 uid = k[len(entry_key):]
450 450 c.issuetracker_entries[uid] = None
451 451
452 452 for uid in c.issuetracker_entries:
453 453 c.issuetracker_entries[uid] = AttributeDict({
454 454 'pat': defaults.get('rhodecode_issuetracker_pat_' + uid),
455 455 'url': defaults.get('rhodecode_issuetracker_url_' + uid),
456 456 'pref': defaults.get('rhodecode_issuetracker_pref_' + uid),
457 457 'desc': defaults.get('rhodecode_issuetracker_desc_' + uid),
458 458 })
459 459
460 460 return self._get_template_context(c)
461 461
462 462 @LoginRequired()
463 463 @HasPermissionAllDecorator('hg.admin')
464 464 @CSRFRequired()
465 465 @view_config(
466 466 route_name='admin_settings_issuetracker_test', request_method='POST',
467 467 renderer='string', xhr=True)
468 468 def settings_issuetracker_test(self):
469 469 return h.urlify_commit_message(
470 470 self.request.POST.get('test_text', ''),
471 471 'repo_group/test_repo1')
472 472
473 473 @LoginRequired()
474 474 @HasPermissionAllDecorator('hg.admin')
475 475 @CSRFRequired()
476 476 @view_config(
477 477 route_name='admin_settings_issuetracker_update', request_method='POST',
478 478 renderer='rhodecode:templates/admin/settings/settings.mako')
479 479 def settings_issuetracker_update(self):
480 480 _ = self.request.translate
481 481 self.load_default_context()
482 482 settings_model = IssueTrackerSettingsModel()
483 483
484 484 try:
485 form = IssueTrackerPatternsForm(self.request.translate)().to_python(self.request.POST)
485 form = IssueTrackerPatternsForm(self.request.translate)()
486 data = form.to_python(self.request.POST)
486 487 except formencode.Invalid as errors:
487 488 log.exception('Failed to add new pattern')
488 489 error = errors
489 490 h.flash(_('Invalid issue tracker pattern: {}'.format(error)),
490 491 category='error')
491 492 raise HTTPFound(h.route_path('admin_settings_issuetracker'))
492 493
493 if form:
494 for uid in form.get('delete_patterns', []):
494 if data:
495 for uid in data.get('delete_patterns', []):
495 496 settings_model.delete_entries(uid)
496 497
497 for pattern in form.get('patterns', []):
498 for pattern in data.get('patterns', []):
498 499 for setting, value, type_ in pattern:
499 500 sett = settings_model.create_or_update_setting(
500 501 setting, value, type_)
501 502 Session().add(sett)
502 503
503 504 Session().commit()
504 505
505 506 SettingsModel().invalidate_settings_cache()
506 507 h.flash(_('Updated issue tracker entries'), category='success')
507 508 raise HTTPFound(h.route_path('admin_settings_issuetracker'))
508 509
509 510 @LoginRequired()
510 511 @HasPermissionAllDecorator('hg.admin')
511 512 @CSRFRequired()
512 513 @view_config(
513 514 route_name='admin_settings_issuetracker_delete', request_method='POST',
514 515 renderer='rhodecode:templates/admin/settings/settings.mako')
515 516 def settings_issuetracker_delete(self):
516 517 _ = self.request.translate
517 518 self.load_default_context()
518 519 uid = self.request.POST.get('uid')
519 520 try:
520 521 IssueTrackerSettingsModel().delete_entries(uid)
521 522 except Exception:
522 523 log.exception('Failed to delete issue tracker setting %s', uid)
523 524 raise HTTPNotFound()
524 525 h.flash(_('Removed issue tracker entry'), category='success')
525 526 raise HTTPFound(h.route_path('admin_settings_issuetracker'))
526 527
527 528 @LoginRequired()
528 529 @HasPermissionAllDecorator('hg.admin')
529 530 @view_config(
530 531 route_name='admin_settings_email', request_method='GET',
531 532 renderer='rhodecode:templates/admin/settings/settings.mako')
532 533 def settings_email(self):
533 534 c = self.load_default_context()
534 535 c.active = 'email'
535 536 c.rhodecode_ini = rhodecode.CONFIG
536 537
537 538 data = render('rhodecode:templates/admin/settings/settings.mako',
538 539 self._get_template_context(c), self.request)
539 540 html = formencode.htmlfill.render(
540 541 data,
541 542 defaults=self._form_defaults(),
542 543 encoding="UTF-8",
543 544 force_defaults=False
544 545 )
545 546 return Response(html)
546 547
547 548 @LoginRequired()
548 549 @HasPermissionAllDecorator('hg.admin')
549 550 @CSRFRequired()
550 551 @view_config(
551 552 route_name='admin_settings_email_update', request_method='POST',
552 553 renderer='rhodecode:templates/admin/settings/settings.mako')
553 554 def settings_email_update(self):
554 555 _ = self.request.translate
555 556 c = self.load_default_context()
556 557 c.active = 'email'
557 558
558 559 test_email = self.request.POST.get('test_email')
559 560
560 561 if not test_email:
561 562 h.flash(_('Please enter email address'), category='error')
562 563 raise HTTPFound(h.route_path('admin_settings_email'))
563 564
564 565 email_kwargs = {
565 566 'date': datetime.datetime.now(),
566 567 'user': c.rhodecode_user,
567 568 'rhodecode_version': c.rhodecode_version
568 569 }
569 570
570 571 (subject, headers, email_body,
571 572 email_body_plaintext) = EmailNotificationModel().render_email(
572 573 EmailNotificationModel.TYPE_EMAIL_TEST, **email_kwargs)
573 574
574 575 recipients = [test_email] if test_email else None
575 576
576 577 run_task(tasks.send_email, recipients, subject,
577 578 email_body_plaintext, email_body)
578 579
579 580 h.flash(_('Send email task created'), category='success')
580 581 raise HTTPFound(h.route_path('admin_settings_email'))
581 582
582 583 @LoginRequired()
583 584 @HasPermissionAllDecorator('hg.admin')
584 585 @view_config(
585 586 route_name='admin_settings_hooks', request_method='GET',
586 587 renderer='rhodecode:templates/admin/settings/settings.mako')
587 588 def settings_hooks(self):
588 589 c = self.load_default_context()
589 590 c.active = 'hooks'
590 591
591 592 model = SettingsModel()
592 593 c.hooks = model.get_builtin_hooks()
593 594 c.custom_hooks = model.get_custom_hooks()
594 595
595 596 data = render('rhodecode:templates/admin/settings/settings.mako',
596 597 self._get_template_context(c), self.request)
597 598 html = formencode.htmlfill.render(
598 599 data,
599 600 defaults=self._form_defaults(),
600 601 encoding="UTF-8",
601 602 force_defaults=False
602 603 )
603 604 return Response(html)
604 605
605 606 @LoginRequired()
606 607 @HasPermissionAllDecorator('hg.admin')
607 608 @CSRFRequired()
608 609 @view_config(
609 610 route_name='admin_settings_hooks_update', request_method='POST',
610 611 renderer='rhodecode:templates/admin/settings/settings.mako')
611 612 @view_config(
612 613 route_name='admin_settings_hooks_delete', request_method='POST',
613 614 renderer='rhodecode:templates/admin/settings/settings.mako')
614 615 def settings_hooks_update(self):
615 616 _ = self.request.translate
616 617 c = self.load_default_context()
617 618 c.active = 'hooks'
618 619 if c.visual.allow_custom_hooks_settings:
619 620 ui_key = self.request.POST.get('new_hook_ui_key')
620 621 ui_value = self.request.POST.get('new_hook_ui_value')
621 622
622 623 hook_id = self.request.POST.get('hook_id')
623 624 new_hook = False
624 625
625 626 model = SettingsModel()
626 627 try:
627 628 if ui_value and ui_key:
628 629 model.create_or_update_hook(ui_key, ui_value)
629 630 h.flash(_('Added new hook'), category='success')
630 631 new_hook = True
631 632 elif hook_id:
632 633 RhodeCodeUi.delete(hook_id)
633 634 Session().commit()
634 635
635 636 # check for edits
636 637 update = False
637 638 _d = self.request.POST.dict_of_lists()
638 639 for k, v in zip(_d.get('hook_ui_key', []),
639 640 _d.get('hook_ui_value_new', [])):
640 641 model.create_or_update_hook(k, v)
641 642 update = True
642 643
643 644 if update and not new_hook:
644 645 h.flash(_('Updated hooks'), category='success')
645 646 Session().commit()
646 647 except Exception:
647 648 log.exception("Exception during hook creation")
648 649 h.flash(_('Error occurred during hook creation'),
649 650 category='error')
650 651
651 652 raise HTTPFound(h.route_path('admin_settings_hooks'))
652 653
653 654 @LoginRequired()
654 655 @HasPermissionAllDecorator('hg.admin')
655 656 @view_config(
656 657 route_name='admin_settings_search', request_method='GET',
657 658 renderer='rhodecode:templates/admin/settings/settings.mako')
658 659 def settings_search(self):
659 660 c = self.load_default_context()
660 661 c.active = 'search'
661 662
662 663 searcher = searcher_from_config(self.request.registry.settings)
663 664 c.statistics = searcher.statistics(self.request.translate)
664 665
665 666 return self._get_template_context(c)
666 667
667 668 @LoginRequired()
668 669 @HasPermissionAllDecorator('hg.admin')
669 670 @view_config(
670 671 route_name='admin_settings_labs', request_method='GET',
671 672 renderer='rhodecode:templates/admin/settings/settings.mako')
672 673 def settings_labs(self):
673 674 c = self.load_default_context()
674 675 if not c.labs_active:
675 676 raise HTTPFound(h.route_path('admin_settings'))
676 677
677 678 c.active = 'labs'
678 679 c.lab_settings = _LAB_SETTINGS
679 680
680 681 data = render('rhodecode:templates/admin/settings/settings.mako',
681 682 self._get_template_context(c), self.request)
682 683 html = formencode.htmlfill.render(
683 684 data,
684 685 defaults=self._form_defaults(),
685 686 encoding="UTF-8",
686 687 force_defaults=False
687 688 )
688 689 return Response(html)
689 690
690 691 @LoginRequired()
691 692 @HasPermissionAllDecorator('hg.admin')
692 693 @CSRFRequired()
693 694 @view_config(
694 695 route_name='admin_settings_labs_update', request_method='POST',
695 696 renderer='rhodecode:templates/admin/settings/settings.mako')
696 697 def settings_labs_update(self):
697 698 _ = self.request.translate
698 699 c = self.load_default_context()
699 700 c.active = 'labs'
700 701
701 702 application_form = LabsSettingsForm(self.request.translate)()
702 703 try:
703 704 form_result = application_form.to_python(dict(self.request.POST))
704 705 except formencode.Invalid as errors:
705 706 h.flash(
706 707 _('Some form inputs contain invalid data.'),
707 708 category='error')
708 709 data = render('rhodecode:templates/admin/settings/settings.mako',
709 710 self._get_template_context(c), self.request)
710 711 html = formencode.htmlfill.render(
711 712 data,
712 713 defaults=errors.value,
713 714 errors=errors.error_dict or {},
714 715 prefix_error=False,
715 716 encoding="UTF-8",
716 717 force_defaults=False
717 718 )
718 719 return Response(html)
719 720
720 721 try:
721 722 session = Session()
722 723 for setting in _LAB_SETTINGS:
723 724 setting_name = setting.key[len('rhodecode_'):]
724 725 sett = SettingsModel().create_or_update_setting(
725 726 setting_name, form_result[setting.key], setting.type)
726 727 session.add(sett)
727 728
728 729 except Exception:
729 730 log.exception('Exception while updating lab settings')
730 731 h.flash(_('Error occurred during updating labs settings'),
731 732 category='error')
732 733 else:
733 734 Session().commit()
734 735 SettingsModel().invalidate_settings_cache()
735 736 h.flash(_('Updated Labs settings'), category='success')
736 737 raise HTTPFound(h.route_path('admin_settings_labs'))
737 738
738 739 data = render('rhodecode:templates/admin/settings/settings.mako',
739 740 self._get_template_context(c), self.request)
740 741 html = formencode.htmlfill.render(
741 742 data,
742 743 defaults=self._form_defaults(),
743 744 encoding="UTF-8",
744 745 force_defaults=False
745 746 )
746 747 return Response(html)
747 748
748 749
749 750 # :param key: name of the setting including the 'rhodecode_' prefix
750 751 # :param type: the RhodeCodeSetting type to use.
751 752 # :param group: the i18ned group in which we should dispaly this setting
752 753 # :param label: the i18ned label we should display for this setting
753 754 # :param help: the i18ned help we should dispaly for this setting
754 755 LabSetting = collections.namedtuple(
755 756 'LabSetting', ('key', 'type', 'group', 'label', 'help'))
756 757
757 758
758 759 # This list has to be kept in sync with the form
759 760 # rhodecode.model.forms.LabsSettingsForm.
760 761 _LAB_SETTINGS = [
761 762
762 763 ]
@@ -1,2064 +1,2072 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 Helper functions
23 23
24 24 Consists of functions to typically be used within templates, but also
25 25 available to Controllers. This module is available to both as 'h'.
26 26 """
27 27
28 28 import random
29 29 import hashlib
30 30 import StringIO
31 31 import urllib
32 32 import math
33 33 import logging
34 34 import re
35 35 import urlparse
36 36 import time
37 37 import string
38 38 import hashlib
39 39 from collections import OrderedDict
40 40
41 41 import pygments
42 42 import itertools
43 43 import fnmatch
44 44
45 45 from datetime import datetime
46 46 from functools import partial
47 47 from pygments.formatters.html import HtmlFormatter
48 48 from pygments import highlight as code_highlight
49 49 from pygments.lexers import (
50 50 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
51 51
52 52 from pyramid.threadlocal import get_current_request
53 53
54 54 from webhelpers.html import literal, HTML, escape
55 55 from webhelpers.html.tools import *
56 56 from webhelpers.html.builder import make_tag
57 57 from webhelpers.html.tags import auto_discovery_link, checkbox, css_classes, \
58 58 end_form, file, form as wh_form, hidden, image, javascript_link, link_to, \
59 59 link_to_if, link_to_unless, ol, required_legend, select, stylesheet_link, \
60 60 submit, text, password, textarea, title, ul, xml_declaration, radio
61 61 from webhelpers.html.tools import auto_link, button_to, highlight, \
62 62 js_obfuscate, mail_to, strip_links, strip_tags, tag_re
63 63 from webhelpers.text import chop_at, collapse, convert_accented_entities, \
64 64 convert_misc_entities, lchop, plural, rchop, remove_formatting, \
65 65 replace_whitespace, urlify, truncate, wrap_paragraphs
66 66 from webhelpers.date import time_ago_in_words
67 67 from webhelpers.paginate import Page as _Page
68 68 from webhelpers.html.tags import _set_input_attrs, _set_id_attr, \
69 69 convert_boolean_attrs, NotGiven, _make_safe_id_component
70 70 from webhelpers2.number import format_byte_size
71 71
72 72 from rhodecode.lib.action_parser import action_parser
73 73 from rhodecode.lib.ext_json import json
74 74 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
75 75 from rhodecode.lib.utils2 import str2bool, safe_unicode, safe_str, \
76 76 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime, \
77 77 AttributeDict, safe_int, md5, md5_safe
78 78 from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links
79 79 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
80 80 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
81 81 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
82 82 from rhodecode.model.changeset_status import ChangesetStatusModel
83 83 from rhodecode.model.db import Permission, User, Repository
84 84 from rhodecode.model.repo_group import RepoGroupModel
85 85 from rhodecode.model.settings import IssueTrackerSettingsModel
86 86
87 87 log = logging.getLogger(__name__)
88 88
89 89
90 90 DEFAULT_USER = User.DEFAULT_USER
91 91 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
92 92
93 93
94 94 def asset(path, ver=None, **kwargs):
95 95 """
96 96 Helper to generate a static asset file path for rhodecode assets
97 97
98 98 eg. h.asset('images/image.png', ver='3923')
99 99
100 100 :param path: path of asset
101 101 :param ver: optional version query param to append as ?ver=
102 102 """
103 103 request = get_current_request()
104 104 query = {}
105 105 query.update(kwargs)
106 106 if ver:
107 107 query = {'ver': ver}
108 108 return request.static_path(
109 109 'rhodecode:public/{}'.format(path), _query=query)
110 110
111 111
112 112 default_html_escape_table = {
113 113 ord('&'): u'&amp;',
114 114 ord('<'): u'&lt;',
115 115 ord('>'): u'&gt;',
116 116 ord('"'): u'&quot;',
117 117 ord("'"): u'&#39;',
118 118 }
119 119
120 120
121 121 def html_escape(text, html_escape_table=default_html_escape_table):
122 122 """Produce entities within text."""
123 123 return text.translate(html_escape_table)
124 124
125 125
126 126 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
127 127 """
128 128 Truncate string ``s`` at the first occurrence of ``sub``.
129 129
130 130 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
131 131 """
132 132 suffix_if_chopped = suffix_if_chopped or ''
133 133 pos = s.find(sub)
134 134 if pos == -1:
135 135 return s
136 136
137 137 if inclusive:
138 138 pos += len(sub)
139 139
140 140 chopped = s[:pos]
141 141 left = s[pos:].strip()
142 142
143 143 if left and suffix_if_chopped:
144 144 chopped += suffix_if_chopped
145 145
146 146 return chopped
147 147
148 148
149 149 def shorter(text, size=20):
150 150 postfix = '...'
151 151 if len(text) > size:
152 152 return text[:size - len(postfix)] + postfix
153 153 return text
154 154
155 155
156 156 def _reset(name, value=None, id=NotGiven, type="reset", **attrs):
157 157 """
158 158 Reset button
159 159 """
160 160 _set_input_attrs(attrs, type, name, value)
161 161 _set_id_attr(attrs, id, name)
162 162 convert_boolean_attrs(attrs, ["disabled"])
163 163 return HTML.input(**attrs)
164 164
165 165 reset = _reset
166 166 safeid = _make_safe_id_component
167 167
168 168
169 169 def branding(name, length=40):
170 170 return truncate(name, length, indicator="")
171 171
172 172
173 173 def FID(raw_id, path):
174 174 """
175 175 Creates a unique ID for filenode based on it's hash of path and commit
176 176 it's safe to use in urls
177 177
178 178 :param raw_id:
179 179 :param path:
180 180 """
181 181
182 182 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
183 183
184 184
185 185 class _GetError(object):
186 186 """Get error from form_errors, and represent it as span wrapped error
187 187 message
188 188
189 189 :param field_name: field to fetch errors for
190 190 :param form_errors: form errors dict
191 191 """
192 192
193 193 def __call__(self, field_name, form_errors):
194 194 tmpl = """<span class="error_msg">%s</span>"""
195 195 if form_errors and field_name in form_errors:
196 196 return literal(tmpl % form_errors.get(field_name))
197 197
198 198 get_error = _GetError()
199 199
200 200
201 201 class _ToolTip(object):
202 202
203 203 def __call__(self, tooltip_title, trim_at=50):
204 204 """
205 205 Special function just to wrap our text into nice formatted
206 206 autowrapped text
207 207
208 208 :param tooltip_title:
209 209 """
210 210 tooltip_title = escape(tooltip_title)
211 211 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
212 212 return tooltip_title
213 213 tooltip = _ToolTip()
214 214
215 215
216 216 def files_breadcrumbs(repo_name, commit_id, file_path):
217 217 if isinstance(file_path, str):
218 218 file_path = safe_unicode(file_path)
219 219
220 220 # TODO: johbo: Is this always a url like path, or is this operating
221 221 # system dependent?
222 222 path_segments = file_path.split('/')
223 223
224 224 repo_name_html = escape(repo_name)
225 225 if len(path_segments) == 1 and path_segments[0] == '':
226 226 url_segments = [repo_name_html]
227 227 else:
228 228 url_segments = [
229 229 link_to(
230 230 repo_name_html,
231 231 route_path(
232 232 'repo_files',
233 233 repo_name=repo_name,
234 234 commit_id=commit_id,
235 235 f_path=''),
236 236 class_='pjax-link')]
237 237
238 238 last_cnt = len(path_segments) - 1
239 239 for cnt, segment in enumerate(path_segments):
240 240 if not segment:
241 241 continue
242 242 segment_html = escape(segment)
243 243
244 244 if cnt != last_cnt:
245 245 url_segments.append(
246 246 link_to(
247 247 segment_html,
248 248 route_path(
249 249 'repo_files',
250 250 repo_name=repo_name,
251 251 commit_id=commit_id,
252 252 f_path='/'.join(path_segments[:cnt + 1])),
253 253 class_='pjax-link'))
254 254 else:
255 255 url_segments.append(segment_html)
256 256
257 257 return literal('/'.join(url_segments))
258 258
259 259
260 260 class CodeHtmlFormatter(HtmlFormatter):
261 261 """
262 262 My code Html Formatter for source codes
263 263 """
264 264
265 265 def wrap(self, source, outfile):
266 266 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
267 267
268 268 def _wrap_code(self, source):
269 269 for cnt, it in enumerate(source):
270 270 i, t = it
271 271 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
272 272 yield i, t
273 273
274 274 def _wrap_tablelinenos(self, inner):
275 275 dummyoutfile = StringIO.StringIO()
276 276 lncount = 0
277 277 for t, line in inner:
278 278 if t:
279 279 lncount += 1
280 280 dummyoutfile.write(line)
281 281
282 282 fl = self.linenostart
283 283 mw = len(str(lncount + fl - 1))
284 284 sp = self.linenospecial
285 285 st = self.linenostep
286 286 la = self.lineanchors
287 287 aln = self.anchorlinenos
288 288 nocls = self.noclasses
289 289 if sp:
290 290 lines = []
291 291
292 292 for i in range(fl, fl + lncount):
293 293 if i % st == 0:
294 294 if i % sp == 0:
295 295 if aln:
296 296 lines.append('<a href="#%s%d" class="special">%*d</a>' %
297 297 (la, i, mw, i))
298 298 else:
299 299 lines.append('<span class="special">%*d</span>' % (mw, i))
300 300 else:
301 301 if aln:
302 302 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
303 303 else:
304 304 lines.append('%*d' % (mw, i))
305 305 else:
306 306 lines.append('')
307 307 ls = '\n'.join(lines)
308 308 else:
309 309 lines = []
310 310 for i in range(fl, fl + lncount):
311 311 if i % st == 0:
312 312 if aln:
313 313 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
314 314 else:
315 315 lines.append('%*d' % (mw, i))
316 316 else:
317 317 lines.append('')
318 318 ls = '\n'.join(lines)
319 319
320 320 # in case you wonder about the seemingly redundant <div> here: since the
321 321 # content in the other cell also is wrapped in a div, some browsers in
322 322 # some configurations seem to mess up the formatting...
323 323 if nocls:
324 324 yield 0, ('<table class="%stable">' % self.cssclass +
325 325 '<tr><td><div class="linenodiv" '
326 326 'style="background-color: #f0f0f0; padding-right: 10px">'
327 327 '<pre style="line-height: 125%">' +
328 328 ls + '</pre></div></td><td id="hlcode" class="code">')
329 329 else:
330 330 yield 0, ('<table class="%stable">' % self.cssclass +
331 331 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
332 332 ls + '</pre></div></td><td id="hlcode" class="code">')
333 333 yield 0, dummyoutfile.getvalue()
334 334 yield 0, '</td></tr></table>'
335 335
336 336
337 337 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
338 338 def __init__(self, **kw):
339 339 # only show these line numbers if set
340 340 self.only_lines = kw.pop('only_line_numbers', [])
341 341 self.query_terms = kw.pop('query_terms', [])
342 342 self.max_lines = kw.pop('max_lines', 5)
343 343 self.line_context = kw.pop('line_context', 3)
344 344 self.url = kw.pop('url', None)
345 345
346 346 super(CodeHtmlFormatter, self).__init__(**kw)
347 347
348 348 def _wrap_code(self, source):
349 349 for cnt, it in enumerate(source):
350 350 i, t = it
351 351 t = '<pre>%s</pre>' % t
352 352 yield i, t
353 353
354 354 def _wrap_tablelinenos(self, inner):
355 355 yield 0, '<table class="code-highlight %stable">' % self.cssclass
356 356
357 357 last_shown_line_number = 0
358 358 current_line_number = 1
359 359
360 360 for t, line in inner:
361 361 if not t:
362 362 yield t, line
363 363 continue
364 364
365 365 if current_line_number in self.only_lines:
366 366 if last_shown_line_number + 1 != current_line_number:
367 367 yield 0, '<tr>'
368 368 yield 0, '<td class="line">...</td>'
369 369 yield 0, '<td id="hlcode" class="code"></td>'
370 370 yield 0, '</tr>'
371 371
372 372 yield 0, '<tr>'
373 373 if self.url:
374 374 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
375 375 self.url, current_line_number, current_line_number)
376 376 else:
377 377 yield 0, '<td class="line"><a href="">%i</a></td>' % (
378 378 current_line_number)
379 379 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
380 380 yield 0, '</tr>'
381 381
382 382 last_shown_line_number = current_line_number
383 383
384 384 current_line_number += 1
385 385
386 386
387 387 yield 0, '</table>'
388 388
389 389
390 390 def extract_phrases(text_query):
391 391 """
392 392 Extracts phrases from search term string making sure phrases
393 393 contained in double quotes are kept together - and discarding empty values
394 394 or fully whitespace values eg.
395 395
396 396 'some text "a phrase" more' => ['some', 'text', 'a phrase', 'more']
397 397
398 398 """
399 399
400 400 in_phrase = False
401 401 buf = ''
402 402 phrases = []
403 403 for char in text_query:
404 404 if in_phrase:
405 405 if char == '"': # end phrase
406 406 phrases.append(buf)
407 407 buf = ''
408 408 in_phrase = False
409 409 continue
410 410 else:
411 411 buf += char
412 412 continue
413 413 else:
414 414 if char == '"': # start phrase
415 415 in_phrase = True
416 416 phrases.append(buf)
417 417 buf = ''
418 418 continue
419 419 elif char == ' ':
420 420 phrases.append(buf)
421 421 buf = ''
422 422 continue
423 423 else:
424 424 buf += char
425 425
426 426 phrases.append(buf)
427 427 phrases = [phrase.strip() for phrase in phrases if phrase.strip()]
428 428 return phrases
429 429
430 430
431 431 def get_matching_offsets(text, phrases):
432 432 """
433 433 Returns a list of string offsets in `text` that the list of `terms` match
434 434
435 435 >>> get_matching_offsets('some text here', ['some', 'here'])
436 436 [(0, 4), (10, 14)]
437 437
438 438 """
439 439 offsets = []
440 440 for phrase in phrases:
441 441 for match in re.finditer(phrase, text):
442 442 offsets.append((match.start(), match.end()))
443 443
444 444 return offsets
445 445
446 446
447 447 def normalize_text_for_matching(x):
448 448 """
449 449 Replaces all non alnum characters to spaces and lower cases the string,
450 450 useful for comparing two text strings without punctuation
451 451 """
452 452 return re.sub(r'[^\w]', ' ', x.lower())
453 453
454 454
455 455 def get_matching_line_offsets(lines, terms):
456 456 """ Return a set of `lines` indices (starting from 1) matching a
457 457 text search query, along with `context` lines above/below matching lines
458 458
459 459 :param lines: list of strings representing lines
460 460 :param terms: search term string to match in lines eg. 'some text'
461 461 :param context: number of lines above/below a matching line to add to result
462 462 :param max_lines: cut off for lines of interest
463 463 eg.
464 464
465 465 text = '''
466 466 words words words
467 467 words words words
468 468 some text some
469 469 words words words
470 470 words words words
471 471 text here what
472 472 '''
473 473 get_matching_line_offsets(text, 'text', context=1)
474 474 {3: [(5, 9)], 6: [(0, 4)]]
475 475
476 476 """
477 477 matching_lines = {}
478 478 phrases = [normalize_text_for_matching(phrase)
479 479 for phrase in extract_phrases(terms)]
480 480
481 481 for line_index, line in enumerate(lines, start=1):
482 482 match_offsets = get_matching_offsets(
483 483 normalize_text_for_matching(line), phrases)
484 484 if match_offsets:
485 485 matching_lines[line_index] = match_offsets
486 486
487 487 return matching_lines
488 488
489 489
490 490 def hsv_to_rgb(h, s, v):
491 491 """ Convert hsv color values to rgb """
492 492
493 493 if s == 0.0:
494 494 return v, v, v
495 495 i = int(h * 6.0) # XXX assume int() truncates!
496 496 f = (h * 6.0) - i
497 497 p = v * (1.0 - s)
498 498 q = v * (1.0 - s * f)
499 499 t = v * (1.0 - s * (1.0 - f))
500 500 i = i % 6
501 501 if i == 0:
502 502 return v, t, p
503 503 if i == 1:
504 504 return q, v, p
505 505 if i == 2:
506 506 return p, v, t
507 507 if i == 3:
508 508 return p, q, v
509 509 if i == 4:
510 510 return t, p, v
511 511 if i == 5:
512 512 return v, p, q
513 513
514 514
515 515 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
516 516 """
517 517 Generator for getting n of evenly distributed colors using
518 518 hsv color and golden ratio. It always return same order of colors
519 519
520 520 :param n: number of colors to generate
521 521 :param saturation: saturation of returned colors
522 522 :param lightness: lightness of returned colors
523 523 :returns: RGB tuple
524 524 """
525 525
526 526 golden_ratio = 0.618033988749895
527 527 h = 0.22717784590367374
528 528
529 529 for _ in xrange(n):
530 530 h += golden_ratio
531 531 h %= 1
532 532 HSV_tuple = [h, saturation, lightness]
533 533 RGB_tuple = hsv_to_rgb(*HSV_tuple)
534 534 yield map(lambda x: str(int(x * 256)), RGB_tuple)
535 535
536 536
537 537 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
538 538 """
539 539 Returns a function which when called with an argument returns a unique
540 540 color for that argument, eg.
541 541
542 542 :param n: number of colors to generate
543 543 :param saturation: saturation of returned colors
544 544 :param lightness: lightness of returned colors
545 545 :returns: css RGB string
546 546
547 547 >>> color_hash = color_hasher()
548 548 >>> color_hash('hello')
549 549 'rgb(34, 12, 59)'
550 550 >>> color_hash('hello')
551 551 'rgb(34, 12, 59)'
552 552 >>> color_hash('other')
553 553 'rgb(90, 224, 159)'
554 554 """
555 555
556 556 color_dict = {}
557 557 cgenerator = unique_color_generator(
558 558 saturation=saturation, lightness=lightness)
559 559
560 560 def get_color_string(thing):
561 561 if thing in color_dict:
562 562 col = color_dict[thing]
563 563 else:
564 564 col = color_dict[thing] = cgenerator.next()
565 565 return "rgb(%s)" % (', '.join(col))
566 566
567 567 return get_color_string
568 568
569 569
570 570 def get_lexer_safe(mimetype=None, filepath=None):
571 571 """
572 572 Tries to return a relevant pygments lexer using mimetype/filepath name,
573 573 defaulting to plain text if none could be found
574 574 """
575 575 lexer = None
576 576 try:
577 577 if mimetype:
578 578 lexer = get_lexer_for_mimetype(mimetype)
579 579 if not lexer:
580 580 lexer = get_lexer_for_filename(filepath)
581 581 except pygments.util.ClassNotFound:
582 582 pass
583 583
584 584 if not lexer:
585 585 lexer = get_lexer_by_name('text')
586 586
587 587 return lexer
588 588
589 589
590 590 def get_lexer_for_filenode(filenode):
591 591 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
592 592 return lexer
593 593
594 594
595 595 def pygmentize(filenode, **kwargs):
596 596 """
597 597 pygmentize function using pygments
598 598
599 599 :param filenode:
600 600 """
601 601 lexer = get_lexer_for_filenode(filenode)
602 602 return literal(code_highlight(filenode.content, lexer,
603 603 CodeHtmlFormatter(**kwargs)))
604 604
605 605
606 606 def is_following_repo(repo_name, user_id):
607 607 from rhodecode.model.scm import ScmModel
608 608 return ScmModel().is_following_repo(repo_name, user_id)
609 609
610 610
611 611 class _Message(object):
612 612 """A message returned by ``Flash.pop_messages()``.
613 613
614 614 Converting the message to a string returns the message text. Instances
615 615 also have the following attributes:
616 616
617 617 * ``message``: the message text.
618 618 * ``category``: the category specified when the message was created.
619 619 """
620 620
621 621 def __init__(self, category, message):
622 622 self.category = category
623 623 self.message = message
624 624
625 625 def __str__(self):
626 626 return self.message
627 627
628 628 __unicode__ = __str__
629 629
630 630 def __html__(self):
631 631 return escape(safe_unicode(self.message))
632 632
633 633
634 634 class Flash(object):
635 635 # List of allowed categories. If None, allow any category.
636 636 categories = ["warning", "notice", "error", "success"]
637 637
638 638 # Default category if none is specified.
639 639 default_category = "notice"
640 640
641 641 def __init__(self, session_key="flash", categories=None,
642 642 default_category=None):
643 643 """
644 644 Instantiate a ``Flash`` object.
645 645
646 646 ``session_key`` is the key to save the messages under in the user's
647 647 session.
648 648
649 649 ``categories`` is an optional list which overrides the default list
650 650 of categories.
651 651
652 652 ``default_category`` overrides the default category used for messages
653 653 when none is specified.
654 654 """
655 655 self.session_key = session_key
656 656 if categories is not None:
657 657 self.categories = categories
658 658 if default_category is not None:
659 659 self.default_category = default_category
660 660 if self.categories and self.default_category not in self.categories:
661 661 raise ValueError(
662 662 "unrecognized default category %r" % (self.default_category,))
663 663
664 664 def pop_messages(self, session=None, request=None):
665 665 """
666 666 Return all accumulated messages and delete them from the session.
667 667
668 668 The return value is a list of ``Message`` objects.
669 669 """
670 670 messages = []
671 671
672 672 if not session:
673 673 if not request:
674 674 request = get_current_request()
675 675 session = request.session
676 676
677 677 # Pop the 'old' pylons flash messages. They are tuples of the form
678 678 # (category, message)
679 679 for cat, msg in session.pop(self.session_key, []):
680 680 messages.append(_Message(cat, msg))
681 681
682 682 # Pop the 'new' pyramid flash messages for each category as list
683 683 # of strings.
684 684 for cat in self.categories:
685 685 for msg in session.pop_flash(queue=cat):
686 686 messages.append(_Message(cat, msg))
687 687 # Map messages from the default queue to the 'notice' category.
688 688 for msg in session.pop_flash():
689 689 messages.append(_Message('notice', msg))
690 690
691 691 session.save()
692 692 return messages
693 693
694 694 def json_alerts(self, session=None, request=None):
695 695 payloads = []
696 696 messages = flash.pop_messages(session=session, request=request)
697 697 if messages:
698 698 for message in messages:
699 699 subdata = {}
700 700 if hasattr(message.message, 'rsplit'):
701 701 flash_data = message.message.rsplit('|DELIM|', 1)
702 702 org_message = flash_data[0]
703 703 if len(flash_data) > 1:
704 704 subdata = json.loads(flash_data[1])
705 705 else:
706 706 org_message = message.message
707 707 payloads.append({
708 708 'message': {
709 709 'message': u'{}'.format(org_message),
710 710 'level': message.category,
711 711 'force': True,
712 712 'subdata': subdata
713 713 }
714 714 })
715 715 return json.dumps(payloads)
716 716
717 717 def __call__(self, message, category=None, ignore_duplicate=False,
718 718 session=None, request=None):
719 719
720 720 if not session:
721 721 if not request:
722 722 request = get_current_request()
723 723 session = request.session
724 724
725 725 session.flash(
726 726 message, queue=category, allow_duplicate=not ignore_duplicate)
727 727
728 728
729 729 flash = Flash()
730 730
731 731 #==============================================================================
732 732 # SCM FILTERS available via h.
733 733 #==============================================================================
734 734 from rhodecode.lib.vcs.utils import author_name, author_email
735 735 from rhodecode.lib.utils2 import credentials_filter, age as _age
736 736 from rhodecode.model.db import User, ChangesetStatus
737 737
738 738 age = _age
739 739 capitalize = lambda x: x.capitalize()
740 740 email = author_email
741 741 short_id = lambda x: x[:12]
742 742 hide_credentials = lambda x: ''.join(credentials_filter(x))
743 743
744 744
745 745 def age_component(datetime_iso, value=None, time_is_local=False):
746 746 title = value or format_date(datetime_iso)
747 747 tzinfo = '+00:00'
748 748
749 749 # detect if we have a timezone info, otherwise, add it
750 750 if isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
751 751 if time_is_local:
752 752 tzinfo = time.strftime("+%H:%M",
753 753 time.gmtime(
754 754 (datetime.now() - datetime.utcnow()).seconds + 1
755 755 )
756 756 )
757 757
758 758 return literal(
759 759 '<time class="timeago tooltip" '
760 760 'title="{1}{2}" datetime="{0}{2}">{1}</time>'.format(
761 761 datetime_iso, title, tzinfo))
762 762
763 763
764 764 def _shorten_commit_id(commit_id):
765 765 from rhodecode import CONFIG
766 766 def_len = safe_int(CONFIG.get('rhodecode_show_sha_length', 12))
767 767 return commit_id[:def_len]
768 768
769 769
770 770 def show_id(commit):
771 771 """
772 772 Configurable function that shows ID
773 773 by default it's r123:fffeeefffeee
774 774
775 775 :param commit: commit instance
776 776 """
777 777 from rhodecode import CONFIG
778 778 show_idx = str2bool(CONFIG.get('rhodecode_show_revision_number', True))
779 779
780 780 raw_id = _shorten_commit_id(commit.raw_id)
781 781 if show_idx:
782 782 return 'r%s:%s' % (commit.idx, raw_id)
783 783 else:
784 784 return '%s' % (raw_id, )
785 785
786 786
787 787 def format_date(date):
788 788 """
789 789 use a standardized formatting for dates used in RhodeCode
790 790
791 791 :param date: date/datetime object
792 792 :return: formatted date
793 793 """
794 794
795 795 if date:
796 796 _fmt = "%a, %d %b %Y %H:%M:%S"
797 797 return safe_unicode(date.strftime(_fmt))
798 798
799 799 return u""
800 800
801 801
802 802 class _RepoChecker(object):
803 803
804 804 def __init__(self, backend_alias):
805 805 self._backend_alias = backend_alias
806 806
807 807 def __call__(self, repository):
808 808 if hasattr(repository, 'alias'):
809 809 _type = repository.alias
810 810 elif hasattr(repository, 'repo_type'):
811 811 _type = repository.repo_type
812 812 else:
813 813 _type = repository
814 814 return _type == self._backend_alias
815 815
816 816 is_git = _RepoChecker('git')
817 817 is_hg = _RepoChecker('hg')
818 818 is_svn = _RepoChecker('svn')
819 819
820 820
821 821 def get_repo_type_by_name(repo_name):
822 822 repo = Repository.get_by_repo_name(repo_name)
823 823 return repo.repo_type
824 824
825 825
826 826 def is_svn_without_proxy(repository):
827 827 if is_svn(repository):
828 828 from rhodecode.model.settings import VcsSettingsModel
829 829 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
830 830 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
831 831 return False
832 832
833 833
834 834 def discover_user(author):
835 835 """
836 836 Tries to discover RhodeCode User based on the autho string. Author string
837 837 is typically `FirstName LastName <email@address.com>`
838 838 """
839 839
840 840 # if author is already an instance use it for extraction
841 841 if isinstance(author, User):
842 842 return author
843 843
844 844 # Valid email in the attribute passed, see if they're in the system
845 845 _email = author_email(author)
846 846 if _email != '':
847 847 user = User.get_by_email(_email, case_insensitive=True, cache=True)
848 848 if user is not None:
849 849 return user
850 850
851 851 # Maybe it's a username, we try to extract it and fetch by username ?
852 852 _author = author_name(author)
853 853 user = User.get_by_username(_author, case_insensitive=True, cache=True)
854 854 if user is not None:
855 855 return user
856 856
857 857 return None
858 858
859 859
860 860 def email_or_none(author):
861 861 # extract email from the commit string
862 862 _email = author_email(author)
863 863
864 864 # If we have an email, use it, otherwise
865 865 # see if it contains a username we can get an email from
866 866 if _email != '':
867 867 return _email
868 868 else:
869 869 user = User.get_by_username(
870 870 author_name(author), case_insensitive=True, cache=True)
871 871
872 872 if user is not None:
873 873 return user.email
874 874
875 875 # No valid email, not a valid user in the system, none!
876 876 return None
877 877
878 878
879 879 def link_to_user(author, length=0, **kwargs):
880 880 user = discover_user(author)
881 881 # user can be None, but if we have it already it means we can re-use it
882 882 # in the person() function, so we save 1 intensive-query
883 883 if user:
884 884 author = user
885 885
886 886 display_person = person(author, 'username_or_name_or_email')
887 887 if length:
888 888 display_person = shorter(display_person, length)
889 889
890 890 if user:
891 891 return link_to(
892 892 escape(display_person),
893 893 route_path('user_profile', username=user.username),
894 894 **kwargs)
895 895 else:
896 896 return escape(display_person)
897 897
898 898
899 899 def person(author, show_attr="username_and_name"):
900 900 user = discover_user(author)
901 901 if user:
902 902 return getattr(user, show_attr)
903 903 else:
904 904 _author = author_name(author)
905 905 _email = email(author)
906 906 return _author or _email
907 907
908 908
909 909 def author_string(email):
910 910 if email:
911 911 user = User.get_by_email(email, case_insensitive=True, cache=True)
912 912 if user:
913 913 if user.first_name or user.last_name:
914 914 return '%s %s &lt;%s&gt;' % (
915 915 user.first_name, user.last_name, email)
916 916 else:
917 917 return email
918 918 else:
919 919 return email
920 920 else:
921 921 return None
922 922
923 923
924 924 def person_by_id(id_, show_attr="username_and_name"):
925 925 # attr to return from fetched user
926 926 person_getter = lambda usr: getattr(usr, show_attr)
927 927
928 928 #maybe it's an ID ?
929 929 if str(id_).isdigit() or isinstance(id_, int):
930 930 id_ = int(id_)
931 931 user = User.get(id_)
932 932 if user is not None:
933 933 return person_getter(user)
934 934 return id_
935 935
936 936
937 937 def gravatar_with_user(request, author, show_disabled=False):
938 938 _render = request.get_partial_renderer(
939 939 'rhodecode:templates/base/base.mako')
940 940 return _render('gravatar_with_user', author, show_disabled=show_disabled)
941 941
942 942
943 943 tags_paterns = OrderedDict((
944 944 ('lang', (re.compile(r'\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+\.]*)\]'),
945 945 '<div class="metatag" tag="lang">\\2</div>')),
946 946
947 947 ('see', (re.compile(r'\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
948 948 '<div class="metatag" tag="see">see: \\1 </div>')),
949 949
950 950 ('url', (re.compile(r'\[url\ \=\&gt;\ \[([a-zA-Z0-9\ \.\-\_]+)\]\((http://|https://|/)(.*?)\)\]'),
951 951 '<div class="metatag" tag="url"> <a href="\\2\\3">\\1</a> </div>')),
952 952
953 953 ('license', (re.compile(r'\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]'),
954 954 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>')),
955 955
956 956 ('ref', (re.compile(r'\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]'),
957 957 '<div class="metatag" tag="ref \\1">\\1: <a href="/\\2">\\2</a></div>')),
958 958
959 959 ('state', (re.compile(r'\[(stable|featured|stale|dead|dev|deprecated)\]'),
960 960 '<div class="metatag" tag="state \\1">\\1</div>')),
961 961
962 962 # label in grey
963 963 ('label', (re.compile(r'\[([a-z]+)\]'),
964 964 '<div class="metatag" tag="label">\\1</div>')),
965 965
966 966 # generic catch all in grey
967 967 ('generic', (re.compile(r'\[([a-zA-Z0-9\.\-\_]+)\]'),
968 968 '<div class="metatag" tag="generic">\\1</div>')),
969 969 ))
970 970
971 971
972 972 def extract_metatags(value):
973 973 """
974 974 Extract supported meta-tags from given text value
975 975 """
976 976 tags = []
977 977 if not value:
978 978 return tags, ''
979 979
980 980 for key, val in tags_paterns.items():
981 981 pat, replace_html = val
982 982 tags.extend([(key, x.group()) for x in pat.finditer(value)])
983 983 value = pat.sub('', value)
984 984
985 985 return tags, value
986 986
987 987
988 988 def style_metatag(tag_type, value):
989 989 """
990 990 converts tags from value into html equivalent
991 991 """
992 992 if not value:
993 993 return ''
994 994
995 995 html_value = value
996 996 tag_data = tags_paterns.get(tag_type)
997 997 if tag_data:
998 998 pat, replace_html = tag_data
999 999 # convert to plain `unicode` instead of a markup tag to be used in
1000 1000 # regex expressions. safe_unicode doesn't work here
1001 1001 html_value = pat.sub(replace_html, unicode(value))
1002 1002
1003 1003 return html_value
1004 1004
1005 1005
1006 1006 def bool2icon(value):
1007 1007 """
1008 1008 Returns boolean value of a given value, represented as html element with
1009 1009 classes that will represent icons
1010 1010
1011 1011 :param value: given value to convert to html node
1012 1012 """
1013 1013
1014 1014 if value: # does bool conversion
1015 1015 return HTML.tag('i', class_="icon-true")
1016 1016 else: # not true as bool
1017 1017 return HTML.tag('i', class_="icon-false")
1018 1018
1019 1019
1020 1020 #==============================================================================
1021 1021 # PERMS
1022 1022 #==============================================================================
1023 1023 from rhodecode.lib.auth import HasPermissionAny, HasPermissionAll, \
1024 1024 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll, \
1025 1025 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token, \
1026 1026 csrf_token_key
1027 1027
1028 1028
1029 1029 #==============================================================================
1030 1030 # GRAVATAR URL
1031 1031 #==============================================================================
1032 1032 class InitialsGravatar(object):
1033 1033 def __init__(self, email_address, first_name, last_name, size=30,
1034 1034 background=None, text_color='#fff'):
1035 1035 self.size = size
1036 1036 self.first_name = first_name
1037 1037 self.last_name = last_name
1038 1038 self.email_address = email_address
1039 1039 self.background = background or self.str2color(email_address)
1040 1040 self.text_color = text_color
1041 1041
1042 1042 def get_color_bank(self):
1043 1043 """
1044 1044 returns a predefined list of colors that gravatars can use.
1045 1045 Those are randomized distinct colors that guarantee readability and
1046 1046 uniqueness.
1047 1047
1048 1048 generated with: http://phrogz.net/css/distinct-colors.html
1049 1049 """
1050 1050 return [
1051 1051 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1052 1052 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1053 1053 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1054 1054 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1055 1055 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1056 1056 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1057 1057 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1058 1058 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1059 1059 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1060 1060 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1061 1061 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1062 1062 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1063 1063 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1064 1064 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1065 1065 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1066 1066 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1067 1067 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1068 1068 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1069 1069 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1070 1070 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1071 1071 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1072 1072 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1073 1073 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1074 1074 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1075 1075 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1076 1076 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1077 1077 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1078 1078 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1079 1079 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1080 1080 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1081 1081 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1082 1082 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1083 1083 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1084 1084 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1085 1085 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1086 1086 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1087 1087 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1088 1088 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1089 1089 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1090 1090 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1091 1091 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1092 1092 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1093 1093 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1094 1094 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1095 1095 '#4f8c46', '#368dd9', '#5c0073'
1096 1096 ]
1097 1097
1098 1098 def rgb_to_hex_color(self, rgb_tuple):
1099 1099 """
1100 1100 Converts an rgb_tuple passed to an hex color.
1101 1101
1102 1102 :param rgb_tuple: tuple with 3 ints represents rgb color space
1103 1103 """
1104 1104 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1105 1105
1106 1106 def email_to_int_list(self, email_str):
1107 1107 """
1108 1108 Get every byte of the hex digest value of email and turn it to integer.
1109 1109 It's going to be always between 0-255
1110 1110 """
1111 1111 digest = md5_safe(email_str.lower())
1112 1112 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1113 1113
1114 1114 def pick_color_bank_index(self, email_str, color_bank):
1115 1115 return self.email_to_int_list(email_str)[0] % len(color_bank)
1116 1116
1117 1117 def str2color(self, email_str):
1118 1118 """
1119 1119 Tries to map in a stable algorithm an email to color
1120 1120
1121 1121 :param email_str:
1122 1122 """
1123 1123 color_bank = self.get_color_bank()
1124 1124 # pick position (module it's length so we always find it in the
1125 1125 # bank even if it's smaller than 256 values
1126 1126 pos = self.pick_color_bank_index(email_str, color_bank)
1127 1127 return color_bank[pos]
1128 1128
1129 1129 def normalize_email(self, email_address):
1130 1130 import unicodedata
1131 1131 # default host used to fill in the fake/missing email
1132 1132 default_host = u'localhost'
1133 1133
1134 1134 if not email_address:
1135 1135 email_address = u'%s@%s' % (User.DEFAULT_USER, default_host)
1136 1136
1137 1137 email_address = safe_unicode(email_address)
1138 1138
1139 1139 if u'@' not in email_address:
1140 1140 email_address = u'%s@%s' % (email_address, default_host)
1141 1141
1142 1142 if email_address.endswith(u'@'):
1143 1143 email_address = u'%s%s' % (email_address, default_host)
1144 1144
1145 1145 email_address = unicodedata.normalize('NFKD', email_address)\
1146 1146 .encode('ascii', 'ignore')
1147 1147 return email_address
1148 1148
1149 1149 def get_initials(self):
1150 1150 """
1151 1151 Returns 2 letter initials calculated based on the input.
1152 1152 The algorithm picks first given email address, and takes first letter
1153 1153 of part before @, and then the first letter of server name. In case
1154 1154 the part before @ is in a format of `somestring.somestring2` it replaces
1155 1155 the server letter with first letter of somestring2
1156 1156
1157 1157 In case function was initialized with both first and lastname, this
1158 1158 overrides the extraction from email by first letter of the first and
1159 1159 last name. We add special logic to that functionality, In case Full name
1160 1160 is compound, like Guido Von Rossum, we use last part of the last name
1161 1161 (Von Rossum) picking `R`.
1162 1162
1163 1163 Function also normalizes the non-ascii characters to they ascii
1164 1164 representation, eg Δ„ => A
1165 1165 """
1166 1166 import unicodedata
1167 1167 # replace non-ascii to ascii
1168 1168 first_name = unicodedata.normalize(
1169 1169 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore')
1170 1170 last_name = unicodedata.normalize(
1171 1171 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore')
1172 1172
1173 1173 # do NFKD encoding, and also make sure email has proper format
1174 1174 email_address = self.normalize_email(self.email_address)
1175 1175
1176 1176 # first push the email initials
1177 1177 prefix, server = email_address.split('@', 1)
1178 1178
1179 1179 # check if prefix is maybe a 'first_name.last_name' syntax
1180 1180 _dot_split = prefix.rsplit('.', 1)
1181 1181 if len(_dot_split) == 2 and _dot_split[1]:
1182 1182 initials = [_dot_split[0][0], _dot_split[1][0]]
1183 1183 else:
1184 1184 initials = [prefix[0], server[0]]
1185 1185
1186 1186 # then try to replace either first_name or last_name
1187 1187 fn_letter = (first_name or " ")[0].strip()
1188 1188 ln_letter = (last_name.split(' ', 1)[-1] or " ")[0].strip()
1189 1189
1190 1190 if fn_letter:
1191 1191 initials[0] = fn_letter
1192 1192
1193 1193 if ln_letter:
1194 1194 initials[1] = ln_letter
1195 1195
1196 1196 return ''.join(initials).upper()
1197 1197
1198 1198 def get_img_data_by_type(self, font_family, img_type):
1199 1199 default_user = """
1200 1200 <svg xmlns="http://www.w3.org/2000/svg"
1201 1201 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1202 1202 viewBox="-15 -10 439.165 429.164"
1203 1203
1204 1204 xml:space="preserve"
1205 1205 style="background:{background};" >
1206 1206
1207 1207 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1208 1208 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1209 1209 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1210 1210 168.596,153.916,216.671,
1211 1211 204.583,216.671z" fill="{text_color}"/>
1212 1212 <path d="M407.164,374.717L360.88,
1213 1213 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1214 1214 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1215 1215 15.366-44.203,23.488-69.076,23.488c-24.877,
1216 1216 0-48.762-8.122-69.078-23.488
1217 1217 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1218 1218 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1219 1219 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1220 1220 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1221 1221 19.402-10.527 C409.699,390.129,
1222 1222 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1223 1223 </svg>""".format(
1224 1224 size=self.size,
1225 1225 background='#979797', # @grey4
1226 1226 text_color=self.text_color,
1227 1227 font_family=font_family)
1228 1228
1229 1229 return {
1230 1230 "default_user": default_user
1231 1231 }[img_type]
1232 1232
1233 1233 def get_img_data(self, svg_type=None):
1234 1234 """
1235 1235 generates the svg metadata for image
1236 1236 """
1237 1237
1238 1238 font_family = ','.join([
1239 1239 'proximanovaregular',
1240 1240 'Proxima Nova Regular',
1241 1241 'Proxima Nova',
1242 1242 'Arial',
1243 1243 'Lucida Grande',
1244 1244 'sans-serif'
1245 1245 ])
1246 1246 if svg_type:
1247 1247 return self.get_img_data_by_type(font_family, svg_type)
1248 1248
1249 1249 initials = self.get_initials()
1250 1250 img_data = """
1251 1251 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1252 1252 width="{size}" height="{size}"
1253 1253 style="width: 100%; height: 100%; background-color: {background}"
1254 1254 viewBox="0 0 {size} {size}">
1255 1255 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1256 1256 pointer-events="auto" fill="{text_color}"
1257 1257 font-family="{font_family}"
1258 1258 style="font-weight: 400; font-size: {f_size}px;">{text}
1259 1259 </text>
1260 1260 </svg>""".format(
1261 1261 size=self.size,
1262 1262 f_size=self.size/1.85, # scale the text inside the box nicely
1263 1263 background=self.background,
1264 1264 text_color=self.text_color,
1265 1265 text=initials.upper(),
1266 1266 font_family=font_family)
1267 1267
1268 1268 return img_data
1269 1269
1270 1270 def generate_svg(self, svg_type=None):
1271 1271 img_data = self.get_img_data(svg_type)
1272 1272 return "data:image/svg+xml;base64,%s" % img_data.encode('base64')
1273 1273
1274 1274
1275 1275 def initials_gravatar(email_address, first_name, last_name, size=30):
1276 1276 svg_type = None
1277 1277 if email_address == User.DEFAULT_USER_EMAIL:
1278 1278 svg_type = 'default_user'
1279 1279 klass = InitialsGravatar(email_address, first_name, last_name, size)
1280 1280 return klass.generate_svg(svg_type=svg_type)
1281 1281
1282 1282
1283 1283 def gravatar_url(email_address, size=30, request=None):
1284 1284 request = get_current_request()
1285 1285 _use_gravatar = request.call_context.visual.use_gravatar
1286 1286 _gravatar_url = request.call_context.visual.gravatar_url
1287 1287
1288 1288 _gravatar_url = _gravatar_url or User.DEFAULT_GRAVATAR_URL
1289 1289
1290 1290 email_address = email_address or User.DEFAULT_USER_EMAIL
1291 1291 if isinstance(email_address, unicode):
1292 1292 # hashlib crashes on unicode items
1293 1293 email_address = safe_str(email_address)
1294 1294
1295 1295 # empty email or default user
1296 1296 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1297 1297 return initials_gravatar(User.DEFAULT_USER_EMAIL, '', '', size=size)
1298 1298
1299 1299 if _use_gravatar:
1300 1300 # TODO: Disuse pyramid thread locals. Think about another solution to
1301 1301 # get the host and schema here.
1302 1302 request = get_current_request()
1303 1303 tmpl = safe_str(_gravatar_url)
1304 1304 tmpl = tmpl.replace('{email}', email_address)\
1305 1305 .replace('{md5email}', md5_safe(email_address.lower())) \
1306 1306 .replace('{netloc}', request.host)\
1307 1307 .replace('{scheme}', request.scheme)\
1308 1308 .replace('{size}', safe_str(size))
1309 1309 return tmpl
1310 1310 else:
1311 1311 return initials_gravatar(email_address, '', '', size=size)
1312 1312
1313 1313
1314 1314 class Page(_Page):
1315 1315 """
1316 1316 Custom pager to match rendering style with paginator
1317 1317 """
1318 1318
1319 1319 def _get_pos(self, cur_page, max_page, items):
1320 1320 edge = (items / 2) + 1
1321 1321 if (cur_page <= edge):
1322 1322 radius = max(items / 2, items - cur_page)
1323 1323 elif (max_page - cur_page) < edge:
1324 1324 radius = (items - 1) - (max_page - cur_page)
1325 1325 else:
1326 1326 radius = items / 2
1327 1327
1328 1328 left = max(1, (cur_page - (radius)))
1329 1329 right = min(max_page, cur_page + (radius))
1330 1330 return left, cur_page, right
1331 1331
1332 1332 def _range(self, regexp_match):
1333 1333 """
1334 1334 Return range of linked pages (e.g. '1 2 [3] 4 5 6 7 8').
1335 1335
1336 1336 Arguments:
1337 1337
1338 1338 regexp_match
1339 1339 A "re" (regular expressions) match object containing the
1340 1340 radius of linked pages around the current page in
1341 1341 regexp_match.group(1) as a string
1342 1342
1343 1343 This function is supposed to be called as a callable in
1344 1344 re.sub.
1345 1345
1346 1346 """
1347 1347 radius = int(regexp_match.group(1))
1348 1348
1349 1349 # Compute the first and last page number within the radius
1350 1350 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
1351 1351 # -> leftmost_page = 5
1352 1352 # -> rightmost_page = 9
1353 1353 leftmost_page, _cur, rightmost_page = self._get_pos(self.page,
1354 1354 self.last_page,
1355 1355 (radius * 2) + 1)
1356 1356 nav_items = []
1357 1357
1358 1358 # Create a link to the first page (unless we are on the first page
1359 1359 # or there would be no need to insert '..' spacers)
1360 1360 if self.page != self.first_page and self.first_page < leftmost_page:
1361 1361 nav_items.append(self._pagerlink(self.first_page, self.first_page))
1362 1362
1363 1363 # Insert dots if there are pages between the first page
1364 1364 # and the currently displayed page range
1365 1365 if leftmost_page - self.first_page > 1:
1366 1366 # Wrap in a SPAN tag if nolink_attr is set
1367 1367 text = '..'
1368 1368 if self.dotdot_attr:
1369 1369 text = HTML.span(c=text, **self.dotdot_attr)
1370 1370 nav_items.append(text)
1371 1371
1372 1372 for thispage in xrange(leftmost_page, rightmost_page + 1):
1373 1373 # Hilight the current page number and do not use a link
1374 1374 if thispage == self.page:
1375 1375 text = '%s' % (thispage,)
1376 1376 # Wrap in a SPAN tag if nolink_attr is set
1377 1377 if self.curpage_attr:
1378 1378 text = HTML.span(c=text, **self.curpage_attr)
1379 1379 nav_items.append(text)
1380 1380 # Otherwise create just a link to that page
1381 1381 else:
1382 1382 text = '%s' % (thispage,)
1383 1383 nav_items.append(self._pagerlink(thispage, text))
1384 1384
1385 1385 # Insert dots if there are pages between the displayed
1386 1386 # page numbers and the end of the page range
1387 1387 if self.last_page - rightmost_page > 1:
1388 1388 text = '..'
1389 1389 # Wrap in a SPAN tag if nolink_attr is set
1390 1390 if self.dotdot_attr:
1391 1391 text = HTML.span(c=text, **self.dotdot_attr)
1392 1392 nav_items.append(text)
1393 1393
1394 1394 # Create a link to the very last page (unless we are on the last
1395 1395 # page or there would be no need to insert '..' spacers)
1396 1396 if self.page != self.last_page and rightmost_page < self.last_page:
1397 1397 nav_items.append(self._pagerlink(self.last_page, self.last_page))
1398 1398
1399 1399 ## prerender links
1400 1400 #_page_link = url.current()
1401 1401 #nav_items.append(literal('<link rel="prerender" href="%s?page=%s">' % (_page_link, str(int(self.page)+1))))
1402 1402 #nav_items.append(literal('<link rel="prefetch" href="%s?page=%s">' % (_page_link, str(int(self.page)+1))))
1403 1403 return self.separator.join(nav_items)
1404 1404
1405 1405 def pager(self, format='~2~', page_param='page', partial_param='partial',
1406 1406 show_if_single_page=False, separator=' ', onclick=None,
1407 1407 symbol_first='<<', symbol_last='>>',
1408 1408 symbol_previous='<', symbol_next='>',
1409 1409 link_attr={'class': 'pager_link', 'rel': 'prerender'},
1410 1410 curpage_attr={'class': 'pager_curpage'},
1411 1411 dotdot_attr={'class': 'pager_dotdot'}, **kwargs):
1412 1412
1413 1413 self.curpage_attr = curpage_attr
1414 1414 self.separator = separator
1415 1415 self.pager_kwargs = kwargs
1416 1416 self.page_param = page_param
1417 1417 self.partial_param = partial_param
1418 1418 self.onclick = onclick
1419 1419 self.link_attr = link_attr
1420 1420 self.dotdot_attr = dotdot_attr
1421 1421
1422 1422 # Don't show navigator if there is no more than one page
1423 1423 if self.page_count == 0 or (self.page_count == 1 and not show_if_single_page):
1424 1424 return ''
1425 1425
1426 1426 from string import Template
1427 1427 # Replace ~...~ in token format by range of pages
1428 1428 result = re.sub(r'~(\d+)~', self._range, format)
1429 1429
1430 1430 # Interpolate '%' variables
1431 1431 result = Template(result).safe_substitute({
1432 1432 'first_page': self.first_page,
1433 1433 'last_page': self.last_page,
1434 1434 'page': self.page,
1435 1435 'page_count': self.page_count,
1436 1436 'items_per_page': self.items_per_page,
1437 1437 'first_item': self.first_item,
1438 1438 'last_item': self.last_item,
1439 1439 'item_count': self.item_count,
1440 1440 'link_first': self.page > self.first_page and \
1441 1441 self._pagerlink(self.first_page, symbol_first) or '',
1442 1442 'link_last': self.page < self.last_page and \
1443 1443 self._pagerlink(self.last_page, symbol_last) or '',
1444 1444 'link_previous': self.previous_page and \
1445 1445 self._pagerlink(self.previous_page, symbol_previous) \
1446 1446 or HTML.span(symbol_previous, class_="pg-previous disabled"),
1447 1447 'link_next': self.next_page and \
1448 1448 self._pagerlink(self.next_page, symbol_next) \
1449 1449 or HTML.span(symbol_next, class_="pg-next disabled")
1450 1450 })
1451 1451
1452 1452 return literal(result)
1453 1453
1454 1454
1455 1455 #==============================================================================
1456 1456 # REPO PAGER, PAGER FOR REPOSITORY
1457 1457 #==============================================================================
1458 1458 class RepoPage(Page):
1459 1459
1460 1460 def __init__(self, collection, page=1, items_per_page=20,
1461 1461 item_count=None, url=None, **kwargs):
1462 1462
1463 1463 """Create a "RepoPage" instance. special pager for paging
1464 1464 repository
1465 1465 """
1466 1466 self._url_generator = url
1467 1467
1468 1468 # Safe the kwargs class-wide so they can be used in the pager() method
1469 1469 self.kwargs = kwargs
1470 1470
1471 1471 # Save a reference to the collection
1472 1472 self.original_collection = collection
1473 1473
1474 1474 self.collection = collection
1475 1475
1476 1476 # The self.page is the number of the current page.
1477 1477 # The first page has the number 1!
1478 1478 try:
1479 1479 self.page = int(page) # make it int() if we get it as a string
1480 1480 except (ValueError, TypeError):
1481 1481 self.page = 1
1482 1482
1483 1483 self.items_per_page = items_per_page
1484 1484
1485 1485 # Unless the user tells us how many items the collections has
1486 1486 # we calculate that ourselves.
1487 1487 if item_count is not None:
1488 1488 self.item_count = item_count
1489 1489 else:
1490 1490 self.item_count = len(self.collection)
1491 1491
1492 1492 # Compute the number of the first and last available page
1493 1493 if self.item_count > 0:
1494 1494 self.first_page = 1
1495 1495 self.page_count = int(math.ceil(float(self.item_count) /
1496 1496 self.items_per_page))
1497 1497 self.last_page = self.first_page + self.page_count - 1
1498 1498
1499 1499 # Make sure that the requested page number is the range of
1500 1500 # valid pages
1501 1501 if self.page > self.last_page:
1502 1502 self.page = self.last_page
1503 1503 elif self.page < self.first_page:
1504 1504 self.page = self.first_page
1505 1505
1506 1506 # Note: the number of items on this page can be less than
1507 1507 # items_per_page if the last page is not full
1508 1508 self.first_item = max(0, (self.item_count) - (self.page *
1509 1509 items_per_page))
1510 1510 self.last_item = ((self.item_count - 1) - items_per_page *
1511 1511 (self.page - 1))
1512 1512
1513 1513 self.items = list(self.collection[self.first_item:self.last_item + 1])
1514 1514
1515 1515 # Links to previous and next page
1516 1516 if self.page > self.first_page:
1517 1517 self.previous_page = self.page - 1
1518 1518 else:
1519 1519 self.previous_page = None
1520 1520
1521 1521 if self.page < self.last_page:
1522 1522 self.next_page = self.page + 1
1523 1523 else:
1524 1524 self.next_page = None
1525 1525
1526 1526 # No items available
1527 1527 else:
1528 1528 self.first_page = None
1529 1529 self.page_count = 0
1530 1530 self.last_page = None
1531 1531 self.first_item = None
1532 1532 self.last_item = None
1533 1533 self.previous_page = None
1534 1534 self.next_page = None
1535 1535 self.items = []
1536 1536
1537 1537 # This is a subclass of the 'list' type. Initialise the list now.
1538 1538 list.__init__(self, reversed(self.items))
1539 1539
1540 1540
1541 1541 def breadcrumb_repo_link(repo):
1542 1542 """
1543 1543 Makes a breadcrumbs path link to repo
1544 1544
1545 1545 ex::
1546 1546 group >> subgroup >> repo
1547 1547
1548 1548 :param repo: a Repository instance
1549 1549 """
1550 1550
1551 1551 path = [
1552 1552 link_to(group.name, route_path('repo_group_home', repo_group_name=group.group_name))
1553 1553 for group in repo.groups_with_parents
1554 1554 ] + [
1555 1555 link_to(repo.just_name, route_path('repo_summary', repo_name=repo.repo_name))
1556 1556 ]
1557 1557
1558 1558 return literal(' &raquo; '.join(path))
1559 1559
1560 1560
1561 1561 def format_byte_size_binary(file_size):
1562 1562 """
1563 1563 Formats file/folder sizes to standard.
1564 1564 """
1565 1565 if file_size is None:
1566 1566 file_size = 0
1567 1567
1568 1568 formatted_size = format_byte_size(file_size, binary=True)
1569 1569 return formatted_size
1570 1570
1571 1571
1572 1572 def urlify_text(text_, safe=True):
1573 1573 """
1574 1574 Extrac urls from text and make html links out of them
1575 1575
1576 1576 :param text_:
1577 1577 """
1578 1578
1579 1579 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1580 1580 '''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1581 1581
1582 1582 def url_func(match_obj):
1583 1583 url_full = match_obj.groups()[0]
1584 1584 return '<a href="%(url)s">%(url)s</a>' % ({'url': url_full})
1585 1585 _newtext = url_pat.sub(url_func, text_)
1586 1586 if safe:
1587 1587 return literal(_newtext)
1588 1588 return _newtext
1589 1589
1590 1590
1591 1591 def urlify_commits(text_, repository):
1592 1592 """
1593 1593 Extract commit ids from text and make link from them
1594 1594
1595 1595 :param text_:
1596 1596 :param repository: repo name to build the URL with
1597 1597 """
1598 1598
1599 1599 URL_PAT = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1600 1600
1601 1601 def url_func(match_obj):
1602 1602 commit_id = match_obj.groups()[1]
1603 1603 pref = match_obj.groups()[0]
1604 1604 suf = match_obj.groups()[2]
1605 1605
1606 1606 tmpl = (
1607 1607 '%(pref)s<a class="%(cls)s" href="%(url)s">'
1608 1608 '%(commit_id)s</a>%(suf)s'
1609 1609 )
1610 1610 return tmpl % {
1611 1611 'pref': pref,
1612 1612 'cls': 'revision-link',
1613 1613 'url': route_url('repo_commit', repo_name=repository,
1614 1614 commit_id=commit_id),
1615 1615 'commit_id': commit_id,
1616 1616 'suf': suf
1617 1617 }
1618 1618
1619 1619 newtext = URL_PAT.sub(url_func, text_)
1620 1620
1621 1621 return newtext
1622 1622
1623 1623
1624 1624 def _process_url_func(match_obj, repo_name, uid, entry,
1625 1625 return_raw_data=False, link_format='html'):
1626 1626 pref = ''
1627 1627 if match_obj.group().startswith(' '):
1628 1628 pref = ' '
1629 1629
1630 1630 issue_id = ''.join(match_obj.groups())
1631 1631
1632 1632 if link_format == 'html':
1633 1633 tmpl = (
1634 1634 '%(pref)s<a class="%(cls)s" href="%(url)s">'
1635 1635 '%(issue-prefix)s%(id-repr)s'
1636 1636 '</a>')
1637 1637 elif link_format == 'rst':
1638 1638 tmpl = '`%(issue-prefix)s%(id-repr)s <%(url)s>`_'
1639 1639 elif link_format == 'markdown':
1640 1640 tmpl = '[%(issue-prefix)s%(id-repr)s](%(url)s)'
1641 1641 else:
1642 1642 raise ValueError('Bad link_format:{}'.format(link_format))
1643 1643
1644 1644 (repo_name_cleaned,
1645 1645 parent_group_name) = RepoGroupModel().\
1646 1646 _get_group_name_and_parent(repo_name)
1647 1647
1648 1648 # variables replacement
1649 1649 named_vars = {
1650 1650 'id': issue_id,
1651 1651 'repo': repo_name,
1652 1652 'repo_name': repo_name_cleaned,
1653 1653 'group_name': parent_group_name
1654 1654 }
1655 1655 # named regex variables
1656 1656 named_vars.update(match_obj.groupdict())
1657 1657 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1658 1658
1659 1659 data = {
1660 1660 'pref': pref,
1661 1661 'cls': 'issue-tracker-link',
1662 1662 'url': _url,
1663 1663 'id-repr': issue_id,
1664 1664 'issue-prefix': entry['pref'],
1665 1665 'serv': entry['url'],
1666 1666 }
1667 1667 if return_raw_data:
1668 1668 return {
1669 1669 'id': issue_id,
1670 1670 'url': _url
1671 1671 }
1672 1672 return tmpl % data
1673 1673
1674 1674
1675 def process_patterns(text_string, repo_name, link_format='html'):
1676 allowed_formats = ['html', 'rst', 'markdown']
1677 if link_format not in allowed_formats:
1678 raise ValueError('Link format can be only one of:{} got {}'.format(
1679 allowed_formats, link_format))
1680
1675 def get_active_pattern_entries(repo_name):
1681 1676 repo = None
1682 1677 if repo_name:
1683 1678 # Retrieving repo_name to avoid invalid repo_name to explode on
1684 1679 # IssueTrackerSettingsModel but still passing invalid name further down
1685 1680 repo = Repository.get_by_repo_name(repo_name, cache=True)
1686 1681
1687 1682 settings_model = IssueTrackerSettingsModel(repo=repo)
1688 1683 active_entries = settings_model.get_settings(cache=True)
1684 return active_entries
1689 1685
1686
1687 def process_patterns(text_string, repo_name, link_format='html',
1688 active_entries=None):
1689
1690 allowed_formats = ['html', 'rst', 'markdown']
1691 if link_format not in allowed_formats:
1692 raise ValueError('Link format can be only one of:{} got {}'.format(
1693 allowed_formats, link_format))
1694
1695 active_entries = active_entries or get_active_pattern_entries(repo_name)
1690 1696 issues_data = []
1691 1697 newtext = text_string
1692 1698
1693 1699 for uid, entry in active_entries.items():
1694 1700 log.debug('found issue tracker entry with uid %s' % (uid,))
1695 1701
1696 1702 if not (entry['pat'] and entry['url']):
1697 1703 log.debug('skipping due to missing data')
1698 1704 continue
1699 1705
1700 1706 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s'
1701 1707 % (uid, entry['pat'], entry['url'], entry['pref']))
1702 1708
1703 1709 try:
1704 1710 pattern = re.compile(r'%s' % entry['pat'])
1705 1711 except re.error:
1706 1712 log.exception(
1707 1713 'issue tracker pattern: `%s` failed to compile',
1708 1714 entry['pat'])
1709 1715 continue
1710 1716
1711 1717 data_func = partial(
1712 1718 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1713 1719 return_raw_data=True)
1714 1720
1715 1721 for match_obj in pattern.finditer(text_string):
1716 1722 issues_data.append(data_func(match_obj))
1717 1723
1718 1724 url_func = partial(
1719 1725 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1720 1726 link_format=link_format)
1721 1727
1722 1728 newtext = pattern.sub(url_func, newtext)
1723 1729 log.debug('processed prefix:uid `%s`' % (uid,))
1724 1730
1725 1731 return newtext, issues_data
1726 1732
1727 1733
1728 def urlify_commit_message(commit_text, repository=None):
1734 def urlify_commit_message(commit_text, repository=None,
1735 active_pattern_entries=None):
1729 1736 """
1730 1737 Parses given text message and makes proper links.
1731 1738 issues are linked to given issue-server, and rest is a commit link
1732 1739
1733 1740 :param commit_text:
1734 1741 :param repository:
1735 1742 """
1736 1743 def escaper(string):
1737 1744 return string.replace('<', '&lt;').replace('>', '&gt;')
1738 1745
1739 1746 newtext = escaper(commit_text)
1740 1747
1741 1748 # extract http/https links and make them real urls
1742 1749 newtext = urlify_text(newtext, safe=False)
1743 1750
1744 1751 # urlify commits - extract commit ids and make link out of them, if we have
1745 1752 # the scope of repository present.
1746 1753 if repository:
1747 1754 newtext = urlify_commits(newtext, repository)
1748 1755
1749 1756 # process issue tracker patterns
1750 newtext, issues = process_patterns(newtext, repository or '')
1757 newtext, issues = process_patterns(newtext, repository or '',
1758 active_entries=active_pattern_entries)
1751 1759
1752 1760 return literal(newtext)
1753 1761
1754 1762
1755 1763 def render_binary(repo_name, file_obj):
1756 1764 """
1757 1765 Choose how to render a binary file
1758 1766 """
1759 1767 filename = file_obj.name
1760 1768
1761 1769 # images
1762 1770 for ext in ['*.png', '*.jpg', '*.ico', '*.gif']:
1763 1771 if fnmatch.fnmatch(filename, pat=ext):
1764 1772 alt = filename
1765 1773 src = route_path(
1766 1774 'repo_file_raw', repo_name=repo_name,
1767 1775 commit_id=file_obj.commit.raw_id, f_path=file_obj.path)
1768 1776 return literal('<img class="rendered-binary" alt="{}" src="{}">'.format(alt, src))
1769 1777
1770 1778
1771 1779 def renderer_from_filename(filename, exclude=None):
1772 1780 """
1773 1781 choose a renderer based on filename, this works only for text based files
1774 1782 """
1775 1783
1776 1784 # ipython
1777 1785 for ext in ['*.ipynb']:
1778 1786 if fnmatch.fnmatch(filename, pat=ext):
1779 1787 return 'jupyter'
1780 1788
1781 1789 is_markup = MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1782 1790 if is_markup:
1783 1791 return is_markup
1784 1792 return None
1785 1793
1786 1794
1787 1795 def render(source, renderer='rst', mentions=False, relative_urls=None,
1788 1796 repo_name=None):
1789 1797
1790 1798 def maybe_convert_relative_links(html_source):
1791 1799 if relative_urls:
1792 1800 return relative_links(html_source, relative_urls)
1793 1801 return html_source
1794 1802
1795 1803 if renderer == 'rst':
1796 1804 if repo_name:
1797 1805 # process patterns on comments if we pass in repo name
1798 1806 source, issues = process_patterns(
1799 1807 source, repo_name, link_format='rst')
1800 1808
1801 1809 return literal(
1802 1810 '<div class="rst-block">%s</div>' %
1803 1811 maybe_convert_relative_links(
1804 1812 MarkupRenderer.rst(source, mentions=mentions)))
1805 1813 elif renderer == 'markdown':
1806 1814 if repo_name:
1807 1815 # process patterns on comments if we pass in repo name
1808 1816 source, issues = process_patterns(
1809 1817 source, repo_name, link_format='markdown')
1810 1818
1811 1819 return literal(
1812 1820 '<div class="markdown-block">%s</div>' %
1813 1821 maybe_convert_relative_links(
1814 1822 MarkupRenderer.markdown(source, flavored=True,
1815 1823 mentions=mentions)))
1816 1824 elif renderer == 'jupyter':
1817 1825 return literal(
1818 1826 '<div class="ipynb">%s</div>' %
1819 1827 maybe_convert_relative_links(
1820 1828 MarkupRenderer.jupyter(source)))
1821 1829
1822 1830 # None means just show the file-source
1823 1831 return None
1824 1832
1825 1833
1826 1834 def commit_status(repo, commit_id):
1827 1835 return ChangesetStatusModel().get_status(repo, commit_id)
1828 1836
1829 1837
1830 1838 def commit_status_lbl(commit_status):
1831 1839 return dict(ChangesetStatus.STATUSES).get(commit_status)
1832 1840
1833 1841
1834 1842 def commit_time(repo_name, commit_id):
1835 1843 repo = Repository.get_by_repo_name(repo_name)
1836 1844 commit = repo.get_commit(commit_id=commit_id)
1837 1845 return commit.date
1838 1846
1839 1847
1840 1848 def get_permission_name(key):
1841 1849 return dict(Permission.PERMS).get(key)
1842 1850
1843 1851
1844 1852 def journal_filter_help(request):
1845 1853 _ = request.translate
1846 1854
1847 1855 return _(
1848 1856 'Example filter terms:\n' +
1849 1857 ' repository:vcs\n' +
1850 1858 ' username:marcin\n' +
1851 1859 ' username:(NOT marcin)\n' +
1852 1860 ' action:*push*\n' +
1853 1861 ' ip:127.0.0.1\n' +
1854 1862 ' date:20120101\n' +
1855 1863 ' date:[20120101100000 TO 20120102]\n' +
1856 1864 '\n' +
1857 1865 'Generate wildcards using \'*\' character:\n' +
1858 1866 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1859 1867 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1860 1868 '\n' +
1861 1869 'Optional AND / OR operators in queries\n' +
1862 1870 ' "repository:vcs OR repository:test"\n' +
1863 1871 ' "username:test AND repository:test*"\n'
1864 1872 )
1865 1873
1866 1874
1867 1875 def search_filter_help(searcher, request):
1868 1876 _ = request.translate
1869 1877
1870 1878 terms = ''
1871 1879 return _(
1872 1880 'Example filter terms for `{searcher}` search:\n' +
1873 1881 '{terms}\n' +
1874 1882 'Generate wildcards using \'*\' character:\n' +
1875 1883 ' "repo_name:vcs*" - search everything starting with \'vcs\'\n' +
1876 1884 ' "repo_name:*vcs*" - search for repository containing \'vcs\'\n' +
1877 1885 '\n' +
1878 1886 'Optional AND / OR operators in queries\n' +
1879 1887 ' "repo_name:vcs OR repo_name:test"\n' +
1880 1888 ' "owner:test AND repo_name:test*"\n' +
1881 1889 'More: {search_doc}'
1882 1890 ).format(searcher=searcher.name,
1883 1891 terms=terms, search_doc=searcher.query_lang_doc)
1884 1892
1885 1893
1886 1894 def not_mapped_error(repo_name):
1887 1895 from rhodecode.translation import _
1888 1896 flash(_('%s repository is not mapped to db perhaps'
1889 1897 ' it was created or renamed from the filesystem'
1890 1898 ' please run the application again'
1891 1899 ' in order to rescan repositories') % repo_name, category='error')
1892 1900
1893 1901
1894 1902 def ip_range(ip_addr):
1895 1903 from rhodecode.model.db import UserIpMap
1896 1904 s, e = UserIpMap._get_ip_range(ip_addr)
1897 1905 return '%s - %s' % (s, e)
1898 1906
1899 1907
1900 1908 def form(url, method='post', needs_csrf_token=True, **attrs):
1901 1909 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1902 1910 if method.lower() != 'get' and needs_csrf_token:
1903 1911 raise Exception(
1904 1912 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1905 1913 'CSRF token. If the endpoint does not require such token you can ' +
1906 1914 'explicitly set the parameter needs_csrf_token to false.')
1907 1915
1908 1916 return wh_form(url, method=method, **attrs)
1909 1917
1910 1918
1911 1919 def secure_form(form_url, method="POST", multipart=False, **attrs):
1912 1920 """Start a form tag that points the action to an url. This
1913 1921 form tag will also include the hidden field containing
1914 1922 the auth token.
1915 1923
1916 1924 The url options should be given either as a string, or as a
1917 1925 ``url()`` function. The method for the form defaults to POST.
1918 1926
1919 1927 Options:
1920 1928
1921 1929 ``multipart``
1922 1930 If set to True, the enctype is set to "multipart/form-data".
1923 1931 ``method``
1924 1932 The method to use when submitting the form, usually either
1925 1933 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1926 1934 hidden input with name _method is added to simulate the verb
1927 1935 over POST.
1928 1936
1929 1937 """
1930 1938 from webhelpers.pylonslib.secure_form import insecure_form
1931 1939
1932 1940 if 'request' in attrs:
1933 1941 session = attrs['request'].session
1934 1942 del attrs['request']
1935 1943 else:
1936 1944 raise ValueError(
1937 1945 'Calling this form requires request= to be passed as argument')
1938 1946
1939 1947 form = insecure_form(form_url, method, multipart, **attrs)
1940 1948 token = literal(
1941 1949 '<input type="hidden" id="{}" name="{}" value="{}">'.format(
1942 1950 csrf_token_key, csrf_token_key, get_csrf_token(session)))
1943 1951
1944 1952 return literal("%s\n%s" % (form, token))
1945 1953
1946 1954
1947 1955 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
1948 1956 select_html = select(name, selected, options, **attrs)
1949 1957 select2 = """
1950 1958 <script>
1951 1959 $(document).ready(function() {
1952 1960 $('#%s').select2({
1953 1961 containerCssClass: 'drop-menu',
1954 1962 dropdownCssClass: 'drop-menu-dropdown',
1955 1963 dropdownAutoWidth: true%s
1956 1964 });
1957 1965 });
1958 1966 </script>
1959 1967 """
1960 1968 filter_option = """,
1961 1969 minimumResultsForSearch: -1
1962 1970 """
1963 1971 input_id = attrs.get('id') or name
1964 1972 filter_enabled = "" if enable_filter else filter_option
1965 1973 select_script = literal(select2 % (input_id, filter_enabled))
1966 1974
1967 1975 return literal(select_html+select_script)
1968 1976
1969 1977
1970 1978 def get_visual_attr(tmpl_context_var, attr_name):
1971 1979 """
1972 1980 A safe way to get a variable from visual variable of template context
1973 1981
1974 1982 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
1975 1983 :param attr_name: name of the attribute we fetch from the c.visual
1976 1984 """
1977 1985 visual = getattr(tmpl_context_var, 'visual', None)
1978 1986 if not visual:
1979 1987 return
1980 1988 else:
1981 1989 return getattr(visual, attr_name, None)
1982 1990
1983 1991
1984 1992 def get_last_path_part(file_node):
1985 1993 if not file_node.path:
1986 1994 return u''
1987 1995
1988 1996 path = safe_unicode(file_node.path.split('/')[-1])
1989 1997 return u'../' + path
1990 1998
1991 1999
1992 2000 def route_url(*args, **kwargs):
1993 2001 """
1994 2002 Wrapper around pyramids `route_url` (fully qualified url) function.
1995 2003 """
1996 2004 req = get_current_request()
1997 2005 return req.route_url(*args, **kwargs)
1998 2006
1999 2007
2000 2008 def route_path(*args, **kwargs):
2001 2009 """
2002 2010 Wrapper around pyramids `route_path` function.
2003 2011 """
2004 2012 req = get_current_request()
2005 2013 return req.route_path(*args, **kwargs)
2006 2014
2007 2015
2008 2016 def route_path_or_none(*args, **kwargs):
2009 2017 try:
2010 2018 return route_path(*args, **kwargs)
2011 2019 except KeyError:
2012 2020 return None
2013 2021
2014 2022
2015 2023 def current_route_path(request, **kw):
2016 2024 new_args = request.GET.mixed()
2017 2025 new_args.update(kw)
2018 2026 return request.current_route_path(_query=new_args)
2019 2027
2020 2028
2021 2029 def api_call_example(method, args):
2022 2030 """
2023 2031 Generates an API call example via CURL
2024 2032 """
2025 2033 args_json = json.dumps(OrderedDict([
2026 2034 ('id', 1),
2027 2035 ('auth_token', 'SECRET'),
2028 2036 ('method', method),
2029 2037 ('args', args)
2030 2038 ]))
2031 2039 return literal(
2032 2040 "curl {api_url} -X POST -H 'content-type:text/plain' --data-binary '{data}'"
2033 2041 "<br/><br/>SECRET can be found in <a href=\"{token_url}\">auth-tokens</a> page, "
2034 2042 "and needs to be of `api calls` role."
2035 2043 .format(
2036 2044 api_url=route_url('apiv2'),
2037 2045 token_url=route_url('my_account_auth_tokens'),
2038 2046 data=args_json))
2039 2047
2040 2048
2041 2049 def notification_description(notification, request):
2042 2050 """
2043 2051 Generate notification human readable description based on notification type
2044 2052 """
2045 2053 from rhodecode.model.notification import NotificationModel
2046 2054 return NotificationModel().make_description(
2047 2055 notification, translate=request.translate)
2048 2056
2049 2057
2050 2058 def go_import_header(request, db_repo=None):
2051 2059 """
2052 2060 Creates a header for go-import functionality in Go Lang
2053 2061 """
2054 2062
2055 2063 if not db_repo:
2056 2064 return
2057 2065 if 'go-get' not in request.GET:
2058 2066 return
2059 2067
2060 2068 clone_url = db_repo.clone_url()
2061 2069 prefix = re.split(r'^https?:\/\/', clone_url)[-1]
2062 2070 # we have a repo and go-get flag,
2063 2071 return literal('<meta name="go-import" content="{} {} {}">'.format(
2064 2072 prefix, db_repo.repo_type, clone_url))
@@ -1,146 +1,152 b''
1 1 ## small box that displays changed/added/removed details fetched by AJAX
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3
4 4
5 5 % if c.prev_page:
6 6 <tr>
7 7 <td colspan="9" class="load-more-commits">
8 8 <a class="prev-commits" href="#loadPrevCommits" onclick="commitsController.loadPrev(this, ${c.prev_page}, '${c.branch_name}', '${c.commit_id}', '${c.f_path}');return false">
9 9 ${_('load previous')}
10 10 </a>
11 11 </td>
12 12 </tr>
13 13 % endif
14 14
15 ## to speed up lookups cache some functions before the loop
16 <%
17 active_patterns = h.get_active_pattern_entries(c.repo_name)
18 urlify_commit_message = h.partial(h.urlify_commit_message, active_pattern_entries=active_patterns)
19 %>
20
15 21 % for cnt,commit in enumerate(c.pagination):
16 22 <tr id="sha_${commit.raw_id}" class="changelogRow container ${'tablerow%s' % (cnt%2)}">
17 23
18 24 <td class="td-checkbox">
19 25 ${h.checkbox(commit.raw_id,class_="commit-range")}
20 26 </td>
21 27 <td class="td-status">
22 28
23 29 %if c.statuses.get(commit.raw_id):
24 30 <div class="changeset-status-ico">
25 31 %if c.statuses.get(commit.raw_id)[2]:
26 32 <a class="tooltip" title="${_('Commit status: %s\nClick to open associated pull request #%s') % (h.commit_status_lbl(c.statuses.get(commit.raw_id)[0]), c.statuses.get(commit.raw_id)[2])}" href="${h.route_path('pullrequest_show',repo_name=c.statuses.get(commit.raw_id)[3],pull_request_id=c.statuses.get(commit.raw_id)[2])}">
27 33 <div class="${'flag_status {}'.format(c.statuses.get(commit.raw_id)[0])}"></div>
28 34 </a>
29 35 %else:
30 36 <a class="tooltip" title="${_('Commit status: {}').format(h.commit_status_lbl(c.statuses.get(commit.raw_id)[0]))}" href="${h.route_path('repo_commit',repo_name=c.repo_name,commit_id=commit.raw_id,_anchor='comment-%s' % c.comments[commit.raw_id][0].comment_id)}">
31 37 <div class="${'flag_status {}'.format(c.statuses.get(commit.raw_id)[0])}"></div>
32 38 </a>
33 39 %endif
34 40 </div>
35 41 %else:
36 42 <div class="tooltip flag_status not_reviewed" title="${_('Commit status: Not Reviewed')}"></div>
37 43 %endif
38 44 </td>
39 45 <td class="td-comments comments-col">
40 46 %if c.comments.get(commit.raw_id):
41 47 <a title="${_('Commit has comments')}" href="${h.route_path('repo_commit',repo_name=c.repo_name,commit_id=commit.raw_id,_anchor='comment-%s' % c.comments[commit.raw_id][0].comment_id)}">
42 48 <i class="icon-comment"></i> ${len(c.comments[commit.raw_id])}
43 49 </a>
44 50 %endif
45 51 </td>
46 52 <td class="td-hash">
47 53 <code>
48 54
49 55 <a href="${h.route_path('repo_commit',repo_name=c.repo_name,commit_id=commit.raw_id)}">
50 56 <span class="${'commit_hash obsolete' if getattr(commit, 'obsolete', None) else 'commit_hash'}">${h.show_id(commit)}</span>
51 57 </a>
52 58 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${commit.raw_id}" title="${_('Copy the full commit id')}"></i>
53 59 </code>
54 60 </td>
55 61 <td class="td-tags tags-col">
56 62 ## phase
57 63 % if hasattr(commit, 'phase'):
58 64 % if commit.phase != 'public':
59 65 <span class="tag phase-${commit.phase} tooltip" title="${_('Commit phase')}">${commit.phase}</span>
60 66 % endif
61 67 % endif
62 68
63 69 ## obsolete commits
64 70 % if hasattr(commit, 'obsolete'):
65 71 % if commit.obsolete:
66 72 <span class="tag obsolete-${commit.obsolete} tooltip" title="${_('Evolve State')}">${_('obsolete')}</span>
67 73 % endif
68 74 % endif
69 75
70 76 ## hidden commits
71 77 % if hasattr(commit, 'hidden'):
72 78 % if commit.hidden:
73 79 <span class="tag obsolete-${commit.hidden} tooltip" title="${_('Evolve State')}">${_('hidden')}</span>
74 80 % endif
75 81 % endif
76 82 </td>
77 83 <td class="td-message expand_commit" data-commit-id="${commit.raw_id}" title="${_('Expand commit message')}" onclick="commitsController.expandCommit(this); return false">
78 84 <div class="show_more_col">
79 85 <i class="show_more"></i>&nbsp;
80 86 </div>
81 87 </td>
82 88 <td class="td-description mid">
83 89 <div class="log-container truncate-wrap">
84 <div class="message truncate" id="c-${commit.raw_id}">${h.urlify_commit_message(commit.message, c.repo_name)}</div>
90 <div class="message truncate" id="c-${commit.raw_id}">${urlify_commit_message(commit.message, c.repo_name)}</div>
85 91 </div>
86 92 </td>
87 93
88 94 <td class="td-time">
89 95 ${h.age_component(commit.date)}
90 96 </td>
91 97 <td class="td-user">
92 98 ${base.gravatar_with_user(commit.author)}
93 99 </td>
94 100
95 101 <td class="td-tags tags-col">
96 102 <div id="t-${commit.raw_id}">
97 103
98 104 ## merge
99 105 %if commit.merge:
100 106 <span class="tag mergetag">
101 107 <i class="icon-merge"></i>${_('merge')}
102 108 </span>
103 109 %endif
104 110
105 111 ## branch
106 112 %if commit.branch:
107 113 <span class="tag branchtag" title="${h.tooltip(_('Branch %s') % commit.branch)}">
108 114 <a href="${h.route_path('repo_changelog',repo_name=c.repo_name,_query=dict(branch=commit.branch))}"><i class="icon-code-fork"></i>${h.shorter(commit.branch)}</a>
109 115 </span>
110 116 %endif
111 117
112 118 ## bookmarks
113 119 %if h.is_hg(c.rhodecode_repo):
114 120 %for book in commit.bookmarks:
115 121 <span class="tag booktag" title="${h.tooltip(_('Bookmark %s') % book)}">
116 122 <a href="${h.route_path('repo_files:default_path',repo_name=c.repo_name,commit_id=commit.raw_id, _query=dict(at=book))}"><i class="icon-bookmark"></i>${h.shorter(book)}</a>
117 123 </span>
118 124 %endfor
119 125 %endif
120 126
121 127 ## tags
122 128 %for tag in commit.tags:
123 129 <span class="tag tagtag" title="${h.tooltip(_('Tag %s') % tag)}">
124 130 <a href="${h.route_path('repo_files:default_path',repo_name=c.repo_name,commit_id=commit.raw_id, _query=dict(at=tag))}"><i class="icon-tag"></i>${h.shorter(tag)}</a>
125 131 </span>
126 132 %endfor
127 133
128 134 </div>
129 135 </td>
130 136 </tr>
131 137 % endfor
132 138
133 139 % if c.next_page:
134 140 <tr>
135 141 <td colspan="10" class="load-more-commits">
136 142 <a class="next-commits" href="#loadNextCommits" onclick="commitsController.loadNext(this, ${c.next_page}, '${c.branch_name}', '${c.commit_id}', '${c.f_path}');return false">
137 143 ${_('load next')}
138 144 </a>
139 145 </td>
140 146 </tr>
141 147 % endif
142 148 <tr class="chunk-graph-data" style="display:none"
143 149 data-graph='${c.graph_data|n}'
144 150 data-node='${c.prev_page}:${c.next_page}'
145 151 data-commits='${c.graph_commits|n}'>
146 152 </tr> No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now